From 1f16f1e7daec90079ac7ebb098e94f2118f94401 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 18:33:07 +0000 Subject: [PATCH 1/8] Redesign template detail pages around new Contentful fields (WEB3-28) Rebuilds /templates/[slug] to the client project tracker handoff: hero, proof strip, definition, benefits, how it works, works with, FAQ and a final CTA. Per-template copy comes from the new Apps fields (Marketing Headline, Hero Description, SEO Keyword, Definition Body, Final CTA Heading, SEO Title, SEO Meta Description) plus the existing FAQ and Relative Apps fields; each section hides or falls back when its field is empty, so unfilled templates still render whole. - Benefits are read from the Marketing App Overview by a fixed layout (H2, then 2-4 H3 + paragraph cards); anything else renders whole and logs a warning. - Definition and FAQ headings are built from the SEO keyword. - Gallery card extracted into TemplateCard and reused by Works with. - /templates honours ?category= so the breadcrumb lands filtered. - BreadcrumbList, SoftwareApplication and FAQPage JSON-LD. - G2/Capterra marks moved to a shared component. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Q5EaLBeuhHauS18ES6U7uK --- src/app/customers/page.tsx | 25 +- src/app/templates/[slug]/page.tsx | 478 +++++++++++------- src/components/customers/review-marks.tsx | 26 + .../detail/template-how-it-works.tsx | 200 ++++++++ .../templates/detail/template-proof.tsx | 69 +++ .../templates/detail/template-shot.tsx | 88 ++++ .../templates/detail/template-sticky-cta.tsx | 44 ++ src/components/templates/template-card.tsx | 114 +++++ src/components/templates/template-cta.tsx | 26 + .../templates/templates-browser.tsx | 116 +---- src/lib/contentful.ts | 82 ++- src/lib/template-page.test.mjs | 70 +++ src/lib/template-page.ts | 129 +++++ 13 files changed, 1180 insertions(+), 287 deletions(-) create mode 100644 src/components/customers/review-marks.tsx create mode 100644 src/components/templates/detail/template-how-it-works.tsx create mode 100644 src/components/templates/detail/template-proof.tsx create mode 100644 src/components/templates/detail/template-shot.tsx create mode 100644 src/components/templates/detail/template-sticky-cta.tsx create mode 100644 src/components/templates/template-card.tsx create mode 100644 src/lib/template-page.test.mjs create mode 100644 src/lib/template-page.ts diff --git a/src/app/customers/page.tsx b/src/app/customers/page.tsx index 350282a7..21a6593a 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 ( <> diff --git a/src/app/templates/[slug]/page.tsx b/src/app/templates/[slug]/page.tsx index f04277e1..0b3a9087 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,197 +41,332 @@ export async function generateStaticParams() { return (await getCatalogueTemplates()).map((t) => ({ slug: t.slug })); } +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"; + +/** + * 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. + */ +async function resolveTemplate(slug: string) { + const local = (await getCatalogueTemplate(slug)) ?? getTemplateBySlug(slug); + if (!local) return null; + const entry = await getAppTemplate(slug); + const template: Template = entry + ? { + ...local, + title: entry.name, + description: entry.subtitle ?? local.description, + // Contentful's "Template Id" is the product's own app id and the only + // authoritative source for it; the committed value is the fallback for + // templates the CMS doesn't hold yet. + templateId: entry.templateId ?? local.templateId, + category: entry.category ?? local.category, + } + : local; + const copy = templateCopy({ + name: template.title, + subtitle: template.description, + 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 template = (await getCatalogueTemplate(slug)) ?? getTemplateBySlug(slug); - if (!template) return {}; - const entry = await getAppTemplate(slug); - const name = entry?.name ?? template.title; + const resolved = await resolveTemplate(slug); + if (!resolved) return {}; + const { template, copy } = resolved; 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, + title: copy.seoTitle, + description: copy.seoDescription, 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), + ogImageFor(template.title), ); } /** - * 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 Works with row: the templates the editor picked in Relative Apps, else + * the template's category-mates by rank. Listed templates only, never itself. */ -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} - -
- -
- -
-
- ); +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; - // 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. - const local = (await getCatalogueTemplate(slug)) ?? getTemplateBySlug(slug); - if (!local) notFound(); + const resolved = await resolveTemplate(slug); + if (!resolved) notFound(); + const { template, entry, copy } = resolved; - // 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. - const entry = await getAppTemplate(slug); - const template: Template = entry - ? { - ...local, - title: entry.name, - description: entry.subtitle ?? local.description, - // Contentful's "Template Id" is the product's own app id and the only - // authoritative source for it; the committed value is the fallback for - // 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 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`, + 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}

