diff --git a/src/app/customers/page.tsx b/src/app/customers/page.tsx index 350282a7..3afc1e3f 100644 --- a/src/app/customers/page.tsx +++ b/src/app/customers/page.tsx @@ -5,33 +5,10 @@ import { CustomersCta } from "@/components/customers/customers-cta"; import { GRID_LINE, GridDivider, GridRails } from "@/components/ui/grid-lines"; import { CAPTERRA_URL, G2_URL } from "@/lib/constants"; import { PAGE_SEO, pageMetadata } from "@/lib/seo"; +import { CapterraLogo, G2Logo } from "@/components/customers/review-marks"; export const metadata: Metadata = pageMetadata(PAGE_SEO.customers); -// Capterra mark, monochrome (inherits currentColor). -function CapterraLogo({ className = "" }: { className?: string }) { - return ( - - - - - - - ); -} - -// G2 wordmark, monochrome (inherits currentColor). Shown in place of the "G2" -// text on desktop; mobile keeps the plain text. -function G2Logo({ className = "" }: { className?: string }) { - return ( - - - - - - ); -} - export default function CustomersPage() { return ( <> @@ -47,26 +24,26 @@ export default function CustomersPage() { Trusted by professional service firms with 1M+ clients and counting.

- {/* Review-score strip. NOTE: placeholder ratings — confirm/replace with - real G2 / Capterra numbers before launch. */} + {/* Review-score strip. G2 4.7 and Capterra 4.8, current as of + 2026-10-19; the template pages' proof strip quotes the same. */}
- 4.8 rating + 4.7 rating - 4.7 rating + 4.8 rating
diff --git a/src/app/templates/[slug]/page.tsx b/src/app/templates/[slug]/page.tsx index f04277e1..e5be5102 100644 --- a/src/app/templates/[slug]/page.tsx +++ b/src/app/templates/[slug]/page.tsx @@ -3,19 +3,24 @@ import Link from "next/link"; import { notFound } from "next/navigation"; import { pageMetadata } from "@/lib/seo"; import { ogImageFor } from "@/lib/og"; +import { serializeJsonLd } from "@/lib/json-ld"; +import { DEMO_CTA_LABEL, DEMO_URL, SITE_URL } from "@/lib/constants"; import { getCatalogueTemplate, getCatalogueTemplates, + getVisibleTemplates, } from "@/lib/visible-templates"; -import { getAppTemplate } from "@/lib/contentful"; +import { getAppTemplate, getAppTemplates, type AppTemplateEntry } from "@/lib/contentful"; +import { getTemplateBySlug, type Template } from "@/lib/templates"; +import { parseBenefits, templateCopy } from "@/lib/template-page"; import { TemplateOverview } from "@/components/templates/template-overview"; -import { - TEMPLATE_CUSTOMIZATION as CUSTOMIZABLE, - getTemplateBySlug, - type Template, -} from "@/lib/templates"; -import { TemplateGallery } from "@/components/templates/template-gallery"; -import { TemplateCta } from "@/components/templates/template-cta"; +import { TemplateUseCta } from "@/components/templates/template-cta"; +import { TemplateCard } from "@/components/templates/template-card"; +import { BrowserFrame, TemplateShot } from "@/components/templates/detail/template-shot"; +import { TemplateProof } from "@/components/templates/detail/template-proof"; +import { TemplateHowItWorks } from "@/components/templates/detail/template-how-it-works"; +import { TemplateStickyCta } from "@/components/templates/detail/template-sticky-cta"; +import { FAQ } from "@/components/home/faq"; interface Props { params: Promise<{ slug: string }>; @@ -36,94 +41,23 @@ export async function generateStaticParams() { return (await getCatalogueTemplates()).map((t) => ({ slug: t.slug })); } -export async function generateMetadata({ params }: Props): Promise { - const { slug } = await params; - const template = (await getCatalogueTemplate(slug)) ?? getTemplateBySlug(slug); - if (!template) return {}; - const entry = await getAppTemplate(slug); - const name = entry?.name ?? template.title; - return pageMetadata( - { - title: name, - // The card description is a fragment ("Track records through stages"); - // the long form reads as a sentence in a search result. - description: template.longDescription || template.description, - path: `/templates/${template.slug}`, - }, - // A template's preview names the template. Shared across every surface that - // unfurls the link, so what someone sees before clicking is what they get. - ogImageFor(name), - ); -} +const HERO_CTA_ID = "template-hero-cta"; +const WORKS_WITH_COUNT = 4; + +const PRIMARY_BUTTON = + "rounded-lg bg-foreground px-5 py-2.5 text-center text-sm text-background transition-opacity hover:opacity-90"; +const SECONDARY_BUTTON = + "rounded-lg border border-foreground/20 bg-transparent px-5 py-2.5 text-center text-sm text-foreground transition-colors hover:bg-foreground/5"; /** - * Breadcrumb, title, description, industry tags, and the primary CTA. Rendered - * twice with responsive visibility: first in the flow on mobile (so the title - * leads the page), and inside the sticky sidebar on large screens. + * The committed record merged with its Contentful entry, the same way the page + * always has: the catalogue first, so a template that lives only in Contentful + * resolves; the committed array second, so a slug the CMS has since dropped + * keeps its page rather than 404ing on an editorial change. */ -function TemplateHeader({ - template, - className = "", -}: { - template: Template; - className?: string; -}) { - return ( -
- - -

- {template.title} -

-

- {template.description} -

- - {/* The template's category, not the industries it serves — the catalogue - browses by category, and industries aren't a facet we show anywhere. */} -
- {template.usesAI && ( - - AI - - )} - - {template.category} - -
- -
- -
-
- ); -} - -export default async function TemplateDetailPage({ params }: Props) { - const { slug } = await params; - // The catalogue first, so a template that lives only in Contentful resolves; - // the committed array second, so a slug the CMS has since dropped keeps its - // page rather than 404ing on an editorial change. +async function resolveTemplate(slug: string) { const local = (await getCatalogueTemplate(slug)) ?? getTemplateBySlug(slug); - if (!local) notFound(); - - // Contentful owns this template if an App Template entry exists for the slug; - // the rest still render from templates.ts. That lets the catalogue fill in one - // entry at a time without the gallery ever going half-empty. + if (!local) return null; const entry = await getAppTemplate(slug); const template: Template = entry ? { @@ -135,98 +69,315 @@ export default async function TemplateDetailPage({ params }: Props) { // templates the CMS doesn't hold yet. templateId: entry.templateId ?? local.templateId, category: entry.category ?? local.category, - images: entry.images.length - ? entry.images.map((image) => image.url) - : local.images, - // Once Contentful supplies the artwork it decides how many frames there - // are; the local previewCount only pads placeholders for templates that - // still have no real screenshots. - previewCount: entry.images.length || local.previewCount, } : local; + const copy = templateCopy({ + name: template.title, + longDescription: template.longDescription || undefined, + marketingHeadline: entry?.marketingHeadline, + heroDescription: entry?.heroDescription, + seoKeyword: entry?.seoKeyword, + definitionBody: entry?.definitionBody, + ctaHeading: entry?.ctaHeading, + seoTitle: entry?.seoTitle, + seoMetaDescription: entry?.seoMetaDescription, + }); + return { template, entry, copy }; +} + +export async function generateMetadata({ params }: Props): Promise { + const { slug } = await params; + const resolved = await resolveTemplate(slug); + if (!resolved) return {}; + const { template, copy } = resolved; + const metadata = pageMetadata( + { + title: copy.seoTitle, + description: copy.seoDescription ?? "", + path: `/templates/${template.slug}`, + }, + // The card prints the SEO title, so the unfurl, the tab and the search + // result all name the page the same way. + ogImageFor(copy.seoTitle), + ); + // No hero description and no SEO description: no description at all, so + // search engines write their own snippet rather than read an empty tag or + // the gallery card's one-liner. null, not omitted: an omitted description + // inherits the root layout's, which is the homepage's copy. The social + // objects replace the layout's wholesale, so dropping the key is enough there. + if (!copy.seoDescription) { + metadata.description = null; + if (metadata.openGraph) delete metadata.openGraph.description; + if (metadata.twitter) delete metadata.twitter.description; + } + return metadata; +} + +/** + * The Works with row: the templates the editor picked in Relative Apps, else + * the template's category-mates by rank. Listed templates only, never itself. + */ +async function worksWith(template: Template, entry: AppTemplateEntry | null) { + const listed = (await getVisibleTemplates()).filter((t) => t.slug !== template.slug); + const bySlug = new Map(listed.map((t) => [t.slug, t])); + const picked = (entry?.relatedSlugs ?? []) + .map((slug) => bySlug.get(slug)) + .filter((t): t is Template => Boolean(t)); + const cards = picked.length + ? picked + : listed + .filter((t) => t.category === template.category) + .sort((a, b) => (a.rank ?? Infinity) - (b.rank ?? Infinity)); + + // "Internal" marks an app only the team sees, read off the CMS Audience. + const audience = new Map((await getAppTemplates()).map((e) => [e.slug, e.audience])); + const internalOnly = (slug: string) => { + const a = audience.get(slug) ?? []; + return a.includes("Internal Team") && !a.includes("Clients"); + }; + + return { + cards: cards.slice(0, WORKS_WITH_COUNT).map((t) => ({ + template: t, + chip: internalOnly(t.slug) ? "Internal" : undefined, + })), + // "30+", not "32": a round floor that stays true as templates come and go. + total: listed.length + 1, + }; +} + +function browseAllLabel(total: number) { + return total >= 10 ? `Browse all ${Math.floor(total / 10) * 10}+ templates` : "Browse all templates"; +} + +export default async function TemplateDetailPage({ params }: Props) { + const { slug } = await params; + const resolved = await resolveTemplate(slug); + if (!resolved) notFound(); + const { template, entry, copy } = resolved; + + const images = entry?.images ?? []; + const mobileImages = entry?.mobileImages ?? []; + const benefits = parseBenefits(entry?.overview); + if (entry?.overview && !benefits) { + console.warn( + `[templates] ${slug}: Marketing App Overview doesn't match the benefits layout (one Heading 2, then 2–4 Heading 3 + paragraph cards), so it renders whole.`, + ); + } + const faqs = entry?.faqs ?? []; + const related = await worksWith(template, entry); + const categoryHref = `/templates?category=${encodeURIComponent(template.category.toLowerCase())}`; + const url = `${SITE_URL}/templates/${template.slug}`; + + const schema = [ + { + "@context": "https://schema.org", + "@type": "BreadcrumbList", + itemListElement: [ + { name: "App templates", item: `${SITE_URL}/templates` }, + ...(template.category ? [{ name: template.category, item: `${SITE_URL}${categoryHref}` }] : []), + { name: template.title, item: url }, + ].map((crumb, i) => ({ "@type": "ListItem", position: i + 1, ...crumb })), + }, + { + // No rating markup: the only scores on the page are Assembly's own on + // G2 and Capterra, not reviews of this template. + "@context": "https://schema.org", + "@type": "SoftwareApplication", + name: `${template.title} template`, + ...(copy.seoDescription ? { description: copy.seoDescription } : {}), + url, + applicationCategory: "BusinessApplication", + operatingSystem: "Web", + offers: { "@type": "Offer", price: "0", priceCurrency: "USD" }, + }, + ...(faqs.length + ? [ + { + "@context": "https://schema.org", + "@type": "FAQPage", + mainEntity: faqs.map(({ question, answer }) => ({ + "@type": "Question", + name: question, + acceptedAnswer: { "@type": "Answer", text: answer }, + })), + }, + ] + : []), + ]; return ( <> - {/* Bottom padding keeps the last content ("Perfect for") off the footer. */} -
-
- {/* Mobile: title/CTA lead the page, above the gallery */} - - -
- {/* Left — gallery + about (keeps the sidebar sticky alongside) */} -
- + + {/* 01 Hero: copy on the left (5 of 12), the tracker shot on the right + (7 of 12); stacked on a phone, copy first. */} +
+
+
+ +

{copy.headline}

+

{copy.lead ?? template.description}

+ +

+ Start for free. No credit card required. +

+
+
+ + + +
+
+
-
- {/* One rich-text field from Contentful covers both the About - copy and the customization list, so an editor can reshape - those sections without a code change. Templates that aren't - in the CMS yet keep the written-out version below. */} - {entry?.overview ? ( - - ) : ( - <> -

- About this template -

-

- {template.longDescription} -

-

- Start from this template and describe what you want to - change — Assembly adapts the layout, fields, and flow to - your firm, then publishes it to your client portal in - minutes. No code required. -

- -

- What you can customize -

-
    - {CUSTOMIZABLE.map((item) => ( -
  • - - - {item} - -
  • - ))} -
- {/* Inside the fallback: the Contentful overview carries its - own "Perfect for" section, so rendering this alongside it - would print the heading twice. */} -

Perfect for

-

- {template.industries && template.industries.length > 0 - ? template.industries - .join(", ") - .replace(/, ([^,]*)$/, ", and $1") - : "consulting, accounting, legal, and real estate"}{" "} - firms running {template.category.toLowerCase()} workflows. -

- - )} + {/* 02 Social proof */} +
+
+ +
+
+ + {/* 03 Definition: hidden whole when the entry has no Definition Body. */} + {copy.definition && ( +
+
+

{copy.definition.heading}

+

+ {copy.definition.body} +

+
+
+ )} + + {/* 04 Benefits: 2×2 cards beside the second shot. An overview that doesn't + follow the layout still renders, whole, rather than vanishing. */} + {(benefits || entry?.overview) && ( +
+
+ {benefits ? ( + <> +

{benefits.heading}

+
+ {/* Without the second shot the cards take the full width, four across + from lg, rather than leaving the right half empty. */} +
+ {benefits.cards.map((card) => ( +
+

{card.title}

+

{card.body}

+
+ ))} +
+ {images[1] && ( +
+ +
+ )} +
+ + ) : ( +
+
-
+ )} +
+
+ )} - {/* Right — sticky header + CTA (desktop). On mobile the header - renders above the gallery instead. */} -
- + {/* 05 How it works: shared by every template page. */} +
+
+

Add it, describe your changes, publish

+ +
+
+ + {/* 06 Works with */} + {related.cards.length > 0 && ( +
+
+
+
+

Pair it with the rest of your client experience

+

+ Every app connects to the same built-in CRM and appears in the same branded client experience. +

+
+ + {browseAllLabel(related.total)} → + +
+ {/* A sideways scroll on a phone, with the next card showing at the + edge; four across from md. */} +
+ {related.cards.map(({ template: card, chip }) => ( + + ))}
+
+ )} + + {/* 07 FAQ */} + {faqs.length > 0 && } + {/* 08 Final CTA */} +
+
+

+ {copy.ctaHeading} +

+

+ Start from this template for free and make it yours in minutes. No credit card required. +

+
+ + + + ); } diff --git a/src/components/customers/review-marks.tsx b/src/components/customers/review-marks.tsx new file mode 100644 index 00000000..1de57cff --- /dev/null +++ b/src/components/customers/review-marks.tsx @@ -0,0 +1,26 @@ +// The two review-site marks, shared by /customers and the template pages' proof +// strip so both draw the same badges. + +// Capterra mark, monochrome (inherits currentColor). +export function CapterraLogo({ className = "" }: { className?: string }) { + return ( + + + + + + + ); +} + +// G2 wordmark, monochrome (inherits currentColor). Shown in place of the "G2" +// text on desktop; mobile keeps the plain text. +export function G2Logo({ className = "" }: { className?: string }) { + return ( + + + + + + ); +} diff --git a/src/components/templates/detail/template-how-it-works.tsx b/src/components/templates/detail/template-how-it-works.tsx new file mode 100644 index 00000000..de206dca --- /dev/null +++ b/src/components/templates/detail/template-how-it-works.tsx @@ -0,0 +1,200 @@ +/** + * "Add it, describe your changes, publish": the three steps, each under a small + * panel. The panels are fixed graphics, the same on every template page: they + * show how any template goes live, using the client project tracker as the + * worked example, so nothing here reads from the CMS. + * + * Drawn in the page's own tokens (background, muted, card, border, foreground) + * so both themes come from the same markup. Assembly chrome appears only in + * the two team-facing panels; the client view shows the placeholder workspace, + * Brandmages, and no Assembly mark. + */ + +// The Assembly symbol, in currentColor so it takes the theme's ink. +function AssemblyMark({ className }: { className?: string }) { + return ( + + + + + + ); +} + +const PANEL = + "flex aspect-[4/3] flex-col overflow-hidden rounded-xl border border-border bg-background text-xs leading-4"; + +function AppBar({ label, trailing }: { label: string; trailing?: string }) { + return ( +
+ + {label} + {trailing && ( + {trailing} + )} +
+ ); +} + +function InstallPanel() { + return ( +
+ +
+
+
+
+ Client project tracker + Milestones and progress per engagement +
+ + Install + +
+
+
+
+
+ ); +} + +function BuilderPanel() { + return ( +
+ +
+
+ + Margot + +
+ Add a Client review stage between In progress and Done. +
+
+
+ + + Assembly + +
+ Updated. Client review now sits between In progress and Done. +
+
+
+ Describe a change +
+
+
+ ); +} + +const COLUMNS: { label: string; items: string[]; done?: boolean }[] = [ + { label: "Next", items: ["Launch"] }, + { label: "In progress", items: [] }, + { label: "Client review", items: ["Homepage design"] }, + { label: "Done", items: ["Discovery", "Sitemap", "Wireframes"], done: true }, +]; + +function PortalPanel() { + return ( +
+
+
+ + + +
+
+ portal.brandmages.com +
+
+
+
+ + Brandmages + + Meridian Corp + + M + + +
+
+
+ Website redesign + 3 of 5 milestones done +
+
+
+
+
+ {COLUMNS.map((col) => ( +
+ + {col.label} + {col.items.length} + + {col.items.map((item) => ( +
+ {item} +
+ ))} +
+ ))} +
+
+
+ ); +} + +const STEPS = [ + { + Panel: InstallPanel, + title: "Add the template", + body: "It installs as a working app you can use as is.", + }, + { + Panel: BuilderPanel, + title: "Describe your changes", + body: "Tell the app builder what to change, from fields to steps, and it adapts the template to your business.", + }, + { + Panel: PortalPanel, + title: "Publish to clients", + body: "It appears in your branded client experience, with sign-in and permissions already handled.", + }, +]; + +export function TemplateHowItWorks() { + return ( +
    + {STEPS.map(({ Panel, title, body }, i) => ( +
  1. + + + {String(i + 1).padStart(2, "0")} + +

    {title}

    +

    {body}

    +
  2. + ))} +
+ ); +} diff --git a/src/components/templates/detail/template-proof.tsx b/src/components/templates/detail/template-proof.tsx new file mode 100644 index 00000000..723f45f9 --- /dev/null +++ b/src/components/templates/detail/template-proof.tsx @@ -0,0 +1,75 @@ +import Link from "next/link"; +import { CustomerLogo } from "@/components/customers/customer-logos"; +import { CapterraLogo, G2Logo } from "@/components/customers/review-marks"; +import { getCaseStudyBySlug } from "@/lib/case-studies"; +import { CAPTERRA_URL, G2_URL } from "@/lib/constants"; + +// The six marks the handoff names, all of them firms the template pages are +// written for. Shared by every template page, so it is set here and not in +// the CMS. +const LOGO_SLUGS = [ + "advertai-marketing", + "collective-cpa", + "sargent-cpa", + "heritage-law-partners", + "valuenode-accounting", + "durrick-designs", +]; + +// Current as of 2026-10-19. The line beside the badges is built from these, so +// the two can't disagree; re-check both against G2 and Capterra before quoting +// them anywhere new. +const RATINGS = [ + { href: G2_URL, label: "G2", score: "4.7", Mark: G2Logo }, + { href: CAPTERRA_URL, label: "Capterra", score: "4.8", Mark: CapterraLogo }, +]; + +/** + * The proof strip under the hero: the rating line on the left, the customer + * marks on the right; stacked on a phone, with the marks in two rows of three. + * No heading, by design: the marks carry it. + */ +export function TemplateProof() { + return ( +
+
+
+ {RATINGS.map(({ href, label, score, Mark }) => ( + + {score} + + ))} +
+

