From 4069cda022053acb2583e2b405ed5fd8f5b05f83 Mon Sep 17 00:00:00 2001
From: Arbyhisenaj <41119392+Arbyhisenaj@users.noreply.github.com>
Date: Thu, 29 Jan 2026 11:23:15 +0000
Subject: [PATCH 1/2] init
---
.../components/MarketingNavbar.tsx | 127 +------
src/app/(marketing)/layout.tsx | 9 +-
src/app/(marketing)/page.tsx | 7 +-
src/app/(marketing)/solutions/[slug]/page.tsx | 142 +++++---
.../components/SolutionsTableOfContents.tsx | 196 +++++++++++
src/app/(marketing)/solutions/page.tsx | 322 ++++++++++++++++++
.../HomepageFeatureSectionVideos.tsx | 128 -------
src/components/marketing/HomepageFeatures.tsx | 178 ++++++++++
.../marketing/HomepageSolutionsSection.tsx | 101 ++++++
src/components/marketing/MuxVideoPlayer.tsx | 25 +-
src/components/typography.tsx | 2 +-
src/sanity/schemaTypes/features.ts | 24 +-
src/sanity/schemaTypes/featuresNew.ts | 129 +++++++
src/sanity/schemaTypes/index.ts | 8 +-
src/sanity/schemaTypes/solutions.ts | 18 +-
src/sanity/structure.ts | 64 ++--
tsconfig.json | 16 +-
17 files changed, 1120 insertions(+), 376 deletions(-)
create mode 100644 src/app/(marketing)/solutions/components/SolutionsTableOfContents.tsx
create mode 100644 src/app/(marketing)/solutions/page.tsx
delete mode 100644 src/components/marketing/HomepageFeatureSectionVideos.tsx
create mode 100644 src/components/marketing/HomepageFeatures.tsx
create mode 100644 src/components/marketing/HomepageSolutionsSection.tsx
create mode 100644 src/sanity/schemaTypes/featuresNew.ts
diff --git a/src/app/(marketing)/components/MarketingNavbar.tsx b/src/app/(marketing)/components/MarketingNavbar.tsx
index e115f873..6152a826 100644
--- a/src/app/(marketing)/components/MarketingNavbar.tsx
+++ b/src/app/(marketing)/components/MarketingNavbar.tsx
@@ -1,40 +1,25 @@
"use client";
-import { ChevronDown, ChevronRight } from "lucide-react";
import Image from "next/image";
import { useState } from "react";
import { Link } from "@/components/Link";
-import { urlFor } from "@/sanity/lib/image";
-import { Badge } from "@/shadcn/ui/badge";
import { Button } from "@/shadcn/ui/button";
import {
NavigationMenu,
- NavigationMenuContent,
- NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
- NavigationMenuTrigger,
navigationMenuTriggerStyle,
} from "@/shadcn/ui/navigation-menu";
import { cn } from "@/shadcn/utils";
import type { CurrentUser } from "@/authTypes";
-interface Solution {
- _id: string;
- title: string;
- subtitle: string;
- status: "active" | "archived" | "coming-soon";
- slug: { current: string };
- position: number;
- icon: string;
-}
-
interface NavItem {
label: string;
href: string;
}
const NAV_ITEMS: NavItem[] = [
+ { label: "Solutions", href: "/solutions" },
{ label: "Docs", href: "/docs" },
{ label: "News", href: "/news" },
{ label: "About", href: "/about" },
@@ -42,13 +27,10 @@ const NAV_ITEMS: NavItem[] = [
export const MarketingNavbar = ({
currentUser,
- solutions,
}: {
currentUser: CurrentUser | null;
- solutions: Solution[];
}) => {
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
- const [isSolutionsOpen, setIsSolutionsOpen] = useState(false);
return (
<>
@@ -67,7 +49,7 @@ export const MarketingNavbar = ({
{/* Desktop Navigation */}
-
+
{/* Mobile Menu Button */}
@@ -119,61 +101,7 @@ export const MarketingNavbar = ({
onClick={(e) => e.stopPropagation()}
>
- {/* Solutions Accordion */}
-
-
- {isSolutionsOpen && (
-
- {solutions.length > 0 ? (
- solutions
- .sort((a, b) => a.position - b.position)
- .map((solution) => (
-
setIsMobileMenuOpen(false)}
- >
-
-
-
- {solution.title}
- {solution.status === "coming-soon" && (
- Coming soon
- )}
-
-
- {solution.subtitle}
-
-
-
- ))
- ) : (
-
- No solutions available
-
- )}
-
- )}
-
-
- {/* Other Navigation Items */}
+ {/* Navigation Items */}
{NAV_ITEMS.map((item) => (
{
+const DesktopNavbar = () => {
return (
-
- Solutions
-
-
- {solutions.length > 0 ? (
- solutions
- .sort((a, b) => a.position - b.position)
- .map((solution) => (
- -
-
-
-
-
-
- {solution.title}
- {solution.status === "coming-soon" && (
- Coming soon
- )}
-
-
- {solution.subtitle}
-
-
-
-
-
- ))
- ) : (
- -
- No solutions available
-
- )}
-
-
-
{NAV_ITEMS.map((item) => (
-
+
{children}
diff --git a/src/app/(marketing)/page.tsx b/src/app/(marketing)/page.tsx
index 4eec7572..77c13bfa 100644
--- a/src/app/(marketing)/page.tsx
+++ b/src/app/(marketing)/page.tsx
@@ -1,6 +1,7 @@
import Image from "next/image";
import { Link } from "@/components/Link";
-import HomepageFeatureSectionVideos from "@/components/marketing/HomepageFeatureSectionVideos";
+import HomepageFeatures from "@/components/marketing/HomepageFeatures";
+import HomepageSolutionsSection from "@/components/marketing/HomepageSolutionsSection";
import { Button } from "@/shadcn/ui/button";
export default function HomePage() {
@@ -46,7 +47,9 @@ export default function HomePage() {
-
+
+
+
>
);
}
diff --git a/src/app/(marketing)/solutions/[slug]/page.tsx b/src/app/(marketing)/solutions/[slug]/page.tsx
index 854bb116..4ec04089 100644
--- a/src/app/(marketing)/solutions/[slug]/page.tsx
+++ b/src/app/(marketing)/solutions/[slug]/page.tsx
@@ -14,12 +14,20 @@ import { client } from "@/sanity/lib/client";
import { urlFor } from "@/sanity/lib/image";
import { Badge } from "@/shadcn/ui/badge";
import { Button } from "@/shadcn/ui/button";
+import MuxVideoPlayer from "@/components/marketing/MuxVideoPlayer";
-interface SolutionArray {
+interface Feature {
+ _id: string;
title: string;
description: string;
image?: string;
- status: string;
+ video?: {
+ asset: {
+ playbackId: string;
+ status: string;
+ data: Record;
+ };
+ };
button?: {
text: string;
url: string;
@@ -39,10 +47,18 @@ const POST_QUERY = `*[_type == "solutions" && slug.current == $slug][0]{
position,
publishedAt,
status,
- solutionsArray[]{
+ features[]->{
+ _id,
title,
description,
image,
+ video{
+ asset->{
+ playbackId,
+ status,
+ data
+ }
+ },
button{
text,
linkType,
@@ -118,79 +134,105 @@ export default async function SolutionPage({
{/* Content */}
- {solution.solutionsArray && solution.solutionsArray.length > 0 ? (
- solution.solutionsArray.map(
- (solution: SolutionArray, index: number) => (
-
- ),
- )
- ) : (
-
- No solutions available
-
- This solution page doesn't have any content yet.
-
-
- )}
+ {(() => {
+ const validFeatures = (solution.features?.filter(
+ (f: Feature | null): f is Feature => f !== null && f._id !== undefined
+ ) || []) as Feature[];
+
+ return validFeatures.length > 0 ? (
+ validFeatures.map(
+ (feature: Feature, index: number) => (
+
+ ),
+ )
+ ) : (
+
+ No features available
+
+ This solution doesn't have any features yet.
+
+
+ );
+ })()}
>
);
}
-function SolutionItemCard({
- solutionItem,
+function FeatureCard({
+ feature,
isReversed,
}: {
- solutionItem: SolutionArray;
+ feature: Feature;
isReversed: boolean;
}) {
+ const playbackId = feature.video?.asset?.playbackId;
+
const textContent = (
-
{solutionItem.title}
+
{feature.title}
- {solutionItem.description?.split("\n").map((paragraph, index) => (
+ {feature.description?.split("\n").map((paragraph, index) => (
{paragraph}
))}
- {solutionItem.button && (
-
- )}
+ {feature.button && (() => {
+ let href: string | null = null;
+
+ if (feature.button.linkType === "docs") {
+ if (feature.button.docsPage?.slug?.current) {
+ href = `/docs/${feature.button.docsPage.slug.current}`;
+ }
+ } else {
+ href = feature.button.url || null;
+ }
+
+ if (!href) return null;
+
+ return (
+
+ );
+ })()}
);
- const imageContent = (
+ const mediaContent = (
- {solutionItem.image ? (
+ {playbackId ? (
+
+
+
+ ) : feature.image ? (
) : (
)}
@@ -198,9 +240,9 @@ function SolutionItemCard({
return (
- {/* Mobile: Always image first, then text */}
+ {/* Mobile: Always media first, then text */}
- {imageContent}
+ {mediaContent}
{textContent}
@@ -208,13 +250,13 @@ function SolutionItemCard({
{isReversed ? (
<>
- {imageContent}
+ {mediaContent}
{textContent}
>
) : (
<>
{textContent}
- {imageContent}
+ {mediaContent}
>
)}
diff --git a/src/app/(marketing)/solutions/components/SolutionsTableOfContents.tsx b/src/app/(marketing)/solutions/components/SolutionsTableOfContents.tsx
new file mode 100644
index 00000000..a53eccb7
--- /dev/null
+++ b/src/app/(marketing)/solutions/components/SolutionsTableOfContents.tsx
@@ -0,0 +1,196 @@
+"use client";
+
+import Image from "next/image";
+import { useEffect, useState } from "react";
+import { urlFor } from "@/sanity/lib/image";
+import { TypographyH2 } from "@/components/typography";
+import Container from "@/components/layout/Container";
+
+interface Solution {
+ _id: string;
+ title: string;
+ slug: { current: string };
+ icon: string;
+}
+
+interface SolutionsTableOfContentsProps {
+ solutions: Solution[];
+}
+
+// Generate slug-friendly IDs for anchor links
+const getSolutionId = (slug: string) => {
+ return slug.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
+};
+
+export default function SolutionsTableOfContents({
+ solutions,
+}: SolutionsTableOfContentsProps) {
+ const [activeId, setActiveId] = useState
("");
+
+ useEffect(() => {
+ const observerOptions = {
+ rootMargin: "-100px 0px -60% 0px",
+ threshold: [0, 0.25, 0.5, 0.75, 1],
+ };
+
+ const observerCallback = (entries: IntersectionObserverEntry[]) => {
+ // Find the entry with the highest intersection ratio that's intersecting
+ const intersectingEntries = entries.filter((entry) => entry.isIntersecting);
+
+ if (intersectingEntries.length === 0) {
+ // If nothing is intersecting, find the closest section above the viewport
+ const allElements = solutions.map((solution) => {
+ const solutionId = getSolutionId(
+ solution.slug?.current || solution.title,
+ );
+ return document.getElementById(solutionId);
+ }).filter(Boolean) as HTMLElement[];
+
+ const viewportTop = window.scrollY + 200; // Account for sticky header + TOC
+ let closestElement: HTMLElement | null = null;
+ let closestDistance = Infinity;
+
+ allElements.forEach((element) => {
+ const elementTop = element.getBoundingClientRect().top + window.scrollY;
+ const distance = Math.abs(elementTop - viewportTop);
+
+ if (elementTop <= viewportTop && distance < closestDistance) {
+ closestDistance = distance;
+ closestElement = element;
+ }
+ });
+
+ if (closestElement) {
+ setActiveId(closestElement.id);
+ }
+ return;
+ }
+
+ // Sort by intersection ratio (highest first), then by position in viewport
+ intersectingEntries.sort((a, b) => {
+ if (b.intersectionRatio !== a.intersectionRatio) {
+ return b.intersectionRatio - a.intersectionRatio;
+ }
+ // If ratios are equal, prefer the one higher up in the viewport
+ return a.boundingClientRect.top - b.boundingClientRect.top;
+ });
+
+ // Set the most visible intersecting section as active
+ if (intersectingEntries.length > 0) {
+ setActiveId(intersectingEntries[0].target.id);
+ }
+ };
+
+ const observer = new IntersectionObserver(
+ observerCallback,
+ observerOptions,
+ );
+
+ // Observe all solution sections
+ solutions.forEach((solution) => {
+ const solutionId = getSolutionId(
+ solution.slug?.current || solution.title,
+ );
+ const element = document.getElementById(solutionId);
+ if (element) {
+ observer.observe(element);
+ }
+ });
+
+ // Also handle scroll events to update active state when clicking links
+ const handleScroll = () => {
+ const allElements = solutions.map((solution) => {
+ const solutionId = getSolutionId(
+ solution.slug?.current || solution.title,
+ );
+ return document.getElementById(solutionId);
+ }).filter(Boolean) as HTMLElement[];
+
+ const viewportTop = window.scrollY + 200;
+ let activeElement: HTMLElement | null = null;
+ let minDistance = Infinity;
+
+ allElements.forEach((element) => {
+ const rect = element.getBoundingClientRect();
+ const elementTop = rect.top + window.scrollY;
+ const distance = Math.abs(elementTop - viewportTop);
+
+ // Prefer elements that are at or above the viewport top
+ if (rect.top <= 200 && distance < minDistance) {
+ minDistance = distance;
+ activeElement = element;
+ }
+ });
+
+ if (activeElement) {
+ setActiveId(activeElement.id);
+ }
+ };
+
+ window.addEventListener("scroll", handleScroll, { passive: true });
+
+ return () => {
+ solutions.forEach((solution) => {
+ const solutionId = getSolutionId(
+ solution.slug?.current || solution.title,
+ );
+ const element = document.getElementById(solutionId);
+ if (element) {
+ observer.unobserve(element);
+ }
+ });
+ window.removeEventListener("scroll", handleScroll);
+ };
+ }, [solutions]);
+
+ return (
+
+
+
+
+
+ );
+}
+
diff --git a/src/app/(marketing)/solutions/page.tsx b/src/app/(marketing)/solutions/page.tsx
new file mode 100644
index 00000000..f0c718be
--- /dev/null
+++ b/src/app/(marketing)/solutions/page.tsx
@@ -0,0 +1,322 @@
+import Image from "next/image";
+import { type SanityDocument } from "next-sanity";
+import React from "react";
+import Container from "@/components/layout/Container";
+import { Link } from "@/components/Link";
+import {
+ TypographyH1,
+ TypographyH2,
+ TypographyLead,
+ TypographyMuted,
+ TypographyP,
+} from "@/components/typography";
+import { client } from "@/sanity/lib/client";
+import { urlFor } from "@/sanity/lib/image";
+import { Badge } from "@/shadcn/ui/badge";
+import { Button } from "@/shadcn/ui/button";
+import { Separator } from "@/shadcn/ui/separator";
+import MuxVideoPlayer from "@/components/marketing/MuxVideoPlayer";
+import SolutionsTableOfContents from "./components/SolutionsTableOfContents";
+
+const SOLUTIONS_QUERY = `*[_type == "solutions"] | order(position asc){
+ _id,
+ title,
+ subtitle,
+ slug,
+ position,
+ status,
+ icon,
+ features[]->{
+ _id,
+ title,
+ description,
+ image,
+ video{
+ asset->{
+ playbackId,
+ status,
+ data
+ }
+ },
+ button{
+ text,
+ linkType,
+ url,
+ docsPage->{
+ slug
+ }
+ }
+ } | order(_createdAt asc)
+}`;
+
+const options = { next: { revalidate: 30 } };
+
+interface Feature {
+ _id: string;
+ title: string;
+ description: string;
+ image?: string;
+ video?: {
+ asset: {
+ playbackId: string;
+ status: string;
+ data: Record;
+ };
+ };
+ button?: {
+ text: string;
+ url: string;
+ linkType: string;
+ docsPage?: {
+ slug: {
+ current: string;
+ };
+ };
+ };
+}
+
+interface Solution {
+ _id: string;
+ title: string;
+ subtitle: string;
+ slug: { current: string };
+ position: number;
+ status: "active" | "archived" | "coming-soon";
+ icon: string;
+ features?: (Feature | null)[];
+}
+
+export default async function SolutionsPage() {
+ const solutions = await client.fetch(
+ SOLUTIONS_QUERY,
+ {},
+ options,
+ );
+
+ // Debug: Log solutions to see what we're getting
+ // Uncomment to debug:
+ // console.log("Solutions data:", JSON.stringify(solutions, null, 2));
+
+ // Generate slug-friendly IDs for anchor links
+ const getSolutionId = (slug: string) => {
+ return slug.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
+ };
+
+ return (
+ <>
+ {/* Hero */}
+
+
+
+ Solutions
+
+ Explore our solutions designed to enhance your organising strategy
+ with visual mapping tools.
+
+
+
+
+
+
+
+
+
+ {/* Table of Contents */}
+ {solutions.length > 0 && (
+
+ )}
+
+ {/* Solutions Content */}
+
+ {solutions.length > 0 ? (
+
+ {solutions.map((solution) => {
+ const solutionId = getSolutionId(
+ solution.slug?.current || solution.title,
+ );
+ return (
+
+ {/* Solution Header */}
+
+
+
+
+
+
+ {solution.title}
+
+ {solution.status === "coming-soon" && (
+
+ Coming soon
+
+ )}
+
+ {solution.subtitle && (
+
+ {solution.subtitle}
+
+ )}
+
+
+
+
+ {/* Solution Content */}
+ {(() => {
+ const validFeatures = (solution.features?.filter(
+ (f: Feature | null): f is Feature => f !== null && f._id !== undefined
+ ) || []) as Feature[];
+
+ return validFeatures.length > 0 ? (
+
+ {validFeatures.map(
+ (feature: Feature, index: number) => (
+
+ ),
+ )}
+
+ ) : (
+
+
+ This solution doesn't have any features yet.
+
+
+ );
+ })()}
+
+ );
+ })}
+
+ ) : (
+
+ No solutions available
+
+ Solutions will appear here once they're added.
+
+
+ )}
+
+ >
+ );
+}
+
+function FeatureCard({
+ feature,
+ isReversed,
+}: {
+ feature: Feature;
+ isReversed: boolean;
+}) {
+ const playbackId = feature.video?.asset?.playbackId;
+
+ const textContent = (
+
+
{feature.title}
+
+ {feature.description?.split("\n").map((paragraph, index) => (
+
+ {paragraph}
+
+ ))}
+
+ {feature.button && (() => {
+ let href: string | null = null;
+
+ if (feature.button.linkType === "docs") {
+ if (feature.button.docsPage?.slug?.current) {
+ href = `/docs/${feature.button.docsPage.slug.current}`;
+ }
+ } else {
+ href = feature.button.url || null;
+ }
+
+ if (!href) return null;
+
+ return (
+
+ );
+ })()}
+
+ );
+
+ const mediaContent = (
+
+ {playbackId ? (
+
+
+
+ ) : feature.image ? (
+
+ ) : (
+
+ )}
+
+ );
+
+ return (
+
+ {/* Mobile: Always media first, then text */}
+
+ {mediaContent}
+ {textContent}
+
+
+ {/* Desktop: Respect isReversed logic */}
+
+ {isReversed ? (
+ <>
+ {mediaContent}
+ {textContent}
+ >
+ ) : (
+ <>
+ {textContent}
+ {mediaContent}
+ >
+ )}
+
+
+ );
+}
+
diff --git a/src/components/marketing/HomepageFeatureSectionVideos.tsx b/src/components/marketing/HomepageFeatureSectionVideos.tsx
deleted file mode 100644
index a09a9a1e..00000000
--- a/src/components/marketing/HomepageFeatureSectionVideos.tsx
+++ /dev/null
@@ -1,128 +0,0 @@
-import React from "react";
-import RichTextComponent from "@/app/(marketing)/components/RichTextComponent";
-import Container from "@/components/layout/Container";
-import { client } from "@/sanity/lib/client";
-import MuxVideoPlayer from "./MuxVideoPlayer";
-
-interface RichTextBlock {
- _key: string;
- _type: string;
- children: {
- _key: string;
- _type: string;
- text: string;
- marks?: string[];
- }[];
- markDefs?: {
- _key: string;
- _type: string;
- href?: string;
- }[];
- style?: string;
-}
-
-interface HomepageVideo {
- _id: string;
- title: string;
- description: RichTextBlock[];
- video: {
- asset: {
- playbackId: string;
- status: string;
- data: Record;
- };
- };
- order: number;
-}
-
-const homepageVideosQuery = `*[_type == "homepageVideos"] | order(order asc) {
- _id,
- title,
- description,
- video {
- asset->{
- playbackId,
- status,
- data
- }
- },
- order
-}`;
-
-export default async function HomepageFeatureSectionVideos() {
- const homepageVideos = await client.fetch(homepageVideosQuery);
- if (!homepageVideos || homepageVideos.length === 0) {
- return No homepage videos found
;
- }
- return (
-
-
- {homepageVideos.map((video: HomepageVideo, index: number) => (
-
- ))}
-
-
- );
-}
-
-function FeatureCardVideos({
- title,
- description,
- video,
- alternate,
-}: {
- title: string;
- description: RichTextBlock[];
- video: {
- asset: {
- playbackId: string;
- status: string;
- data: Record;
- };
- };
- alternate?: boolean;
-}) {
- const playbackId = video?.asset?.playbackId;
-
- return (
-
-
-
- {playbackId ? (
-
- ) : (
-
- )}
-
-
- );
-}
diff --git a/src/components/marketing/HomepageFeatures.tsx b/src/components/marketing/HomepageFeatures.tsx
new file mode 100644
index 00000000..58ad0509
--- /dev/null
+++ b/src/components/marketing/HomepageFeatures.tsx
@@ -0,0 +1,178 @@
+import Image from "next/image";
+import React from "react";
+import Container from "@/components/layout/Container";
+import { Link } from "@/components/Link";
+import { client } from "@/sanity/lib/client";
+import { urlFor } from "@/sanity/lib/image";
+import { Button } from "@/shadcn/ui/button";
+import MuxVideoPlayer from "@/components/marketing/MuxVideoPlayer";
+import {
+ TypographyH2,
+ TypographyH3,
+ TypographyP,
+} from "@/components/typography";
+
+const HOMEPAGE_FEATURES_QUERY = `*[_type == "features" && showOnHomepage == true] | order(homepageOrder asc, _createdAt asc) {
+ _id,
+ title,
+ description,
+ image,
+ video{
+ asset->{
+ playbackId,
+ status,
+ data
+ }
+ },
+ button{
+ text,
+ linkType,
+ url,
+ docsPage->{
+ slug
+ }
+ },
+ solution->{
+ _id,
+ title,
+ slug
+ }
+}`;
+
+interface Feature {
+ _id: string;
+ title: string;
+ description: string;
+ image?: string;
+ video?: {
+ asset: {
+ playbackId: string;
+ status: string;
+ data: Record;
+ };
+ };
+ button?: {
+ text: string;
+ url: string;
+ linkType: string;
+ docsPage?: {
+ slug: {
+ current: string;
+ };
+ };
+ };
+ solution?: {
+ _id: string;
+ title: string;
+ slug: { current: string };
+ };
+}
+
+const options = { next: { revalidate: 30 } };
+
+export default async function HomepageFeatures() {
+ const features = await client.fetch(
+ HOMEPAGE_FEATURES_QUERY,
+ {},
+ options,
+ );
+
+ if (!features || features.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+
+ Explore Our Features
+
+
+
+ {features.map((feature, index) => (
+
+ ))}
+
+
+
+ );
+}
+
+function FeatureCard({
+ feature,
+ alternate,
+}: {
+ feature: Feature;
+ alternate?: boolean;
+}) {
+ let href: string | null = null;
+
+ if (feature.button) {
+ if (feature.button.linkType === "docs") {
+ if (feature.button.docsPage?.slug?.current) {
+ href = `/docs/${feature.button.docsPage.slug.current}`;
+ }
+ } else {
+ href = feature.button.url || null;
+ }
+ } else if (feature.solution) {
+ href = `/solutions/${feature.solution.slug?.current || feature.solution._id}`;
+ }
+
+ const playbackId = feature.video?.asset?.playbackId;
+
+ return (
+
+
+
+
+ {feature.title}
+
+
+
+ {feature.description}
+
+
+ {href && (feature.button || feature.solution) && (
+
+ )}
+
+
+
+ {playbackId ? (
+
+ ) : feature.image ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
+
diff --git a/src/components/marketing/HomepageSolutionsSection.tsx b/src/components/marketing/HomepageSolutionsSection.tsx
new file mode 100644
index 00000000..5c2e1a95
--- /dev/null
+++ b/src/components/marketing/HomepageSolutionsSection.tsx
@@ -0,0 +1,101 @@
+import Image from "next/image";
+import React from "react";
+import Container from "@/components/layout/Container";
+import { Link } from "@/components/Link";
+import { client } from "@/sanity/lib/client";
+import { urlFor } from "@/sanity/lib/image";
+import { Button } from "@/shadcn/ui/button";
+import {
+ TypographyH2,
+ TypographyP,
+} from "@/components/typography";
+
+const HOMEPAGE_SOLUTIONS_QUERY = `*[_type == "solutions" && status != "archived"] | order(position asc) [0...6] {
+ _id,
+ title,
+ subtitle,
+ slug,
+ icon
+}`;
+
+interface Solution {
+ _id: string;
+ title: string;
+ subtitle: string;
+ slug: { current: string };
+ icon?: string;
+}
+
+const options = { next: { revalidate: 30 } };
+
+// Generate slug-friendly IDs for anchor links (same as solutions page)
+const getSolutionId = (slug: string) => {
+ return slug.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
+};
+
+export default async function HomepageSolutionsSection() {
+ const solutions = await client.fetch(
+ HOMEPAGE_SOLUTIONS_QUERY,
+ {},
+ options,
+ );
+
+ if (!solutions || solutions.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+
+ Solutions
+
+ Discover our comprehensive solutions designed to enhance your organising strategy.
+
+
+
+ {solutions.map((solution) => {
+ const solutionId = getSolutionId(
+ solution.slug?.current || solution.title,
+ );
+ return (
+
+ {solution.icon && (
+
+
+
+ )}
+
+
+ {solution.title}
+
+
+ {solution.subtitle}
+
+
+
+ );
+ })}
+
+
+
+
+
+
+ );
+}
+
diff --git a/src/components/marketing/MuxVideoPlayer.tsx b/src/components/marketing/MuxVideoPlayer.tsx
index ce40d2a4..1d8a9d26 100644
--- a/src/components/marketing/MuxVideoPlayer.tsx
+++ b/src/components/marketing/MuxVideoPlayer.tsx
@@ -1,7 +1,5 @@
"use client";
-import MuxPlayer from "@mux/mux-player-react";
-
interface MuxVideoPlayerProps {
playbackId: string;
className?: string;
@@ -17,26 +15,21 @@ export default function MuxVideoPlayer({
loop = true,
muted = true,
}: MuxVideoPlayerProps) {
+ // Build the iframe URL with query parameters
+ const iframeUrl = `https://player.mux.com/${playbackId}?autoplay=${autoplay ? "true" : "false"}&loop=${loop ? "true" : "false"}&muted=${muted ? "true" : "false"}`;
+
return (
-
);
}
diff --git a/src/components/typography.tsx b/src/components/typography.tsx
index 04284957..14c15fc9 100644
--- a/src/components/typography.tsx
+++ b/src/components/typography.tsx
@@ -35,7 +35,7 @@ export function TypographyH3({ children, className }: TypographyProps) {
return (
diff --git a/src/sanity/schemaTypes/features.ts b/src/sanity/schemaTypes/features.ts
index cb266240..fef50654 100644
--- a/src/sanity/schemaTypes/features.ts
+++ b/src/sanity/schemaTypes/features.ts
@@ -1,9 +1,9 @@
import { defineField, defineType } from "sanity";
import { getTextFromBlocks } from "../../sanity/helpers";
-export const featureSetType = defineType({
- name: "featureSet",
- title: "Feature Set",
+export const docsSetType = defineType({
+ name: "docsSet",
+ title: "Docs Set",
type: "document",
fields: [
defineField({
@@ -37,9 +37,9 @@ export const featureSetType = defineType({
},
});
-export const featureType = defineType({
- name: "feature",
- title: "Feature",
+export const docsType = defineType({
+ name: "docs",
+ title: "Docs",
type: "document",
fields: [
defineField({
@@ -216,10 +216,10 @@ export const featureType = defineType({
],
}),
defineField({
- name: "featureSet",
+ name: "docsSet",
type: "reference",
- to: [{ type: "featureSet" }],
- description: "The feature set this feature belongs to",
+ to: [{ type: "docsSet" }],
+ description: "The docs set this doc belongs to",
}),
defineField({
name: "order",
@@ -231,14 +231,14 @@ export const featureType = defineType({
select: {
title: "title",
subtitle: "subtitle",
- featureSet: "featureSet.title",
+ docsSet: "docsSet.title",
status: "status",
},
prepare(selection) {
- const { title, subtitle, featureSet, status } = selection;
+ const { title, subtitle, docsSet, status } = selection;
return {
title,
- subtitle: featureSet ? `${featureSet} - ${subtitle || ""}` : subtitle,
+ subtitle: docsSet ? `${docsSet} - ${subtitle || ""}` : subtitle,
media:
status === "active"
? "Active"
diff --git a/src/sanity/schemaTypes/featuresNew.ts b/src/sanity/schemaTypes/featuresNew.ts
new file mode 100644
index 00000000..15561e53
--- /dev/null
+++ b/src/sanity/schemaTypes/featuresNew.ts
@@ -0,0 +1,129 @@
+import { defineField, defineType } from "sanity";
+
+export const featuresType = defineType({
+ name: "features",
+ title: "Features",
+ type: "document",
+ fields: [
+ defineField({
+ name: "showOnHomepage",
+ type: "boolean",
+ title: "Show on Homepage",
+ initialValue: false,
+ description: "Display this feature on the homepage",
+ }),
+ defineField({
+ name: "homepageOrder",
+ type: "number",
+ title: "Homepage Order",
+ description: "Controls the order this feature appears on the homepage (lower = earlier).",
+ initialValue: 0,
+ hidden: ({ parent }) => !parent?.showOnHomepage,
+ validation: (rule) => rule.min(0).integer(),
+ }),
+ defineField({
+ name: "title",
+ type: "string",
+ validation: (rule) => rule.required(),
+ }),
+ defineField({
+ name: "description",
+ type: "text",
+ validation: (rule) => rule.required(),
+ }),
+ defineField({
+ name: "image",
+ type: "image",
+ title: "Image",
+ options: {
+ hotspot: true,
+ },
+ description: "Use either an image or a video (not both required)",
+ }),
+ defineField({
+ name: "video",
+ type: "mux.video",
+ title: "Video",
+ description:
+ "Upload a new video or select from existing videos in your Mux account. Use either an image or a video.",
+ options: {
+ // Allow selecting from existing Mux videos
+ selectExisting: true,
+ },
+ }),
+ defineField({
+ name: "button",
+ type: "object",
+ title: "Button (Optional)",
+ fields: [
+ defineField({
+ name: "text",
+ type: "string",
+ }),
+ defineField({
+ name: "linkType",
+ type: "string",
+ title: "Link Type",
+ options: {
+ list: [
+ { title: "External URL", value: "external" },
+ { title: "Internal Docs Page", value: "docs" },
+ ],
+ layout: "dropdown",
+ },
+
+ }),
+ defineField({
+ name: "url",
+ type: "url",
+ title: "External URL",
+ hidden: ({ parent }) => parent?.linkType !== "external",
+ validation: (rule) =>
+ rule.custom((value, context) => {
+ const parent = context.parent as { linkType?: string };
+ if (parent?.linkType === "external" && !value) {
+ return "External URL is required when link type is external";
+ }
+ return true;
+ }),
+ }),
+ defineField({
+ name: "docsPage",
+ type: "reference",
+ title: "Docs Page",
+ to: [{ type: "docs" }],
+ hidden: ({ parent }) => parent?.linkType !== "docs",
+ validation: (rule) =>
+ rule.custom((value, context) => {
+ const parent = context.parent as { linkType?: string };
+ if (parent?.linkType === "docs" && !value) {
+ return "Docs page is required when link type is docs";
+ }
+ return true;
+ }),
+ }),
+ ],
+ }),
+ ],
+ preview: {
+ select: {
+ title: "title",
+ subtitle: "description",
+ media: "image",
+ hasVideo: "video.asset.playbackId",
+ },
+ prepare(selection) {
+ const { title, subtitle, media, hasVideo } = selection;
+ return {
+ title: title || "Untitled Feature",
+ subtitle: hasVideo
+ ? `[Video] ${subtitle ? subtitle.substring(0, 40) + "..." : "No description"}`
+ : subtitle
+ ? subtitle.substring(0, 50) + "..."
+ : "No description",
+ media: media || undefined,
+ };
+ },
+ },
+});
+
diff --git a/src/sanity/schemaTypes/index.ts b/src/sanity/schemaTypes/index.ts
index a4eae88a..a1a607e7 100644
--- a/src/sanity/schemaTypes/index.ts
+++ b/src/sanity/schemaTypes/index.ts
@@ -1,7 +1,8 @@
import { type SchemaTypeDefinition } from "sanity";
import { aboutType } from "./about";
import { blockContentType } from "./blockContent";
-import { featureSetType, featureType } from "./features";
+import { docsSetType, docsType } from "./features";
+import { featuresType } from "./featuresNew";
import { homepageVideosType } from "./homepageVideos";
import { newsSchema } from "./news";
import { solutionsType } from "./solutions";
@@ -11,8 +12,9 @@ export const schema: { types: SchemaTypeDefinition[] } = {
types: [
blockContentType,
solutionsType,
- featureSetType,
- featureType,
+ docsSetType,
+ docsType,
+ featuresType,
newsSchema,
youtubeType,
aboutType,
diff --git a/src/sanity/schemaTypes/solutions.ts b/src/sanity/schemaTypes/solutions.ts
index 1328d2ac..3016af6c 100644
--- a/src/sanity/schemaTypes/solutions.ts
+++ b/src/sanity/schemaTypes/solutions.ts
@@ -52,17 +52,29 @@ export const solutionsType = defineType({
validation: (rule) => rule.required(),
description: "Position in the list (lower numbers appear first)",
}),
-
defineField({
name: "publishedAt",
type: "datetime",
initialValue: () => new Date().toISOString(),
validation: (rule) => rule.required(),
}),
+ defineField({
+ name: "features",
+ title: "Features",
+ type: "array",
+ description: "Reference features documents to display in this solution",
+ of: [
+ {
+ type: "reference",
+ to: [{ type: "features" }],
+ },
+ ],
+ }),
defineField({
name: "solutionsArray",
- title: "Solutions Array",
+ title: "Solutions Array (Legacy)",
type: "array",
+ description: "Legacy field - consider using Features references instead",
of: [
{
type: "object",
@@ -121,7 +133,7 @@ export const solutionsType = defineType({
name: "docsPage",
type: "reference",
title: "Docs Page",
- to: [{ type: "feature" }],
+ to: [{ type: "docs" }],
hidden: ({ parent }) => parent?.linkType !== "docs",
validation: (rule) =>
rule.custom((value, context) => {
diff --git a/src/sanity/structure.ts b/src/sanity/structure.ts
index a54fc90f..412eaa86 100644
--- a/src/sanity/structure.ts
+++ b/src/sanity/structure.ts
@@ -1,57 +1,57 @@
-import { FileText, Info, Newspaper, Puzzle, Video } from "lucide-react";
+import { FileText, Folder, Info, Newspaper, Puzzle, Video } from "lucide-react";
import { map } from "rxjs";
import type { StructureResolver } from "sanity/structure";
// https://www.sanity.io/docs/structure-builder-cheat-sheet
export const structure: StructureResolver = (S, context) => {
- // Helper function to create parent-child structure for features
- const featureHierarchy = () => {
- const filter = `_type == "featureSet" && !(_id in path("drafts.**"))`;
+ // Helper function to create parent-child structure for docs
+ const docsHierarchy = () => {
+ const filter = `_type == "docsSet" && !(_id in path("drafts.**"))`;
const query = `*[${filter}]{ _id, title, order } | order(order asc)`;
const options = { apiVersion: "2025-09-04" };
return context.documentStore.listenQuery(query, {}, options).pipe(
- map((featureSets: { _id: string; title: string }[]) =>
+ map((docsSets: { _id: string; title: string }[]) =>
S.list()
- .title("Feature Sets")
+ .title("Docs Sets")
.items([
- // Create a list item for each feature set
- ...featureSets.map((featureSet: { _id: string; title: string }) =>
+ // Create a list item for each docs set
+ ...docsSets.map((docsSet: { _id: string; title: string }) =>
S.listItem({
- id: featureSet._id,
- title: featureSet.title,
- schemaType: "featureSet",
+ id: docsSet._id,
+ title: docsSet.title,
+ schemaType: "docsSet",
child: () =>
S.documentList()
- .title(featureSet.title)
+ .title(docsSet.title)
.filter(
- `_type == "feature" && featureSet._ref == $featureSetId`,
+ `_type == "docs" && docsSet._ref == $docsSetId`,
)
- .params({ featureSetId: featureSet._id })
+ .params({ docsSetId: docsSet._id })
.canHandleIntent(
(intentName, params) =>
intentName === "create" &&
- params.template === "feature-child",
+ params.template === "docs-child",
)
.initialValueTemplates([
- S.initialValueTemplateItem("feature-child", {
- featureSetId: featureSet._id,
+ S.initialValueTemplateItem("docs-child", {
+ docsSetId: docsSet._id,
}),
]),
}),
),
S.divider(),
- // Show all feature sets
+ // Show all docs sets
S.listItem()
- .title("All Feature Sets")
+ .title("All Docs Sets")
.child(
- S.documentTypeList("featureSet").title("All Feature Sets"),
+ S.documentTypeList("docsSet").title("All Docs Sets"),
),
- // Show all features
+ // Show all docs
S.listItem()
- .title("All Feature Items")
- .child(S.documentTypeList("feature").title("All Feature Items")),
+ .title("All Docs Items")
+ .child(S.documentTypeList("docs").title("All Docs Items")),
]),
),
);
@@ -60,13 +60,14 @@ export const structure: StructureResolver = (S, context) => {
return S.list()
.title("Content")
.items([
- // Features section with dynamic hierarchy
- S.listItem().title("Features").icon(FileText).child(featureHierarchy),
+ // Docs section with dynamic hierarchy
+ S.listItem().title("Docs").icon(FileText).child(docsHierarchy),
+
// Solutions section
S.listItem()
.title("Solutions")
- .icon(Puzzle)
+ .icon(Folder)
.child(
S.documentTypeList("solutions")
.title("Solutions")
@@ -75,6 +76,17 @@ export const structure: StructureResolver = (S, context) => {
{ field: "_createdAt", direction: "desc" },
]),
),
+ // Features section
+ S.listItem()
+ .title("Features")
+ .icon(Puzzle)
+ .child(
+ S.documentTypeList("features")
+ .title("Features")
+ .defaultOrdering([
+ { field: "_createdAt", direction: "desc" },
+ ]),
+ ),
// News section
S.listItem()
diff --git a/tsconfig.json b/tsconfig.json
index 40424caa..6dfb703d 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "ES2017",
- "lib": ["dom", "dom.iterable", "esnext"],
+ "lib": [
+ "dom",
+ "dom.iterable",
+ "esnext"
+ ],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -11,7 +15,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
- "jsx": "react-jsx",
+ "jsx": "preserve",
"incremental": true,
"plugins": [
{
@@ -19,7 +23,9 @@
}
],
"paths": {
- "@/*": ["./src/*"]
+ "@/*": [
+ "./src/*"
+ ]
}
},
"include": [
@@ -30,5 +36,7 @@
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
- "exclude": ["node_modules"]
+ "exclude": [
+ "node_modules"
+ ]
}
From 186602760f8469c00d7262214bba59ec8aba1c9b Mon Sep 17 00:00:00 2001
From: Arbyhisenaj <41119392+Arbyhisenaj@users.noreply.github.com>
Date: Thu, 29 Jan 2026 11:57:21 +0000
Subject: [PATCH 2/2] lint fix
---
eslint.config.mjs | 2 -
.../components/SolutionsTableOfContents.tsx | 42 +++++++++++--------
tsconfig.json | 2 +-
3 files changed, 25 insertions(+), 21 deletions(-)
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 4537ea9c..c8498b31 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -1,6 +1,4 @@
import { defineConfig, globalIgnores } from "eslint/config";
-import nextVitals from "eslint-config-next/core-web-vitals";
-import nextTypeScript from "eslint-config-next/typescript";
import unusedImports from "eslint-plugin-unused-imports";
import tseslint from "typescript-eslint";
diff --git a/src/app/(marketing)/solutions/components/SolutionsTableOfContents.tsx b/src/app/(marketing)/solutions/components/SolutionsTableOfContents.tsx
index a53eccb7..c8a06366 100644
--- a/src/app/(marketing)/solutions/components/SolutionsTableOfContents.tsx
+++ b/src/app/(marketing)/solutions/components/SolutionsTableOfContents.tsx
@@ -39,18 +39,20 @@ export default function SolutionsTableOfContents({
if (intersectingEntries.length === 0) {
// If nothing is intersecting, find the closest section above the viewport
- const allElements = solutions.map((solution) => {
- const solutionId = getSolutionId(
- solution.slug?.current || solution.title,
- );
- return document.getElementById(solutionId);
- }).filter(Boolean) as HTMLElement[];
+ const allElements = solutions
+ .map((solution) => {
+ const solutionId = getSolutionId(
+ solution.slug?.current || solution.title,
+ );
+ return document.getElementById(solutionId);
+ })
+ .filter((el): el is HTMLElement => el !== null);
const viewportTop = window.scrollY + 200; // Account for sticky header + TOC
let closestElement: HTMLElement | null = null;
let closestDistance = Infinity;
- allElements.forEach((element) => {
+ for (const element of allElements) {
const elementTop = element.getBoundingClientRect().top + window.scrollY;
const distance = Math.abs(elementTop - viewportTop);
@@ -58,10 +60,11 @@ export default function SolutionsTableOfContents({
closestDistance = distance;
closestElement = element;
}
- });
+ }
if (closestElement) {
- setActiveId(closestElement.id);
+ const elementId = closestElement.id;
+ setActiveId(elementId);
}
return;
}
@@ -99,18 +102,20 @@ export default function SolutionsTableOfContents({
// Also handle scroll events to update active state when clicking links
const handleScroll = () => {
- const allElements = solutions.map((solution) => {
- const solutionId = getSolutionId(
- solution.slug?.current || solution.title,
- );
- return document.getElementById(solutionId);
- }).filter(Boolean) as HTMLElement[];
+ const allElements = solutions
+ .map((solution) => {
+ const solutionId = getSolutionId(
+ solution.slug?.current || solution.title,
+ );
+ return document.getElementById(solutionId);
+ })
+ .filter((el): el is HTMLElement => el !== null);
const viewportTop = window.scrollY + 200;
let activeElement: HTMLElement | null = null;
let minDistance = Infinity;
- allElements.forEach((element) => {
+ for (const element of allElements) {
const rect = element.getBoundingClientRect();
const elementTop = rect.top + window.scrollY;
const distance = Math.abs(elementTop - viewportTop);
@@ -120,10 +125,11 @@ export default function SolutionsTableOfContents({
minDistance = distance;
activeElement = element;
}
- });
+ }
if (activeElement) {
- setActiveId(activeElement.id);
+ const elementId = activeElement.id;
+ setActiveId(elementId);
}
};
diff --git a/tsconfig.json b/tsconfig.json
index 6dfb703d..1a24779e 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -15,7 +15,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
- "jsx": "preserve",
+ "jsx": "react-jsx",
"incremental": true,
"plugins": [
{