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)/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.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.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 ? ( {solutionItem.title} ) : ( {solutionItem.title} )}
@@ -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..c8a06366 --- /dev/null +++ b/src/app/(marketing)/solutions/components/SolutionsTableOfContents.tsx @@ -0,0 +1,202 @@ +"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((el): el is HTMLElement => el !== null); + + const viewportTop = window.scrollY + 200; // Account for sticky header + TOC + let closestElement: HTMLElement | null = null; + let closestDistance = Infinity; + + for (const element of allElements) { + const elementTop = element.getBoundingClientRect().top + window.scrollY; + const distance = Math.abs(elementTop - viewportTop); + + if (elementTop <= viewportTop && distance < closestDistance) { + closestDistance = distance; + closestElement = element; + } + } + + if (closestElement) { + const elementId = closestElement.id; + setActiveId(elementId); + } + 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((el): el is HTMLElement => el !== null); + + const viewportTop = window.scrollY + 200; + let activeElement: HTMLElement | null = null; + let minDistance = Infinity; + + for (const element of allElements) { + 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) { + const elementId = activeElement.id; + setActiveId(elementId); + } + }; + + 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. + +
+
+ +
+ Mapped +
+
+ + {/* 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.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 ? ( + {feature.title} + ) : ( + {feature.title} + )} +
+ ); + + 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 ( -
-
-
-

- {title} -

- - -
-
-
- {playbackId ? ( - - ) : ( -
-

No video available

-
- )} -
-
- ); -} 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 ? ( + {feature.title} + ) : ( +
+

No media available

+
+ )} +
+
+ ); +} + 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.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..1a24779e 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, @@ -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" + ] }