+ Rated {RATINGS.map((r) => `${r.score} on ${r.label}`).join(" and ")} +

+
+
+ {LOGO_SLUGS.map((slug) => ( + + {/* aria-hidden: some marks (Advertai's SVG) label themselves, and + the sr-only name below already says who it is. */} + + + + {/* The marks are drawn (SVG or a CSS mask), so they carry no text of + their own. Real text rather than an aria-label, so crawlers read + the link as the customer's name too, not just its path. */} + {getCaseStudyBySlug(slug)?.company ?? "Customer"} logo + + ))} +
+
+ ); +} diff --git a/src/components/templates/detail/template-shot.tsx b/src/components/templates/detail/template-shot.tsx new file mode 100644 index 00000000..c627977a --- /dev/null +++ b/src/components/templates/detail/template-shot.tsx @@ -0,0 +1,99 @@ +import { getImageProps } from "next/image"; +import type { TemplateImage } from "@/lib/contentful"; +import { PlaceholderArt } from "@/components/templates/template-gallery"; +import { cn } from "@/lib/utils"; + +/** + * One template screenshot, shown as is: the desktop shot from md up, and the + * matching Image List (mobile) shot below it when the entry has one. + * + * Art direction goes through getImageProps and a , not two s + * toggled by CSS: a display:none image is still fetched when it's eager, and + * the hero shot is the page's LCP, so it has to be eager. + * + * `alt` is the asset's own Description when the editor wrote one, else the + * caller's "{name} screenshot". + */ +export function TemplateShot({ + desktop, + mobile, + alt, + sizes, + priority = false, + className, +}: { + desktop?: TemplateImage; + mobile?: TemplateImage; + alt: string; + sizes: string; + priority?: boolean; + className?: string; +}) { + // The frame takes each shot's own proportions, read off the asset, so nothing + // is cropped: a 16:9 export shows whole in a 16:9 frame. Below md the mobile + // shot (a full phone screen, about 1:2.2) sets the shape instead, capped in + // height so one screenshot can't fill the phone, and contained within it. + const ratio = (image?: TemplateImage) => + image?.width && image.height ? `${image.width} / ${image.height}` : undefined; + const style = { + "--shot-ratio": ratio(desktop) ?? "16 / 10", + "--shot-ratio-sm": ratio(mobile) ?? ratio(desktop) ?? "16 / 10", + } as React.CSSProperties; + const frame = cn( + "relative overflow-hidden bg-muted aspect-[var(--shot-ratio)] max-md:aspect-[var(--shot-ratio-sm)]", + mobile && "max-md:max-h-[70svh]", + className, + ); + if (!desktop) { + return ( +
+ +
+ ); + } + + const label = desktop.description ?? alt; + const common = { + alt: label, + sizes, + fill: true, + priority, + } as const; + const { + props: { srcSet: desktopSet, ...rest }, + } = getImageProps({ ...common, src: desktop.url }); + const mobileSet = mobile + ? getImageProps({ ...common, alt: mobile.description ?? label, src: mobile.url }).props.srcSet + : undefined; + + return ( +
+ + {mobileSet && } + + {label} + +
+ ); +} + +/** + * Browser chrome around the hero shot: three dots and an address pill, drawn + * in the page's own tokens so it flips with the theme. + */ +export function BrowserFrame({ children }: { children: React.ReactNode }) { + return ( +
+
+
+ + + +
+
+
+
+ {children} +
+ ); +} diff --git a/src/components/templates/detail/template-sticky-cta.tsx b/src/components/templates/detail/template-sticky-cta.tsx new file mode 100644 index 00000000..49a9efad --- /dev/null +++ b/src/components/templates/detail/template-sticky-cta.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { useEffect, useState } from "react"; + +/** + * The phone's bottom bar: once the hero's own CTA has scrolled up out of view, + * the primary action pins to the bottom of the screen. Hidden from md, where + * the hero CTA is never far away. + * + * It watches the hero CTA by id rather than taking a ref, so the hero itself + * stays a server component. Only "scrolled past" counts: a CTA still below the + * fold, on first paint of a short screen, leaves the bar down. + */ +export function TemplateStickyCta({ + watchId, + children, +}: { + watchId: string; + children: React.ReactNode; +}) { + const [show, setShow] = useState(false); + + useEffect(() => { + const target = document.getElementById(watchId); + if (!target) return; + const io = new IntersectionObserver(([entry]) => { + setShow(!entry.isIntersecting && entry.boundingClientRect.top < 0); + }); + io.observe(target); + return () => io.disconnect(); + }, [watchId]); + + return ( +
+ {children} +
+ ); +} diff --git a/src/components/templates/template-card.tsx b/src/components/templates/template-card.tsx new file mode 100644 index 00000000..31e76434 --- /dev/null +++ b/src/components/templates/template-card.tsx @@ -0,0 +1,114 @@ +"use client"; + +import Link from "next/link"; +import type { Template } from "@/lib/templates"; +import { V69CardMock } from "@/components/home/hero-v71"; +import { useTheme } from "@/components/theme/theme-provider"; +import { MockFit, MOCK_DESIGN_SIZE } from "@/components/templates/mock-fit"; +import { cn } from "@/lib/utils"; + +// Covers whose own fill runs dark all the way to the card edge. The frame's +// hairline and inner highlight are both light, so on those cards they drew a pale +// outline across the dark area instead of disappearing into it; the dark fill +// already defines the card's edge, so the overlay is dropped entirely. +const BLEED_COVERS = new Set(["design-approvals"]); + +/** + * One template card: the widget cover, the name, and the line under it. The + * /templates gallery and a detail page's Works with row both render this, so + * the two can't drift into two drawings of the same card. + * + * `chip` is the site's standard tag chip under the text, for a row that needs + * to say something about a card (the Works with row marks internal-only apps). + */ +export function TemplateCard({ + template, + chip, + className, +}: { + template: Template; + chip?: string; + className?: string; +}) { + // Widget covers (shared with the home hero) reskin to the dark surface. + const { theme } = useTheme(); + const dark = theme === "dark"; + return ( +
+ {/* Its own focus ring rather than the global outline fallback: + that one is full-strength foreground with square corners, so on + a rounded cover it landed as a hard black box around the card + and its caption. Same weight and tint as the filter chips and + the arrows. Drawn on the cover rather than on the whole link: + around the link it enclosed the caption too, and a two-line + description ran right into it. */} + + {/* Real preview image when set; otherwise the template's widget + cover mock (shared with the home hero). */} + {/* Square at every width: the widgets are drawn square, and the + old 5/4 mobile frame cropped the bottom off the taller ones. */} + {/* The outline is drawn OVER the cover, not as a border on the + frame. A border takes a pixel of layout, so the widget could + only ever fill the content box and stopped 1px short of the + edge — with the card's white ground under a 60%-opacity + hairline, that pixel read as a pale gap rather than an outline + hugging the colour. No border means the cover fills the whole + square and the hairline sits on its outermost pixel. + It can't live inside MockFit: `.template-mock-fit > *` sizes + and scales every direct child to the design size. */} +
+ + {/* template-mock-gallery marks this rail specifically — a few + covers are skinned for the gallery alone, and .template-mock + by itself would carry those into the proposal page's mocks + too. */} +
+ +
+
+ {!BLEED_COVERS.has(template.slug) && ( +
+ )} +
+ {/* Card text on the site's own steps: body for the name, caption + for the line under it. Both sit at 400 — the name reads as the + heading through colour and the space above it, not weight. + The category tag that used to close the card is gone: it was + the only uppercase on the page, and the row it sat on was most + of what made the grid feel packed. The filter tabs above + already say which category you're looking at. */} +

