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. */}
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. */}
-
- {/* 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. */}
-
+
+
+ {/* 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. */}
+
+
+
+
+
>
);
}
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.
+
+ );
+}
+
+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) => (
+
+
+
+ {String(i + 1).padStart(2, "0")}
+
+
{title}
+
{body}
+
+ ))}
+
+ );
+}
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 (
+
+ 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 (
+
+ );
+}
+
+/**
+ * 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. */}
+
+ {/* 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. */}
-
- {/* 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,
+ };
+}