+ +

+ Free plan that never expires. No credit card required. +

+
+
+ + + +
+
+
+ + {/* 02 Social proof */} +
+
+ +
+
-
- {/* 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. -

- - )} + {/* 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}

+
+
+ {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 and make it yours in minutes. Free plan that never expires. +

+
+ + + + ); } 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..92f3a1e2 --- /dev/null +++ b/src/components/templates/detail/template-proof.tsx @@ -0,0 +1,69 @@ +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", +]; + +// The same scores, and the same badge, as the /customers hero. Re-check both +// against G2 and Capterra before quoting them anywhere new. +const RATINGS = [ + { href: G2_URL, label: "G2", score: "4.8", Mark: G2Logo }, + { href: CAPTERRA_URL, label: "Capterra", score: "4.7", 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 4.8 on G2 and 4.7 on Capterra +

+
+
+ {LOGO_SLUGS.map((slug) => ( + + + + + + ))} +
+
+ ); +} diff --git a/src/components/templates/detail/template-shot.tsx b/src/components/templates/detail/template-shot.tsx new file mode 100644 index 00000000..8cb028c1 --- /dev/null +++ b/src/components/templates/detail/template-shot.tsx @@ -0,0 +1,88 @@ +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 default. + */ +export function TemplateShot({ + desktop, + mobile, + alt, + aspect, + sizes, + priority = false, + className, +}: { + desktop?: TemplateImage; + mobile?: TemplateImage; + alt: string; + /** Tailwind aspect class for the frame; the shot is cropped to it from the top. */ + aspect: string; + sizes: string; + priority?: boolean; + className?: string; +}) { + const frame = cn("relative overflow-hidden bg-muted", aspect, 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..ffb8aa3e 100644 --- a/src/components/templates/template-cta.tsx +++ b/src/components/templates/template-cta.tsx @@ -66,3 +66,29 @@ export function TemplateCta({ template }: { template: CtaTemplate }) { /> ); } + +/** + * The redesigned detail page's primary action, "Use this template", which the + * handoff puts in place of "Add app to workspace". Same destinations as + * TemplateAuthCta: both auth states take the signup link carrying the + * template, and a template with no app behind it falls back to the app root. + */ +export function TemplateUseCta({ + template, + className, +}: { + template: CtaTemplate; + className?: string; +}) { + const href = templateSignupUrl(template); + const deeplinkable = Boolean(template.templateId); + 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/template-page.test.mjs b/src/lib/template-page.test.mjs new file mode 100644 index 00000000..f5df54f7 --- /dev/null +++ b/src/lib/template-page.test.mjs @@ -0,0 +1,70 @@ +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('builds headings from the SEO keyword', () => { + const copy = templateCopy({ + name: 'Client project tracker', + subtitle: 'Share milestones and progress with clients', + seoKeyword: 'client project tracker', + definitionBody: 'A shared view.', + }); + assert.equal(copy.definition?.heading, 'What is a client project tracker?'); + assert.equal(copy.faqHeading, 'Client project tracker questions'); + assert.equal(copy.seoTitle, 'Client Project Tracker Template'); + assert.equal(templateCopy({ name: 'x', subtitle: 'y', seoKeyword: 'onboarding wizard', definitionBody: 'z' }).definition?.heading, 'What is an onboarding wizard?'); +}); + +test('falls back field by field', () => { + const copy = templateCopy({ name: 'Time tracker', subtitle: 'Log billable hours' }); + assert.equal(copy.headline, 'Time tracker'); + assert.equal(copy.lead, 'Log billable hours'); + assert.equal(copy.definition, null); + assert.equal(copy.ctaHeading, 'Start with the Time tracker template'); + assert.equal(copy.seoTitle, 'Time Tracker Template'); + assert.equal(copy.seoDescription, 'Log billable hours'); +}); diff --git a/src/lib/template-page.ts b/src/lib/template-page.ts new file mode 100644 index 00000000..0ae3d224 --- /dev/null +++ b/src/lib/template-page.ts @@ -0,0 +1,129 @@ +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; + subtitle: string; + marketingHeadline?: string; + heroDescription?: string; + seoKeyword?: string; + definitionBody?: string; + ctaHeading?: string; + seoTitle?: string; + seoMetaDescription?: string; +} + +export interface TemplateCopy { + headline: string; + lead: string; + /** The keyword as it reads mid-sentence. */ + keyword: 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; + seoDescription: string; +} + +export function templateCopy(input: TemplateCopyInput): TemplateCopy { + const keyword = input.seoKeyword ?? input.name.toLowerCase(); + const lead = input.heroDescription ?? input.subtitle; + return { + headline: input.marketingHeadline ?? input.name, + lead, + keyword, + definition: input.definitionBody + ? { heading: `What is ${article(keyword)} ${keyword}?`, body: input.definitionBody } + : null, + faqHeading: `${capitalize(keyword)} questions`, + ctaHeading: input.ctaHeading ?? `Start with the ${input.name} template`, + seoTitle: input.seoTitle ?? `${titleCase(keyword)} Template`, + seoDescription: input.seoMetaDescription ?? lead, + }; +} From 983e1a1b890ef32f56325bd2e140139f015e934c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 18:40:16 +0000 Subject: [PATCH 2/8] Give portrait mobile screenshots a 4:5 frame below md (WEB3-28) Image List (mobile) holds full phone screens (1320x2868). In the desktop frame's 16:10 they showed only the top sliver, so a frame holding a mobile shot switches to 4:5 below md and crops from the top. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Q5EaLBeuhHauS18ES6U7uK --- src/components/templates/detail/template-shot.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/components/templates/detail/template-shot.tsx b/src/components/templates/detail/template-shot.tsx index 8cb028c1..555e61dd 100644 --- a/src/components/templates/detail/template-shot.tsx +++ b/src/components/templates/detail/template-shot.tsx @@ -32,7 +32,15 @@ export function TemplateShot({ priority?: boolean; className?: string; }) { - const frame = cn("relative overflow-hidden bg-muted", aspect, className); + // The Image List (mobile) shots are full phone screens, portrait at about + // 1:2.2. Kept in the desktop frame's 16:10 they'd show a sliver of status bar, + // so below md a frame holding one turns 4:5 and shows the top of the screen. + const frame = cn( + "relative overflow-hidden bg-muted", + aspect, + mobile && "max-md:aspect-[4/5]", + className, + ); if (!desktop) { return (
From e1e0ebd568825ad9e0bff9eb7de91cedbc3454e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 18:41:15 +0000 Subject: [PATCH 3/8] Address Greptile review on template shots and benefits grid (WEB3-28) - The fallback keeps the desktop srcSet, so a phone viewing a template with no mobile shot still picks a small width. - Benefits only split into two columns when the second screenshot exists; otherwise the cards take the full width, four across from lg. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Q5EaLBeuhHauS18ES6U7uK --- src/app/templates/[slug]/page.tsx | 6 ++++-- src/components/templates/detail/template-shot.tsx | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/app/templates/[slug]/page.tsx b/src/app/templates/[slug]/page.tsx index 0b3a9087..ccc686fe 100644 --- a/src/app/templates/[slug]/page.tsx +++ b/src/app/templates/[slug]/page.tsx @@ -273,8 +273,10 @@ export default async function TemplateDetailPage({ params }: Props) { {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}

diff --git a/src/components/templates/detail/template-shot.tsx b/src/components/templates/detail/template-shot.tsx index 555e61dd..c29f2f73 100644 --- a/src/components/templates/detail/template-shot.tsx +++ b/src/components/templates/detail/template-shot.tsx @@ -68,7 +68,7 @@ export function TemplateShot({ {mobileSet && } - {label} + {label}
); From ff8c755f6c39220e2b4696c0941bbc8135e5b182 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 20:20:42 +0000 Subject: [PATCH 4/8] Template page review fixes: copy rules, alt text, uncropped shots (WEB3-28) - Definition H2 names the template ("What is a client project tracker?"); only the FAQ heading uses the SEO keyword ("Questions about this ..."). - Meta description falls back SEO Meta Description -> Hero Description -> committed long description, never the gallery subtitle; with none, the tag is omitted. - Social card prints the SEO title; its alt reads "{title} | Assembly". - Screenshot alt falls back to "{name} screenshot"; customer logos carry "{Customer} logo" as real text. - Screenshots take their own aspect ratio and are contained, not cropped. - "Use this template" keeps production's signed-in behaviour (Add app to workspace / Open Assembly) by reusing TemplateAuthCta. - "Free plan that never expires" -> "Start for free. No credit card required." Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Q5EaLBeuhHauS18ES6U7uK --- src/app/templates/[slug]/page.tsx | 35 +++++++++++-------- .../templates/detail/template-proof.tsx | 5 ++- .../templates/detail/template-shot.tsx | 29 ++++++++------- src/components/templates/template-cta.tsx | 27 ++++++-------- src/lib/og.ts | 2 +- src/lib/template-page.test.mjs | 23 ++++++------ src/lib/template-page.ts | 33 ++++++++++------- 7 files changed, 86 insertions(+), 68 deletions(-) diff --git a/src/app/templates/[slug]/page.tsx b/src/app/templates/[slug]/page.tsx index ccc686fe..f34b0e09 100644 --- a/src/app/templates/[slug]/page.tsx +++ b/src/app/templates/[slug]/page.tsx @@ -73,7 +73,7 @@ async function resolveTemplate(slug: string) { : local; const copy = templateCopy({ name: template.title, - subtitle: template.description, + longDescription: template.longDescription || undefined, marketingHeadline: entry?.marketingHeadline, heroDescription: entry?.heroDescription, seoKeyword: entry?.seoKeyword, @@ -90,16 +90,25 @@ export async function generateMetadata({ params }: Props): Promise { const resolved = await resolveTemplate(slug); if (!resolved) return {}; const { template, copy } = resolved; - return pageMetadata( + const metadata = pageMetadata( { title: copy.seoTitle, - description: copy.seoDescription, + description: copy.seoDescription ?? "", 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(template.title), + // 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: leave the description out so + // search engines write their own snippet, rather than ship an empty tag or + // the gallery card's one-liner. + if (!copy.seoDescription) { + delete metadata.description; + if (metadata.openGraph) delete metadata.openGraph.description; + if (metadata.twitter) delete metadata.twitter.description; + } + return metadata; } /** @@ -174,7 +183,7 @@ export default async function TemplateDetailPage({ params }: Props) { "@context": "https://schema.org", "@type": "SoftwareApplication", name: `${template.title} template`, - description: copy.seoDescription, + ...(copy.seoDescription ? { description: copy.seoDescription } : {}), url, applicationCategory: "BusinessApplication", operatingSystem: "Web", @@ -220,7 +229,7 @@ export default async function TemplateDetailPage({ params }: Props) { {template.title}

{copy.headline}

-

{copy.lead}

+

{copy.lead ?? template.description}

- Free plan that never expires. No credit card required. + Start for free. No credit card required.

@@ -236,8 +245,7 @@ export default async function TemplateDetailPage({ params }: Props) { @@ -289,8 +297,7 @@ export default async function TemplateDetailPage({ params }: Props) { @@ -356,7 +363,7 @@ export default async function TemplateDetailPage({ params }: Props) { {copy.ctaHeading}

- Start from this template and make it yours in minutes. Free plan that never expires. + Start from this template and make it yours in minutes. Start for free. No credit card required.

diff --git a/src/components/templates/detail/template-proof.tsx b/src/components/templates/detail/template-proof.tsx index 92f3a1e2..78b1929b 100644 --- a/src/components/templates/detail/template-proof.tsx +++ b/src/components/templates/detail/template-proof.tsx @@ -55,12 +55,15 @@ export function TemplateProof() { + {/* 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 index c29f2f73..c627977a 100644 --- a/src/components/templates/detail/template-shot.tsx +++ b/src/components/templates/detail/template-shot.tsx @@ -12,13 +12,12 @@ import { cn } from "@/lib/utils"; * 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 default. + * caller's "{name} screenshot". */ export function TemplateShot({ desktop, mobile, alt, - aspect, sizes, priority = false, className, @@ -26,24 +25,28 @@ export function TemplateShot({ desktop?: TemplateImage; mobile?: TemplateImage; alt: string; - /** Tailwind aspect class for the frame; the shot is cropped to it from the top. */ - aspect: string; sizes: string; priority?: boolean; className?: string; }) { - // The Image List (mobile) shots are full phone screens, portrait at about - // 1:2.2. Kept in the desktop frame's 16:10 they'd show a sliver of status bar, - // so below md a frame holding one turns 4:5 and shows the top of the screen. + // 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, - mobile && "max-md:aspect-[4/5]", + "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 ( -
+
); @@ -64,11 +67,11 @@ export function TemplateShot({ : undefined; return ( -
+
{mobileSet && } - {label} + {label}
); diff --git a/src/components/templates/template-cta.tsx b/src/components/templates/template-cta.tsx index ffb8aa3e..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} /> ); @@ -68,10 +71,12 @@ export function TemplateCta({ template }: { template: CtaTemplate }) { } /** - * The redesigned detail page's primary action, "Use this template", which the - * handoff puts in place of "Add app to workspace". Same destinations as - * TemplateAuthCta: both auth states take the signup link carrying the - * template, and a template with no app behind it falls back to the app root. + * 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, @@ -80,15 +85,5 @@ export function TemplateUseCta({ template: CtaTemplate; className?: string; }) { - const href = templateSignupUrl(template); - const deeplinkable = Boolean(template.templateId); - return ( - - ); + return ; } 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/template-page.test.mjs b/src/lib/template-page.test.mjs index f5df54f7..b17f2bae 100644 --- a/src/lib/template-page.test.mjs +++ b/src/lib/template-page.test.mjs @@ -46,25 +46,28 @@ test('rejects the old layout, too few cards, and too many', () => { assert.equal(parseBenefits(undefined), null); }); -test('builds headings from the SEO keyword', () => { +test('the definition names the template; the FAQ heading uses the keyword', () => { const copy = templateCopy({ name: 'Client project tracker', - subtitle: 'Share milestones and progress with clients', - seoKeyword: '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, 'Client project tracker questions'); - assert.equal(copy.seoTitle, 'Client Project Tracker Template'); - assert.equal(templateCopy({ name: 'x', subtitle: 'y', seoKeyword: 'onboarding wizard', definitionBody: 'z' }).definition?.heading, 'What is an onboarding wizard?'); + 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', () => { - const copy = templateCopy({ name: 'Time tracker', subtitle: 'Log billable hours' }); +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, 'Log billable hours'); + 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, 'Log billable hours'); + 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 index 0ae3d224..d4a875f4 100644 --- a/src/lib/template-page.ts +++ b/src/lib/template-page.ts @@ -87,7 +87,8 @@ function article(word: string): string { export interface TemplateCopyInput { name: string; - subtitle: string; + /** The committed long-form description, the last stop for the meta description. */ + longDescription?: string; marketingHeadline?: string; heroDescription?: string; seoKeyword?: string; @@ -99,31 +100,37 @@ export interface TemplateCopyInput { export interface TemplateCopy { headline: string; - lead: string; - /** The keyword as it reads mid-sentence. */ - keyword: 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; - seoDescription: string; + /** Undefined when nothing suitable exists: better no description than the card line. */ + seoDescription?: string; } export function templateCopy(input: TemplateCopyInput): TemplateCopy { - const keyword = input.seoKeyword ?? input.name.toLowerCase(); - const lead = input.heroDescription ?? input.subtitle; + const noun = input.name.toLowerCase(); + const keyword = input.seoKeyword ?? noun; return { headline: input.marketingHeadline ?? input.name, - lead, - keyword, + 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(keyword)} ${keyword}?`, body: input.definitionBody } + ? { heading: `What is ${article(noun)} ${noun}?`, body: input.definitionBody } : null, - faqHeading: `${capitalize(keyword)} questions`, + faqHeading: `Questions about this ${keyword}`, ctaHeading: input.ctaHeading ?? `Start with the ${input.name} template`, - seoTitle: input.seoTitle ?? `${titleCase(keyword)} Template`, - seoDescription: input.seoMetaDescription ?? lead, + 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, }; } From 5afdcf0698c61be0ed395e5a3e73c82b0e21ee49 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 20:29:37 +0000 Subject: [PATCH 5/8] Stop description inheritance and double-read logo names (WEB3-28) - A template with no description sets description: null, so it doesn't inherit the root layout's homepage description. - The drawn customer marks are aria-hidden; the sr-only "{Customer} logo" text is the link's one name (Advertai's SVG labelled itself too). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Q5EaLBeuhHauS18ES6U7uK --- src/app/templates/[slug]/page.tsx | 10 ++++++---- src/components/templates/detail/template-proof.tsx | 4 +++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/app/templates/[slug]/page.tsx b/src/app/templates/[slug]/page.tsx index f34b0e09..8ffe65f9 100644 --- a/src/app/templates/[slug]/page.tsx +++ b/src/app/templates/[slug]/page.tsx @@ -100,11 +100,13 @@ export async function generateMetadata({ params }: Props): Promise { // result all name the page the same way. ogImageFor(copy.seoTitle), ); - // No hero description and no SEO description: leave the description out so - // search engines write their own snippet, rather than ship an empty tag or - // the gallery card's one-liner. + // 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) { - delete metadata.description; + metadata.description = null; if (metadata.openGraph) delete metadata.openGraph.description; if (metadata.twitter) delete metadata.twitter.description; } diff --git a/src/components/templates/detail/template-proof.tsx b/src/components/templates/detail/template-proof.tsx index 78b1929b..c5862691 100644 --- a/src/components/templates/detail/template-proof.tsx +++ b/src/components/templates/detail/template-proof.tsx @@ -57,7 +57,9 @@ export function TemplateProof() { href={`/customers/${slug}`} className="flex h-8 items-center justify-center text-foreground/45 transition-colors duration-200 hover:text-foreground" > - + {/* 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 From 3f4e11613fee91e15d136d9fd788df74eebd85ae Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 20:31:19 +0000 Subject: [PATCH 6/8] Correct review scores on template pages: G2 4.7, Capterra 4.8 (WEB3-28) The rating line is now built from the same RATINGS array as the badges, so the two can't drift apart. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Q5EaLBeuhHauS18ES6U7uK --- src/components/templates/detail/template-proof.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/components/templates/detail/template-proof.tsx b/src/components/templates/detail/template-proof.tsx index c5862691..723f45f9 100644 --- a/src/components/templates/detail/template-proof.tsx +++ b/src/components/templates/detail/template-proof.tsx @@ -16,11 +16,12 @@ const LOGO_SLUGS = [ "durrick-designs", ]; -// The same scores, and the same badge, as the /customers hero. Re-check both -// against G2 and Capterra before quoting them anywhere new. +// 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.8", Mark: G2Logo }, - { href: CAPTERRA_URL, label: "Capterra", score: "4.7", Mark: CapterraLogo }, + { href: G2_URL, label: "G2", score: "4.7", Mark: G2Logo }, + { href: CAPTERRA_URL, label: "Capterra", score: "4.8", Mark: CapterraLogo }, ]; /** @@ -47,7 +48,7 @@ export function TemplateProof() { ))}

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

From 5ab57813876ddeaa92f8a7edc14aefa8b9fb53ea Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 20:32:19 +0000 Subject: [PATCH 7/8] Shorten the template page's final CTA line (WEB3-28) "Start from this template for free and make it yours in minutes. No credit card required." Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Q5EaLBeuhHauS18ES6U7uK --- src/app/templates/[slug]/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/templates/[slug]/page.tsx b/src/app/templates/[slug]/page.tsx index 8ffe65f9..e5be5102 100644 --- a/src/app/templates/[slug]/page.tsx +++ b/src/app/templates/[slug]/page.tsx @@ -365,7 +365,7 @@ export default async function TemplateDetailPage({ params }: Props) { {copy.ctaHeading}

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

From 87c7c914c22913db421eacb597149dfa41b59a84 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 20:32:57 +0000 Subject: [PATCH 8/8] Correct G2/Capterra scores site-wide: G2 4.7, Capterra 4.8 (WEB3-28) /customers hero, the /solutions FAQ answer and llm-info.ts still quoted G2 4.8 / Capterra 4.7. The /customers badge labels lose their em dash to match the template pages. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Q5EaLBeuhHauS18ES6U7uK --- src/app/customers/page.tsx | 12 ++++++------ src/lib/llm-info.ts | 2 +- src/lib/solutions.frozen.ts | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/app/customers/page.tsx b/src/app/customers/page.tsx index 21a6593a..3afc1e3f 100644 --- a/src/app/customers/page.tsx +++ b/src/app/customers/page.tsx @@ -24,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. */}
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/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?",