+ {template.title} +

+

+ {template.description} +

+ {chip && ( + + {chip} + + )} + +
+ ); +} diff --git a/src/components/templates/template-cta.tsx b/src/components/templates/template-cta.tsx index 5ae7ea4d..51b478e9 100644 --- a/src/components/templates/template-cta.tsx +++ b/src/components/templates/template-cta.tsx @@ -26,9 +26,12 @@ type CtaTemplate = { templateId?: string; title: string; description: string }; */ export function TemplateAuthCta({ template, + label = "Get started", className, }: { template: CtaTemplate; + /** The signed-out label. The signed-in one is decided here, for every surface. */ + label?: string; className?: string; }) { const href = templateSignupUrl(template); @@ -38,7 +41,7 @@ export function TemplateAuthCta({ authedHref={deeplinkable ? href : APP_URL} authedLabel={deeplinkable ? "Add app to workspace" : "Open Assembly"} href={href} - label="Get started" + label={label} className={className} /> ); @@ -66,3 +69,21 @@ export function TemplateCta({ template }: { template: CtaTemplate }) { /> ); } + +/** + * The redesigned detail page's primary action. Signed out it reads "Use this + * template", per the handoff; signed in it keeps production's behaviour from + * TemplateAuthCta ("Add app to workspace", or "Open Assembly" when the + * template has no app behind it). Both variants ship in the markup and the + * pre-paint auth script sets display:none on the one that doesn't apply, so a + * screen reader meets one link, not two. + */ +export function TemplateUseCta({ + template, + className, +}: { + template: CtaTemplate; + className?: string; +}) { + return ; +} diff --git a/src/components/templates/templates-browser.tsx b/src/components/templates/templates-browser.tsx index 03c4fce2..bbe45152 100644 --- a/src/components/templates/templates-browser.tsx +++ b/src/components/templates/templates-browser.tsx @@ -1,12 +1,9 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import Link from "next/link"; +import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import type { Template } from "@/lib/templates"; import { TEMPLATE_CATEGORIES } from "@/lib/templates"; -import { V69CardMock } from "@/components/home/hero-v71"; -import { useTheme } from "@/components/theme/theme-provider"; -import { MockFit, MOCK_DESIGN_SIZE } from "@/components/templates/mock-fit"; +import { TemplateCard } from "@/components/templates/template-card"; interface Props { templates: Template[]; @@ -14,12 +11,6 @@ interface Props { const ALL = "All"; -// Covers whose own fill runs dark all the way to the card edge. The frame's -// hairline and inner highlight are both light, so on those cards they drew a pale -// outline across the dark area instead of disappearing into it; the dark fill -// already defines the card's edge, so the overlay is dropped entirely. -const BLEED_COVERS = new Set(["design-approvals"]); - /** Edge affordance for the chip strip: just the button. The fade itself lives on * the scroller (see EDGE_FADE) rather than in an overlay here. */ function ScrollArrow({ @@ -99,17 +90,36 @@ function edgeFade(left: boolean, right: boolean) { // Widgets are drawn at one design size and scaled into the card they're framed // in; the per-template exceptions live with the frame (see MOCK_DESIGN_SIZE). +// The query string doesn't change while the gallery is open (filtering is +// local state, not navigation), so there is nothing to subscribe to. +const subscribeToNothing = () => () => {}; + export function TemplatesBrowser({ templates }: Props) { - // Widget covers (shared with the home hero) reskin to the dark surface. - const { theme } = useTheme(); - const dark = theme === "dark"; // One category at a time. It used to be a multi-select, and two or three dark // chips at once read as a broken toggle rather than as a union — nothing on the // row said the selection added up. null = "All" (show everything); picking the // selected chip again clears back to All. - const [selected, setSelected] = useState(null); + // + // A link can open the gallery already filtered: a detail page's breadcrumb + // points at /templates?category=trackers. `picked` stays undefined until the + // visitor chooses, and until then the URL's category stands in for it. Read + // through useSyncExternalStore rather than useSearchParams, which would put + // the whole statically rendered page behind a Suspense boundary, and rather + // than copied into state from an effect, which renders the page twice. + // Matched case-insensitively against the categories present, so a stale or + // mistyped param just leaves the gallery on All. + const [picked, setPicked] = useState(undefined); + const urlCategory = useSyncExternalStore( + subscribeToNothing, + () => new URLSearchParams(window.location.search).get("category"), + () => null, + ); + const fromUrl = urlCategory + ? (templates.find((t) => t.category.toLowerCase() === urlCategory.toLowerCase())?.category ?? null) + : null; + const selected = picked === undefined ? fromUrl : picked; const selectCategory = (cat: string) => { - setSelected((prev) => (cat === ALL || prev === cat ? null : cat)); + setPicked(cat === ALL || selected === cat ? null : cat); }; // Free-text search across title, description, category, and industries. const [query, setQuery] = useState(""); @@ -431,77 +441,7 @@ export function TemplatesBrowser({ templates }: Props) { {filtered.length > 0 ? (
{filtered.map((template) => ( -
- {/* Its own focus ring rather than the global outline fallback: - that one is full-strength foreground with square corners, so on - a rounded cover it landed as a hard black box around the card - and its caption. Same weight and tint as the filter chips and - the arrows. Drawn on the cover rather than on the whole link: - around the link it enclosed the caption too, and a two-line - description ran right into it. */} - - {/* Real preview image when set; otherwise the template's widget - cover mock (shared with the home hero). */} - {/* Square at every width: the widgets are drawn square, and the - old 5/4 mobile frame cropped the bottom off the taller ones. */} - {/* The outline is drawn OVER the cover, not as a border on the - frame. A border takes a pixel of layout, so the widget could - only ever fill the content box and stopped 1px short of the - edge — with the card's white ground under a 60%-opacity - hairline, that pixel read as a pale gap rather than an outline - hugging the colour. No border means the cover fills the whole - square and the hairline sits on its outermost pixel. - It can't live inside MockFit: `.template-mock-fit > *` sizes - and scales every direct child to the design size. */} -
- - {/* template-mock-gallery marks this rail specifically — a few - covers are skinned for the gallery alone, and .template-mock - by itself would carry those into the proposal page's mocks - too. */} -
- -
-
- {!BLEED_COVERS.has(template.slug) && ( -
- )} -
- {/* Card text on the site's own steps: body for the name, caption - for the line under it. Both sit at 400 — the name reads as the - heading through colour and the space above it, not weight. - The category tag that used to close the card is gone: it was - the only uppercase on the page, and the row it sat on was most - of what made the grid feel packed. The filter tabs above - already say which category you're looking at. */} -

- {template.title} -

-

- {template.description} -

- -
+ ))}
) : ( @@ -526,7 +466,7 @@ export function TemplatesBrowser({ templates }: Props) { type="button" onClick={() => { setQuery(""); - setSelected(null); + setPicked(null); }} className="type-caption mt-7 rounded-lg border border-border px-4 py-1.5 text-muted-foreground transition-colors hover:border-foreground/20 hover:text-foreground" > diff --git a/src/lib/contentful.ts b/src/lib/contentful.ts index 5ef8733a..436a012a 100644 --- a/src/lib/contentful.ts +++ b/src/lib/contentful.ts @@ -28,6 +28,15 @@ const DELIVERY_TOKEN = process.env.CONTENTFUL_DELIVERY_TOKEN; // The content lives in master. Read-only, so there's nothing to isolate from. const ENVIRONMENT = process.env.CONTENTFUL_ENVIRONMENT || "master"; +export interface TemplateImage { + url: string; + title: string; + /** The asset's Description, which is where its alt text is written. */ + description?: string; + width?: number; + height?: number; +} + /** The fields this site reads. The content type carries many more. */ export interface AppTemplateEntry { slug: string; @@ -39,8 +48,29 @@ export interface AppTemplateEntry { category?: string; /** "App Overview" — one rich-text field covering About + What you can customize. */ overview?: Document; - /** "Image List" — the detail gallery. */ - images: { url: string; title: string; width?: number; height?: number }[]; + /** "Image List (Desktop)" — the hero shot first, the benefits shot second. */ + images: TemplateImage[]; + /** "Image List (mobile)" — the same shots cropped for a phone, in the same order. */ + mobileImages: TemplateImage[]; + /** + * The detail page's per-template copy. Every one is optional, and the page + * decides each fallback (see template-page.ts) so a template nobody has + * filled in yet still renders whole. + */ + marketingHeadline?: string; + heroDescription?: string; + /** The search term, as it reads mid-sentence — "client project tracker". */ + seoKeyword?: string; + definitionBody?: string; + ctaHeading?: string; + seoTitle?: string; + seoMetaDescription?: string; + /** "FAQ" — linked question/answer entries, in the order the editor set. */ + faqs: { question: string; answer: string }[]; + /** "Relative Apps" — the slugs picked for the Works with row. */ + relatedSlugs: string[]; + /** "Audience" — "Clients", "Internal Team", or both. */ + audience: string[]; /** * Contentful "Template Id" — the app id the product resolves this template * by, e.g. "app-0548119d". Not every entry carries one yet, so it's optional @@ -114,19 +144,43 @@ export const isContentfulConfigured = Boolean(client); */ export const contentfulClient = client; -function toImage(asset: Asset) { - const file = asset.fields?.file; +function toImage(asset: Asset): TemplateImage | null { + const file = asset?.fields?.file; if (!file?.url) return null; const details = file.details as { image?: { width: number; height: number } } | undefined; + const description = asset.fields.description; return { // Contentful returns protocol-relative URLs. url: String(file.url).startsWith("//") ? `https:${file.url}` : String(file.url), title: typeof asset.fields.title === "string" ? asset.fields.title : "", + description: typeof description === "string" && description.trim() ? description.trim() : undefined, width: details?.image?.width, height: details?.image?.height, }; } +function toImages(value: unknown): TemplateImage[] { + return Array.isArray(value) + ? (value as Asset[]).map(toImage).filter((i): i is TemplateImage => Boolean(i)) + : []; +} + +/** A trimmed string, or undefined for anything empty — an editor clearing a + * field leaves "" behind, and that must fall back the same as never set. */ +function text(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +// Linked entries arrive resolved (include: 2), but one that is unpublished or +// out of the include depth comes back as a bare link with no fields, so each is +// read defensively and dropped rather than rendered half-empty. +function linkedFields(value: unknown): Record[] { + if (!Array.isArray(value)) return []; + return value + .map((item) => (item as { fields?: Record })?.fields) + .filter((f): f is Record => Boolean(f)); +} + function toAppTemplate(entry: Entry): AppTemplateEntry | null { const f = entry.fields as Record; const slug = typeof f.slug === "string" ? f.slug : null; @@ -134,7 +188,6 @@ function toAppTemplate(entry: Entry): AppTemplateEntry | null { if (!slug || !name) return null; const categories = Array.isArray(f.categories) ? (f.categories as string[]) : []; - const imageList = Array.isArray(f.imageList) ? (f.imageList as Asset[]) : []; return { slug, @@ -142,7 +195,24 @@ function toAppTemplate(entry: Entry): AppTemplateEntry | null { subtitle: typeof f.description === "string" ? f.description : undefined, category: categories[0], overview: (f.appOverview as Document) ?? undefined, - images: imageList.map(toImage).filter((i): i is NonNullable => Boolean(i)), + images: toImages(f.imageList), + mobileImages: toImages(f.mobileImageList), + marketingHeadline: text(f.marketingHeadline), + heroDescription: text(f.heroDescription), + seoKeyword: text(f.seoKeyword), + definitionBody: text(f.definitionBody), + ctaHeading: text(f.ctaHeading), + seoTitle: text(f.seoTitle), + seoMetaDescription: text(f.seoMetaDescription), + faqs: linkedFields(f.faq).flatMap((faq) => { + const question = text(faq.question); + const answer = text(faq.answer); + return question && answer ? [{ question, answer }] : []; + }), + relatedSlugs: linkedFields(f.relativeApps) + .map((app) => text(app.slug)) + .filter((s): s is string => Boolean(s)), + audience: Array.isArray(f.audience) ? (f.audience as string[]) : [], templateId: typeof f.templateId === "string" ? f.templateId : undefined, isHidden: f.isHidden === true, featured: f.isFeatured === true, diff --git a/src/lib/llm-info.ts b/src/lib/llm-info.ts index 8cf57b2e..568767d3 100644 --- a/src/lib/llm-info.ts +++ b/src/lib/llm-info.ts @@ -151,7 +151,7 @@ The core framing: prospects comparing Assembly to Lovable are asking the wrong q # Social Proof -Assembly holds a 4.8+ average star rating on G2: https://www.g2.com/products/assemblysoftware/reviews +Assembly holds a 4.7 average star rating on G2: https://www.g2.com/products/assemblysoftware/reviews Assembly is trusted by thousands of businesses and is SOC 2, HIPAA, and GDPR certified. diff --git a/src/lib/og.ts b/src/lib/og.ts index 6e7058a6..92e115a4 100644 --- a/src/lib/og.ts +++ b/src/lib/og.ts @@ -88,6 +88,6 @@ export function ogImageFor(title: string, variant: OgVariant = "template") { url: `/api/og?title=${encodeURIComponent(printed)}&v=${variant}`, width: OG_IMAGE_WIDTH, height: OG_IMAGE_HEIGHT, - alt: `${printed} — ${SITE_NAME}`, + alt: `${printed} | ${SITE_NAME}`, }; } diff --git a/src/lib/solutions.frozen.ts b/src/lib/solutions.frozen.ts index d9bf9b44..663f8790 100644 --- a/src/lib/solutions.frozen.ts +++ b/src/lib/solutions.frozen.ts @@ -215,7 +215,7 @@ export const FROZEN_SOLUTIONS: CmsPage[] = [ }, { "question": "Will my clients actually use the portal?", - "answer": "This is the number one question freelancers should ask. Assembly is designed to be intuitive for clients — they access a simple, clean portal on desktop or mobile, with no app download required. They can reply to messages from email, pay invoices with one click, and upload files with drag-and-drop. The simpler the portal experience, the more your clients will engage — and Assembly is rated 4.8/5 on G2 largely because of how easy it is for both businesses and their clients." + "answer": "This is the number one question freelancers should ask. Assembly is designed to be intuitive for clients — they access a simple, clean portal on desktop or mobile, with no app download required. They can reply to messages from email, pay invoices with one click, and upload files with drag-and-drop. The simpler the portal experience, the more your clients will engage — and Assembly is rated 4.7/5 on G2 largely because of how easy it is for both businesses and their clients." }, { "question": "Can I automate my client onboarding?", diff --git a/src/lib/template-page.test.mjs b/src/lib/template-page.test.mjs new file mode 100644 index 00000000..b17f2bae --- /dev/null +++ b/src/lib/template-page.test.mjs @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { parseBenefits, templateCopy } from './template-page.ts'; + +const text = (value) => ({ nodeType: 'text', value, marks: [], data: {} }); +const block = (nodeType, value) => ({ nodeType, data: {}, content: [text(value)] }); +const h2 = (v) => block('heading-2', v); +const h3 = (v) => block('heading-3', v); +const p = (v) => block('paragraph', v); +const doc = (...content) => ({ nodeType: 'document', data: {}, content }); + +const cards = (n) => + Array.from({ length: n }, (_, i) => [h3(`Card ${i + 1}`), p(`Body ${i + 1}`)]).flat(); + +test('reads the heading and cards in the agreed layout', () => { + const result = parseBenefits( + doc(p('Old intro, ignored'), h2('Project updates'), ...cards(4), block('heading-3', 'Perfect for'), p('')), + ); + // The trailing "Perfect for" H3 has no paragraph after it once empties drop, + // so a fifth card can't form and the layout is rejected rather than truncated. + assert.equal(result, null); + + const ok = parseBenefits(doc(p('Old intro'), h2('Project updates'), ...cards(4), p(''))); + assert.deepEqual(ok, { + heading: 'Project updates', + cards: [1, 2, 3, 4].map((i) => ({ title: `Card ${i}`, body: `Body ${i}` })), + }); +}); + +test('stops at the first non-card node, so old copy after the cards is ignored', () => { + const result = parseBenefits(doc(h2('Benefits'), ...cards(2), block('unordered-list', 'x'), p('Perfect for…'))); + assert.equal(result?.cards.length, 2); +}); + +test('drops marks: an italic run reads as plain text', () => { + const italic = { nodeType: 'paragraph', data: {}, content: [text('Plain '), { ...text('slanted'), marks: [{ type: 'italic' }] }] }; + const result = parseBenefits(doc(h2('Benefits'), h3('One'), italic, ...cards(1))); + assert.equal(result?.cards[0].body, 'Plain slanted'); +}); + +test('rejects the old layout, too few cards, and too many', () => { + const old = doc(p('Keep clients in the loop'), h3('Key Features'), block('unordered-list', 'x'), h3('Perfect for'), p('Marketing firms')); + assert.equal(parseBenefits(old), null); + assert.equal(parseBenefits(doc(h2('Benefits'), ...cards(1))), null); + assert.equal(parseBenefits(doc(h2('Benefits'), ...cards(5))), null); + assert.equal(parseBenefits(undefined), null); +}); + +test('the definition names the template; the FAQ heading uses the keyword', () => { + const copy = templateCopy({ + name: 'Client project tracker', + seoKeyword: 'project tracker template', + seoTitle: 'Free Project Tracker Template for Clients', + definitionBody: 'A shared view.', + }); + assert.equal(copy.definition?.heading, 'What is a client project tracker?'); + assert.equal(copy.faqHeading, 'Questions about this project tracker template'); + assert.equal(copy.seoTitle, 'Free Project Tracker Template for Clients'); + assert.equal(templateCopy({ name: 'Onboarding wizard', definitionBody: 'z' }).definition?.heading, 'What is an onboarding wizard?'); +}); + +test('falls back field by field, never to the gallery subtitle', () => { + const copy = templateCopy({ name: 'Time tracker', longDescription: 'Track billable time per client.' }); + assert.equal(copy.headline, 'Time tracker'); + assert.equal(copy.lead, undefined); + assert.equal(copy.definition, null); + assert.equal(copy.faqHeading, 'Questions about this time tracker'); + assert.equal(copy.ctaHeading, 'Start with the Time tracker template'); + assert.equal(copy.seoTitle, 'Time Tracker Template'); + assert.equal(copy.seoDescription, 'Track billable time per client.'); + assert.equal(templateCopy({ name: 'x', heroDescription: 'Hero.', longDescription: 'Long.' }).seoDescription, 'Hero.'); + assert.equal(templateCopy({ name: 'x' }).seoDescription, undefined); +}); diff --git a/src/lib/template-page.ts b/src/lib/template-page.ts new file mode 100644 index 00000000..d4a875f4 --- /dev/null +++ b/src/lib/template-page.ts @@ -0,0 +1,136 @@ +import { BLOCKS, type Block, type Document, type Inline, type Text } from "@contentful/rich-text-types"; + +/** + * The copy a template detail page is built from, with every fallback decided in + * one place. + * + * Each field is optional in Contentful, so a template nobody has filled in yet + * must still render whole. The rule for each is fixed here rather than at the + * call site, so the page, its metadata and its structured data can never + * disagree about what a template's title or keyword is. + */ + +export interface Benefit { + title: string; + body: string; +} + +export interface Benefits { + heading: string; + cards: Benefit[]; +} + +const MIN_CARDS = 2; +const MAX_CARDS = 4; + +/** Plain text of a node, marks dropped — the page renders none of them. */ +function plain(node: Block | Inline | Text): string { + if (node.nodeType === "text") return (node as Text).value; + return (node as Block | Inline).content.map(plain).join(""); +} + +/** + * The benefits section, read out of the Marketing App Overview by an agreed + * layout: one Heading 2 for the section title, then 2–4 cards, each a Heading 3 + * followed by one paragraph. + * + * Anything before the first Heading 2 is ignored (the definition has its own + * field now), and so is anything after the cards, so the old About / Perfect + * for copy left in an entry can't leak onto the page. Anything that doesn't fit + * the layout returns null and the page falls back to rendering the field whole. + */ +export function parseBenefits(doc: Document | undefined): Benefits | null { + if (!doc) return null; + const nodes = doc.content.filter( + // An empty trailing paragraph is what the editor leaves behind after the + // last line, and it is not a layout mistake. + (n) => !(n.nodeType === BLOCKS.PARAGRAPH && !plain(n).trim()), + ); + const start = nodes.findIndex((n) => n.nodeType === BLOCKS.HEADING_2); + if (start === -1) return null; + + const heading = plain(nodes[start]).trim(); + if (!heading) return null; + + const cards: Benefit[] = []; + let i = start + 1; + while (i < nodes.length && nodes[i].nodeType === BLOCKS.HEADING_3) { + const title = plain(nodes[i]).trim(); + const next = nodes[i + 1]; + if (!title || next?.nodeType !== BLOCKS.PARAGRAPH) return null; + cards.push({ title, body: plain(next).trim() }); + i += 2; + } + + if (cards.length < MIN_CARDS || cards.length > MAX_CARDS) return null; + return { heading, cards }; +} + +function capitalize(s: string): string { + return s.charAt(0).toUpperCase() + s.slice(1); +} + +// Short function words stay lowercase inside a title-cased phrase. +const SMALL_WORDS = new Set(["a", "an", "and", "for", "in", "of", "on", "or", "the", "to", "with"]); + +function titleCase(s: string): string { + return s + .split(" ") + .map((word, i) => (i > 0 && SMALL_WORDS.has(word.toLowerCase()) ? word.toLowerCase() : capitalize(word))) + .join(" "); +} + +/** "a" or "an", by the keyword's first letter. */ +function article(word: string): string { + return /^[aeiou]/i.test(word) ? "an" : "a"; +} + +export interface TemplateCopyInput { + name: string; + /** The committed long-form description, the last stop for the meta description. */ + longDescription?: string; + marketingHeadline?: string; + heroDescription?: string; + seoKeyword?: string; + definitionBody?: string; + ctaHeading?: string; + seoTitle?: string; + seoMetaDescription?: string; +} + +export interface TemplateCopy { + headline: string; + /** Undefined when there's no hero description: the subtitle is the card line. */ + lead?: string; + /** Null when there's no definition body: the whole section is hidden. */ + definition: { heading: string; body: string } | null; + faqHeading: string; + ctaHeading: string; + /** The bare title; pageMetadata() adds " | Assembly". */ + seoTitle: string; + /** Undefined when nothing suitable exists: better no description than the card line. */ + seoDescription?: string; +} + +export function templateCopy(input: TemplateCopyInput): TemplateCopy { + const noun = input.name.toLowerCase(); + const keyword = input.seoKeyword ?? noun; + return { + headline: input.marketingHeadline ?? input.name, + lead: input.heroDescription, + // The question names the thing (the template's own name) so it matches the + // answer under it. The keyword is phrased for search ("project tracker + // template") and reads wrong as "What is a project tracker template?". + definition: input.definitionBody + ? { heading: `What is ${article(noun)} ${noun}?`, body: input.definitionBody } + : null, + faqHeading: `Questions about this ${keyword}`, + ctaHeading: input.ctaHeading ?? `Start with the ${input.name} template`, + seoTitle: + input.seoTitle ?? + (/template$/i.test(input.name) ? titleCase(input.name) : `${titleCase(input.name)} Template`), + // Never the Subtitle: that's the gallery card's one-liner, not a search snippet. + seoDescription: + input.seoMetaDescription ?? input.heroDescription ?? input.longDescription ?? input.definitionBody, + }; +}