How it works
The product as a short sequence of steps, from sign-up to the first result.
Numbered steps with product pictures
how-it-works-01Three numbered steps from sign-up to a scheduled review, each over a small picture of that stage in the product — sources syncing, one customer matched across three systems, the review scheduled — with how long it takes.
import * as React from "react"
import Link from "next/link"
import { BriefcaseIcon, CalendarClockIcon, CheckIcon, Clock3Icon, LifeBuoyIcon, ReceiptTextIcon, type LucideIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { avatarFor } from "@/lib/avatars"
import { getInitials } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { buttonVariants } from "@/components/ui/button"
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { LineChart } from "@/components/ui/line-chart"
import { LogoMark } from "@/components/ui/logo-mark"
import { MetricDelta } from "@/components/ui/metric-delta"
import { Progress } from "@/components/ui/progress"
import { StatusBadge } from "@/components/ui/status-badge"
import {
CONNECT,
EYEBROW,
HEADLINE,
MATCH,
PRIMARY_ACTION,
SECONDARY_ACTION,
SHARE,
lede,
weekChange,
type SourceIcon,
type Step,
} from "./how-it-works-01.data"
const SOURCE_ICONS: Record<SourceIcon, LucideIcon> = {
billing: ReceiptTextIcon,
crm: BriefcaseIcon,
support: LifeBuoyIcon,
}
/** Step 1's picture: the sources list, two synced and one still importing its history. */
function ConnectPicture({ picture }: { picture: typeof CONNECT.picture }) {
return (
<Card className="h-full">
<CardHeader>
<CardTitle>{picture.title}</CardTitle>
<CardAction className="text-xs text-muted-foreground tabular-nums">{picture.status}</CardAction>
</CardHeader>
<CardContent className="flex flex-col justify-center gap-4">
{picture.sources.map((source) => {
const Icon = SOURCE_ICONS[source.icon]
return (
<div key={source.name} className="flex items-center gap-3">
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-surface ring-1 ring-border">
<Icon className="size-4" />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<span className="truncate text-sm font-medium">{source.name}</span>
<StatusBadge
status={source.state}
variant={source.state === "connected" ? "success" : "info"}
label={picture.states[source.state]}
size="sm"
/>
</div>
{source.progress === undefined ? (
<p className="mt-0.5 text-xs text-muted-foreground tabular-nums">{source.detail}</p>
) : (
<div className="mt-1.5 flex items-center gap-2">
<Progress value={source.progress} className="flex-1" />
<span className="text-xs text-muted-foreground tabular-nums">{source.progress}%</span>
</div>
)}
</div>
</div>
)
})}
</CardContent>
</Card>
)
}
/** Step 2's picture: one customer, found under its own id in each of the three systems. */
function MatchPicture({ picture }: { picture: typeof MATCH.picture }) {
return (
<Card className="h-full">
<CardHeader>
<CardTitle>{picture.title}</CardTitle>
<CardAction>
<StatusBadge status="matched" variant="success" label={picture.status} size="sm" />
</CardAction>
</CardHeader>
<CardContent className="flex flex-col justify-center gap-3">
<LogoMark name={picture.account} size="sm" />
<ul className="flex flex-col">
{picture.records.map((record) => (
<li key={record.source} className="flex items-center gap-2 border-t border-border py-2 text-xs last:pb-0">
<CheckIcon className="size-3.5 shrink-0 text-success" />
<span className="min-w-0 flex-1 truncate text-muted-foreground">{record.source}</span>
<span className="font-mono">{record.id}</span>
</li>
))}
</ul>
<p className="text-xs text-muted-foreground tabular-nums">{picture.note}</p>
</CardContent>
</Card>
)
}
/** Step 3's picture: the scheduled review — when, who, and the figure it opens on. */
function SharePicture({ picture }: { picture: typeof SHARE.picture }) {
const { metric } = picture
return (
<Card className="h-full">
<CardHeader>
<CardTitle>{picture.title}</CardTitle>
<CardAction>
<StatusBadge status="scheduled" variant="primary" label={picture.status} size="sm" />
</CardAction>
</CardHeader>
<CardContent className="flex flex-col justify-center gap-4">
<div className="flex items-center justify-between gap-3">
<span className="flex items-center gap-1.5 text-xs text-muted-foreground tabular-nums">
<CalendarClockIcon className="size-3.5 shrink-0" />
{picture.when}
</span>
<span className="flex -space-x-1.5">
{picture.people.map((name) => (
<Avatar key={name} size="sm" className="ring-2 ring-card">
<AvatarImage src={avatarFor(name)} alt="" />
<AvatarFallback>{getInitials(name)}</AvatarFallback>
</Avatar>
))}
</span>
</div>
<div className="flex items-end justify-between gap-4 border-t border-border pt-3">
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">{metric.label}</span>
<span className="type-numeral text-2xl">{metric.value}</span>
<MetricDelta value={weekChange(metric.trend)} variant="pill" size="sm" />
</div>
{/* Fitted to the weeks' own range, not from zero: the picture is
about the shape of the last eight Mondays. */}
<LineChart
data={metric.trend.map((value, week) => ({ week, value }))}
index="week"
series={[{ key: "value", label: metric.label }]}
baseline="auto"
height={48}
showXAxis={false}
showYAxis={false}
showGrid={false}
showLegend={false}
showTooltip={false}
className="max-w-36"
/>
</div>
</CardContent>
</Card>
)
}
/** One step: its picture, then its number, title, sentence and how long it takes. */
function StepItem({ step, number, picture }: { step: Step; number: number; picture: React.ReactNode }) {
return (
// A subgrid of the list's two rows from `lg`, so the three pictures share
// the tallest one's height and the three titles stand on one line.
<li className="grid gap-6 md:grid-cols-2 md:items-center md:gap-10 lg:row-span-2 lg:grid-cols-1 lg:grid-rows-subgrid lg:items-stretch lg:gap-6">
{/* The picture is decoration beside words that already say what it
shows, so it is hidden and inert. */}
<div aria-hidden="true" inert className="h-full">
{picture}
</div>
<div className="flex gap-4">
<span
aria-hidden="true"
className="flex size-7 shrink-0 items-center justify-center rounded-full bg-foreground text-sm font-medium text-background tabular-nums"
>
{number}
</span>
<div className="min-w-0">
<h3 className="text-base font-semibold text-balance">{step.title}</h3>
<p className="mt-2 text-sm/6 text-pretty text-muted-foreground">{step.text}</p>
<p className="mt-3 flex items-center gap-1.5 text-xs text-muted-foreground tabular-nums">
<Clock3Icon aria-hidden="true" className="size-3.5 shrink-0" />
{step.time}
</p>
</div>
</div>
</li>
)
}
/**
* How it works as three numbered steps, each over a small picture of that
* stage in the product — the sources syncing, one customer matched across
* three systems, the review scheduled — drawn with the kit's own Card,
* StatusBadge, Progress, Avatar and LineChart rather than a screenshot, so it
* never goes stale. The lede's total is added up from the steps.
*
* Three columns from `lg`, the pictures sharing one height; on a tablet each
* step is its picture beside its words; on a phone they stack. A server
* component; every word and figure is in `./how-it-works-01.data`.
*/
export function HowItWorks01() {
const titleId = React.useId()
return (
<section
data-section="how-it-works-01"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="mx-auto flex max-w-3xl flex-col items-center text-center">
<p className="type-eyebrow">{EYEBROW}</p>
<h2 id={titleId} className="type-display mt-4 text-3xl text-balance md:text-4xl">
{HEADLINE}
</h2>
<p className="mt-5 max-w-2xl text-base/7 text-pretty text-muted-foreground md:text-lg/8">{lede()}</p>
</div>
<ol className="mt-12 grid gap-x-6 gap-y-12 md:mt-16 lg:grid-cols-3">
<StepItem step={CONNECT.step} number={1} picture={<ConnectPicture picture={CONNECT.picture} />} />
<StepItem step={MATCH.step} number={2} picture={<MatchPicture picture={MATCH.picture} />} />
<StepItem step={SHARE.step} number={3} picture={<SharePicture picture={SHARE.picture} />} />
</ol>
<div className="mt-12 flex flex-wrap items-center justify-center gap-3 md:mt-16">
<Link href={PRIMARY_ACTION.href} className={cn(buttonVariants({ size: "lg" }), "h-10 px-5")}>
{PRIMARY_ACTION.label}
</Link>
<Link href={SECONDARY_ACTION.href} className={cn(buttonVariants({ variant: "outline", size: "lg" }), "h-10 px-5")}>
{SECONDARY_ACTION.label}
</Link>
</div>
</div>
</section>
)
}
export default HowItWorks01/**
* Every word `how-it-works-01` sets: the header, the two ways in, and three
* steps from sign-up to a scheduled Monday review — each with what it asks of
* the reader, how long it takes and what its picture shows. The pictures'
* figures are a sample workspace's, copy like the rest. The lede's total is
* added up from the steps by `totalMinutes()`, so it can never disagree with
* them.
*/
import type { LogoName } from "@/components/ui/logo-mark"
export type HowLink = { label: string; href: string }
export const EYEBROW = "How it works"
export const HEADLINE = "Three steps to a Monday review that builds itself"
/** `{minutes}` is replaced with the steps' total. */
export const LEDE =
"Connect the tools you already run, let Northwind match your customers across them, and schedule the review. It takes about {minutes}\u00a0minutes, and most of that is Northwind working."
export const PRIMARY_ACTION: HowLink = { label: "Start free trial", href: "/sign-up" }
export const SECONDARY_ACTION: HowLink = { label: "Open a sample workspace", href: "/demo" }
/** One step as the reader reads it: what to do, and how long it takes. */
export type Step = {
title: string
text: string
/** Minutes, as a number: the lede adds these up. */
minutes: number
/** The same time in words, under the step: "About 5 minutes". */
time: string
}
/** The picture each source is drawn with: vocabulary of the layout, not copy. */
export type SourceIcon = "billing" | "crm" | "support"
export type Source = {
icon: SourceIcon
name: string
state: "connected" | "importing"
/** Shown under a connected source: when it last synced. */
detail: string
/** An importing source's progress, 0 to 100. */
progress?: number
}
export const CONNECT = {
step: {
title: "Connect billing and your CRM",
text: "Sign in to each with read-only access. Northwind reads thirteen months of history, then syncs every five minutes.",
minutes: 5,
time: "About 5 minutes",
} satisfies Step,
picture: {
title: "Sources",
status: "2 of 3 connected",
states: { connected: "Connected", importing: "Importing" },
sources: [
{ icon: "billing", name: "Billing", state: "connected", detail: "Synced 2 min ago" },
{ icon: "crm", name: "CRM", state: "connected", detail: "Synced 4 min ago" },
{ icon: "support", name: "Support desk", state: "importing", detail: "13 months of tickets", progress: 64 },
] satisfies Source[],
},
}
export const MATCH = {
step: {
title: "Let Northwind match your accounts",
text: "Each customer is matched across billing, the CRM and the support desk by domain and contract. The few it cannot place wait for you to confirm.",
minutes: 4,
time: "About 4 minutes, on its own",
} satisfies Step,
picture: {
title: "Account match",
status: "Matched",
account: "Copperline" as LogoName,
/** The same customer's id in each system it was found in. */
records: [
{ source: "Billing", id: "cus_4821QX" },
{ source: "CRM", id: "001Hq00002Tz" },
{ source: "Support desk", id: "org_22917" },
],
note: "1,284 accounts matched, 1,233 without help",
},
}
export const SHARE = {
step: {
title: "Schedule the Monday review",
text: "Pick the dashboard, the people and the hour. Everyone gets the same numbers you see, in a link that opens on this week.",
minutes: 2,
time: "About 2 minutes",
} satisfies Step,
picture: {
title: "Monday revenue review",
status: "Scheduled",
when: "Mondays at 09:00 UTC",
people: ["Mira Castellanos", "Kwame Mensah", "Rohan Mehta", "Ines Carvalho", "Tomás Ferreira"],
metric: {
label: "MRR",
value: "$412.8K",
/** Eight Mondays of MRR, in thousands; the last is the figure above. */
trend: [371.2, 376.5, 381.9, 388.4, 392.0, 398.7, 400.4, 412.8],
},
},
}
/** The three steps, in the order a team takes them. */
export const STEPS: readonly Step[] = [CONNECT.step, MATCH.step, SHARE.step]
/** How long the three steps take together, in minutes. */
export function totalMinutes(steps: readonly Step[] = STEPS): number {
return steps.reduce((sum, step) => sum + step.minutes, 0)
}
/** The lede with its total filled in. */
export function lede(steps: readonly Step[] = STEPS): string {
return LEDE.replace("{minutes}", String(totalMinutes(steps)))
}
/** The change the review opens on — this Monday against the one before — as a ratio: 400.4 to 412.8 is +3.1%. */
export function weekChange(trend: readonly number[] = SHARE.picture.metric.trend): number {
const [before, now] = trend.slice(-2)
return before ? (now - before) / before : 0
}npx shadcn@latest add @vibra/how-it-works-01Timeline of the first two weeks
how-it-works-02A trial's first fortnight on one rule: the first session in minutes and the rest of the trial in days, side by side with the jump between them dashed, each milestone saying when it happens and who takes it.
import * as React from "react"
import Link from "next/link"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
import { ACTORS, EYEBROW, HEADLINE, LEDE, PHASES, PRIMARY_ACTION, type Actor, type Milestone, type Phase } from "./how-it-works-02.data"
/**
* A milestone's dot: filled in the ink for a step the reader takes, a ring for
* one Northwind takes on its own. Decoration — the milestone says who in words
* — cut out of the rule behind it by the page's own ground.
*/
function Dot({ actor, className }: { actor: Actor; className?: string }) {
return (
<span
aria-hidden="true"
data-actor={actor}
className={cn(
"size-2.5 shrink-0 rounded-full",
actor === "you" ? "bg-foreground" : "bg-background ring-2 ring-foreground ring-inset",
className
)}
/>
)
}
/**
* One milestone: when, who, what. Down the page on a phone, each draws its own
* piece of the rule from under its dot to the next one's, so the line never
* runs past the last dot; across the page from `lg` the phase's list draws one
* rule the dots sit on.
*/
function MilestoneItem({ milestone }: { milestone: Milestone }) {
return (
<li className="relative pl-7 before:absolute before:top-[13px] before:-bottom-[35px] before:left-[4.5px] before:w-px before:bg-rule last:before:hidden lg:pt-8 lg:pl-0 lg:before:hidden">
<Dot actor={milestone.actor} className="absolute top-[3px] left-0 lg:top-0" />
<p className="text-xs font-medium text-muted-foreground tabular-nums">
{milestone.when}
<span aria-hidden="true"> · </span>
<span className="sr-only">, </span>
{ACTORS[milestone.actor]}
</p>
<p className="mt-2 text-sm font-semibold text-balance">{milestone.title}</p>
<p className="mt-1.5 text-sm/6 text-pretty text-muted-foreground">{milestone.text}</p>
</li>
)
}
/**
* A phase: its title and span over its milestones, an ordered list named by
* the title. The second phase opens on a dashed piece of rule across the gap
* before it, so the time skipped between them reads as skipped.
*/
function PhaseBlock({ phase, afterBreak }: { phase: Phase; afterBreak: boolean }) {
const labelId = React.useId()
return (
<div>
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<h3 id={labelId} className="text-base font-semibold">
{phase.title}
</h3>
<p className="text-sm text-muted-foreground tabular-nums">{phase.span}</p>
</div>
<ol
aria-labelledby={labelId}
className={cn(
"relative mt-6 grid gap-8 lg:mt-8 lg:grid-cols-3 lg:gap-6",
"lg:before:absolute lg:before:inset-x-0 lg:before:top-[4.5px] lg:before:h-px lg:before:bg-rule",
afterBreak &&
"lg:after:absolute lg:after:top-[4.5px] lg:after:right-full lg:after:w-12 lg:after:border-t lg:after:border-dashed lg:after:border-rule"
)}
>
{phase.milestones.map((milestone) => (
<MilestoneItem key={milestone.when} milestone={milestone} />
))}
</ol>
</div>
)
}
/**
* How it works as a timeline of a trial's first two weeks: the first session,
* counted in minutes, and the rest of the trial, counted in days, as two
* phases side by side on one rule from `lg` — the jump between them dashed —
* and down the page on a phone. Every milestone says when it happens and who
* does it: a filled dot for the reader, a ring for Northwind, and the same in
* words beside the time.
*
* A server component; every word is in `./how-it-works-02.data`.
*/
export function HowItWorks02() {
const titleId = React.useId()
return (
<section
data-section="how-it-works-02"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="grid gap-6 lg:grid-cols-12 lg:items-end lg:gap-16">
<div className="lg:col-span-7">
<p className="type-eyebrow">{EYEBROW}</p>
<h2 id={titleId} className="type-display mt-4 text-3xl text-balance md:text-4xl">
{HEADLINE}
</h2>
</div>
<div className="flex flex-col items-start gap-6 lg:col-span-5">
<p className="text-base/7 text-pretty text-muted-foreground">{LEDE}</p>
<div className="flex flex-wrap items-center gap-x-6 gap-y-4">
<Link href={PRIMARY_ACTION.href} className={cn(buttonVariants({ size: "lg" }), "h-10 px-5")}>
{PRIMARY_ACTION.label}
</Link>
{/* The key to the dots, for the eye: each milestone already says
who takes it in words. */}
<p aria-hidden="true" className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1.5">
<Dot actor="you" />
{ACTORS.you}
</span>
<span className="flex items-center gap-1.5">
<Dot actor="northwind" />
{ACTORS.northwind}
</span>
</p>
</div>
</div>
</div>
<div className="mt-14 grid gap-12 md:mt-16 lg:grid-cols-2">
{PHASES.map((phase, index) => (
<PhaseBlock key={phase.title} phase={phase} afterBreak={index > 0} />
))}
</div>
</div>
</section>
)
}
export default HowItWorks02/**
* Every word `how-it-works-02` sets: the header, the call to action and a
* trial's first two weeks as two phases of milestones — each with when it
* happens, who does it and what it is. Who does it is said in words at every
* milestone; the dots only repeat it. Replace the phases with your own
* product's first fortnight and the timeline draws that.
*/
export type HowLink = { label: string; href: string }
export const EYEBROW = "The first two weeks"
export const HEADLINE = "From your first minute to your second Monday"
export const LEDE =
"The first session takes about eleven minutes. The rest of the trial is your team using Northwind on real work, with a person from support a message away."
export const PRIMARY_ACTION: HowLink = { label: "Start free trial", href: "/sign-up" }
/** Who does a step: the reader, or Northwind on its own. */
export type Actor = "you" | "northwind"
/** How each actor is said beside the time, and shown in the key. */
export const ACTORS: Record<Actor, string> = { you: "You", northwind: "Northwind" }
export type Milestone = {
/** When it happens, counted from sign-up: "Minute 3", "Day 7". */
when: string
actor: Actor
title: string
text: string
}
export type Phase = {
title: string
/** The span the phase covers, under its title. */
span: string
milestones: Milestone[]
}
export const PHASES: readonly Phase[] = [
{
title: "The first session",
span: "Minutes 0 to 11",
milestones: [
{
when: "Minute 0",
actor: "you",
title: "Create the workspace",
text: "An email address and a name for the workspace. No card, and no call with sales.",
},
{
when: "Minute 3",
actor: "you",
title: "Connect billing and the CRM",
text: "Read-only access to each. Northwind starts on thirteen months of history.",
},
{
when: "Minute 11",
actor: "northwind",
title: "The first report is ready",
text: "Revenue, retention and pipeline, built from what Northwind found in your sources.",
},
],
},
{
title: "The rest of the trial",
span: "Days 2 to 14",
milestones: [
{
when: "Day 2",
actor: "you",
title: "Invite the review's regulars",
text: "Each person lands on the report you shared, not on an empty workspace.",
},
{
when: "Day 7",
actor: "northwind",
title: "The first Monday review",
text: "It opens on this week with last week behind it, and the weekend's alerts on top.",
},
{
when: "Day 14",
actor: "you",
title: "Keep it, or take it with you",
text: "Choose a plan, or export every report and definition as CSV. Nothing is deleted for 30 days.",
},
],
},
]npx shadcn@latest add @vibra/how-it-works-02Steps as tabs over their screens
how-it-works-03Four steps — connect, match, define, share — as a row of tabs; each panel is the step's words beside a picture of the screen it happens on, and ends on a button to the next step.
import * as React from "react"
import Link from "next/link"
import { ActivityIcon, BriefcaseIcon, CheckIcon, DatabaseIcon, LifeBuoyIcon, PlusIcon, ReceiptTextIcon, SheetIcon, type LucideIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { avatarFor } from "@/lib/avatars"
import { getInitials } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { buttonVariants } from "@/components/ui/button"
import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { LineChart } from "@/components/ui/line-chart"
import { LogoMark } from "@/components/ui/logo-mark"
import { MetricDelta } from "@/components/ui/metric-delta"
import { StepTabs } from "./how-it-works-03.client"
import {
CONNECT_PICTURE,
DEFINE_PICTURE,
EYEBROW,
FINISH,
HEADLINE,
LEDE,
MATCH_PICTURE,
NEXT,
SHARE_PICTURE,
STEPS,
STEPS_LABEL,
STEP_OF,
fill,
type SourceIcon,
type Step,
type StepId,
} from "./how-it-works-03.data"
const SOURCE_ICONS: Record<SourceIcon, LucideIcon> = {
billing: ReceiptTextIcon,
crm: BriefcaseIcon,
product: ActivityIcon,
support: LifeBuoyIcon,
warehouse: DatabaseIcon,
sheet: SheetIcon,
}
type ScreenProps = { title: string; description?: string; action?: React.ReactNode; footer?: React.ReactNode; children: React.ReactNode }
/** A picture's frame: the kit's Card with the screen's title on the strip. */
function Screen({ title, description, action, footer, children }: ScreenProps) {
return (
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
{description ? <CardDescription>{description}</CardDescription> : null}
{action ? <CardAction>{action}</CardAction> : null}
</CardHeader>
<CardContent className="flex min-h-64 flex-col justify-center">{children}</CardContent>
{/* One height for every footer, faces or none, so no screen is taller than another for it. */}
{footer ? <CardFooter className="min-h-11 text-xs text-muted-foreground tabular-nums">{footer}</CardFooter> : null}
</Card>
)
}
function ConnectScreen() {
const picture = CONNECT_PICTURE
return (
<Screen title={picture.title} description={picture.description} footer={picture.footer}>
<ul className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{picture.sources.map((source) => {
const Icon = SOURCE_ICONS[source.icon]
return (
<li key={source.name} className={cn("flex flex-col gap-1.5 rounded-lg p-2.5 ring-1 ring-border sm:gap-3 sm:p-3", source.connected && "bg-surface")}>
<span className="flex items-center gap-2 sm:flex-col sm:items-start sm:gap-3">
<Icon className="size-4 shrink-0" />
<span className="truncate text-sm font-medium">{source.name}</span>
</span>
{source.connected ? (
<span className="flex items-center gap-1 text-xs text-success">
<CheckIcon className="size-3.5" />
{picture.connected}
</span>
) : (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<PlusIcon className="size-3.5" />
{picture.available}
</span>
)}
</li>
)
})}
</ul>
</Screen>
)
}
function MatchScreen() {
const picture = MATCH_PICTURE
return (
<Screen title={picture.title} description={picture.description} footer={picture.footer}>
<table className="w-full text-left text-xs">
<thead className="text-muted-foreground">
<tr className="border-b border-rule">
<th className="pb-2 font-medium">{picture.columns.account}</th>
<th className="pb-2 font-medium">{picture.columns.found}</th>
<th className="pb-2 text-right font-medium">{picture.columns.by}</th>
</tr>
</thead>
<tbody>
{picture.rows.map((row) => (
<tr key={row.account} className="border-b border-border last:border-b-0">
<td className="py-2.5">
<LogoMark name={row.account} size="sm" />
</td>
<td className="py-2.5">
<span className="flex gap-1.5">
{picture.systems.map((system, index) => {
const Icon = SOURCE_ICONS[system.icon]
return <Icon key={system.name} className={cn("size-3.5", row.found[index] ? "text-foreground" : "text-faint-foreground/40")} />
})}
</span>
</td>
<td className="py-2.5 text-right text-muted-foreground">{row.by}</td>
</tr>
))}
</tbody>
</table>
</Screen>
)
}
function DefineScreen() {
const picture = DEFINE_PICTURE
return (
<Screen
title={picture.title}
description={picture.window}
action={<Badge variant="secondary">{picture.version}</Badge>}
footer={
<span className="flex items-center gap-2">
<Avatar size="sm">
<AvatarImage src={avatarFor(picture.owner.name)} alt="" />
<AvatarFallback>{getInitials(picture.owner.name)}</AvatarFallback>
</Avatar>
{fill(picture.footer, { team: picture.owner.team })}
</span>
}
>
<div className="grid gap-6 sm:grid-cols-2 sm:items-center">
<ol className="flex flex-col gap-1.5 font-mono text-xs">
{picture.terms.map((term) => (
<li key={term.label} className="flex gap-2">
<span className="w-3 text-muted-foreground">{term.sign}</span>
{term.label}
</li>
))}
<li className="mt-1 border-t border-rule pt-2 text-muted-foreground">{picture.divisor}</li>
</ol>
<div className="flex flex-col gap-2">
<span className="type-numeral text-3xl">{picture.value}</span>
<LineChart
data={picture.months.map((value, month) => ({ month, value }))}
index="month"
series={[{ key: "value", label: picture.title }]}
baseline="auto"
height={56}
showXAxis={false}
showYAxis={false}
showGrid={false}
showLegend={false}
showTooltip={false}
/>
</div>
</div>
</Screen>
)
}
function ShareScreen() {
const picture = SHARE_PICTURE
return (
<Screen
title={picture.title}
description={picture.description}
footer={
<span className="flex items-center gap-2">
<span className="flex -space-x-1.5">
{picture.people.map((name) => (
<Avatar key={name} size="sm" className="ring-2 ring-surface">
<AvatarImage src={avatarFor(name)} alt="" />
<AvatarFallback>{getInitials(name)}</AvatarFallback>
</Avatar>
))}
</span>
{picture.footer}
</span>
}
>
<ul className="flex flex-col">
{picture.figures.map((figure) => (
<li key={figure.label} className="flex items-center justify-between gap-4 border-b border-border py-3 first:pt-0 last:border-b-0 last:pb-0">
<span className="text-sm text-muted-foreground">{figure.label}</span>
<span className="flex items-center gap-2">
<span className="type-numeral text-lg">{figure.value}</span>
<MetricDelta value={figure.change} positiveIsGood={!figure.lowerIsBetter} variant="pill" size="sm" />
</span>
</li>
))}
</ul>
</Screen>
)
}
const SCREENS: Record<StepId, () => React.ReactNode> = {
connect: ConnectScreen,
match: MatchScreen,
define: DefineScreen,
share: ShareScreen,
}
/** A step's words: its place in the sequence, its title as an h3, its sentence and its specifics. */
function StepBody({ step, number }: { step: Step; number: number }) {
return (
<>
<p className="type-eyebrow tabular-nums">{fill(STEP_OF, { n: number, total: STEPS.length })}</p>
<h3 className="type-display mt-3 text-2xl text-balance">{step.title}</h3>
<p className="mt-4 text-base/7 text-pretty text-muted-foreground">{step.text}</p>
<ul className="mt-6 flex flex-col gap-2.5 text-sm">
{step.points.map((point) => (
<li key={point} className="flex items-start gap-2.5">
<CheckIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
{point}
</li>
))}
</ul>
</>
)
}
/**
* How it works as four steps behind tabs — connect, match, define, share —
* each panel the step's words beside a picture of the screen it happens on:
* the sources grid, the matched accounts, a metric's definition with its
* owner, the scheduled review. The pictures are the kit's own Card, LogoMark,
* Avatar, LineChart and MetricDelta rather than screenshots, hidden and inert
* beside words that already say what they show.
*
* A server component: it draws every step's words and picture and hands them
* to the island, which only chooses between them. Every word and figure is in
* `./how-it-works-03.data`.
*/
export function HowItWorks03() {
const titleId = React.useId()
const tabs = STEPS.map((step, index) => {
const Picture = SCREENS[step.id]
const next = STEPS[index + 1]
return {
value: step.id,
label: step.tab,
body: <StepBody step={step} number={index + 1} />,
picture: (
<div aria-hidden="true" inert>
<Picture />
</div>
),
next: next ? fill(NEXT, { step: next.tab }) : undefined,
}
})
return (
<section
data-section="how-it-works-03"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="max-w-3xl">
<p className="type-eyebrow">{EYEBROW}</p>
<h2 id={titleId} className="type-display mt-4 text-3xl text-balance md:text-4xl">
{HEADLINE}
</h2>
<p className="mt-5 text-base/7 text-pretty text-muted-foreground md:text-lg/8">{LEDE}</p>
</div>
<div className="mt-10 md:mt-12">
<StepTabs
steps={tabs}
label={STEPS_LABEL}
finish={
<Link href={FINISH.href} className={cn(buttonVariants({ size: "lg" }), "h-10 px-5")}>
{FINISH.label}
</Link>
}
/>
</div>
</div>
</section>
)
}
export default HowItWorks03"use client"
import * as React from "react"
import { ArrowRightIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
/** One step as the island receives it: its tab, and its words and picture drawn by the server file. */
export type StepTab = {
value: string
label: string
/** The step's words: its place in the sequence, title, sentence and specifics. */
body: React.ReactNode
/** The screen the step happens on: decoration beside the words, already hidden. */
picture: React.ReactNode
/** The next step's button, "Next: Match"; absent on the last step. */
next?: string
}
/**
* The steps are the kit's Tabs — one choice drives one panel — so the arrow
* keys move between steps and bring each screen up as they land, Home and End
* jump to the ends, and a reader hears which step is chosen. Each panel but
* the last ends on a button to the next step: it chooses that step and puts
* focus on its tab, because the button itself leaves with the panel it was
* in, and focus must not fall back to the top of the page. The last panel
* ends on `finish`, drawn by the server file.
*
* Every word and picture arrives as plain props from the section.
*/
export function StepTabs({ steps, label, finish }: { steps: readonly StepTab[]; label: string; finish: React.ReactNode }) {
const [value, setValue] = React.useState(steps[0]?.value)
const tabs = React.useRef(new Map<string, HTMLElement | null>())
function goTo(step: string) {
setValue(step)
tabs.current.get(step)?.focus()
}
return (
<Tabs value={value} onValueChange={(next) => setValue(String(next))} className="gap-8 md:gap-10">
<TabsList aria-label={label} activateOnFocus className="w-full sm:w-fit">
{steps.map((step, index) => (
<TabsTrigger
key={step.value}
value={step.value}
ref={(node: HTMLElement | null) => {
tabs.current.set(step.value, node)
}}
className="gap-2 px-3 sm:flex-none"
>
<span aria-hidden="true" className="text-xs text-muted-foreground tabular-nums">
{index + 1}
</span>
{step.label}
</TabsTrigger>
))}
</TabsList>
{steps.map((step, index) => {
const next = steps[index + 1]
return (
<TabsContent
key={step.value}
value={step.value}
className="grid gap-10 rounded-xl focus-ring lg:grid-cols-12 lg:items-center lg:gap-16"
>
<div className="flex flex-col items-start lg:col-span-5">
{step.body}
<div className="mt-8">
{next && step.next ? (
<Button variant="outline" size="lg" className="h-10 px-5" onClick={() => goTo(next.value)}>
{step.next}
<ArrowRightIcon data-icon="inline-end" aria-hidden="true" />
</Button>
) : (
finish
)}
</div>
</div>
<div className="min-w-0 lg:col-span-7">{step.picture}</div>
</TabsContent>
)
})}
</Tabs>
)
}/**
* Every word `how-it-works-03` sets: the header, the tab list's name, the
* words around each step and four steps — connect, match, define, share —
* each with its tab, its title, a sentence, three specifics and what its
* picture of that screen shows. The pictures' figures are a sample
* workspace's, copy like the rest.
*/
import type { LogoName } from "@/components/ui/logo-mark"
export type HowLink = { label: string; href: string }
export const EYEBROW = "How it works"
export const HEADLINE = "Four steps, each on the screen where it happens"
export const LEDE =
"Choose a step to see the screen it happens on. None of them is a mock-up of something coming later: every screen here is in the trial."
/** The tab list's name, for a screen reader. */
export const STEPS_LABEL = "Setup steps"
/** Above each step's title: `{n}` and `{total}` are filled in. */
export const STEP_OF = "Step {n} of {total}"
/** The button that moves to the next step: `{step}` is its tab. */
export const NEXT = "Next: {step}"
/** In place of the next button on the last step. */
export const FINISH: HowLink = { label: "Start free trial", href: "/sign-up" }
export type StepId = "connect" | "match" | "define" | "share"
export type Step = {
id: StepId
/** The tab's label: one word. */
tab: string
title: string
text: string
points: string[]
}
export const STEPS: readonly Step[] = [
{
id: "connect",
tab: "Connect",
title: "Connect the systems you already run",
text: "Billing and the CRM first, then whatever else the Monday review draws on. Each takes a sign-in and a click to approve read-only access.",
points: ["Read-only, over OAuth or a scoped key", "Thirteen months of history on day one", "Synced every five minutes after that"],
},
{
id: "match",
tab: "Match",
title: "Let Northwind match every account",
text: "The same customer has a different id in each system. Northwind matches them by domain and contract, and asks you about the few it cannot place.",
points: ["96% matched without help", "The rest wait in one list for you", "A match you correct is never undone"],
},
{
id: "define",
tab: "Define",
title: "Agree on what each number means",
text: "Start from the definitions Northwind ships for revenue, retention and support, and change any of them. Each change is versioned and has an owner.",
points: ["47 definitions to start from", "Every change reviewed before it applies", "Checked on every sync"],
},
{
id: "share",
tab: "Share",
title: "Send the review before anyone asks",
text: "Schedule the dashboard to the people and the channel that read it. It arrives with this week's figures and what changed since last week.",
points: ["Email, chat or a link", "The same numbers for everyone", "Paused for a week with one click"],
},
]
/** The picture each source is drawn with: vocabulary of the layout, not copy. */
export type SourceIcon = "billing" | "crm" | "product" | "support" | "warehouse" | "sheet"
export const CONNECT_PICTURE = {
title: "Add a source",
description: "Read-only, over OAuth or a scoped key",
connected: "Connected",
available: "Connect",
sources: [
{ icon: "billing", name: "Billing", connected: true },
{ icon: "crm", name: "CRM", connected: true },
{ icon: "product", name: "Product events", connected: false },
{ icon: "support", name: "Support desk", connected: false },
{ icon: "warehouse", name: "Warehouse", connected: false },
{ icon: "sheet", name: "Spreadsheet", connected: false },
] satisfies { icon: SourceIcon; name: string; connected: boolean }[],
footer: "2 of 6 connected · syncing every 5 minutes",
}
export const MATCH_PICTURE = {
title: "Accounts",
description: "Matched across billing, the CRM and the support desk",
columns: { account: "Account", found: "Found in", by: "Matched by" },
/** The three systems an account can be found in, in the order they are drawn. */
systems: [
{ icon: "billing", name: "Billing" },
{ icon: "crm", name: "CRM" },
{ icon: "support", name: "Support desk" },
] satisfies { icon: SourceIcon; name: string }[],
rows: [
{ account: "Copperline", found: [true, true, true], by: "Domain" },
{ account: "Harbour", found: [true, true, true], by: "Contract id" },
{ account: "Kestrel", found: [true, true, false], by: "Domain" },
{ account: "Solstice", found: [true, false, true], by: "You confirmed" },
] satisfies { account: LogoName; found: [boolean, boolean, boolean]; by: string }[],
footer: "1,284 matched · 9 waiting for you",
}
export const DEFINE_PICTURE = {
title: "Net revenue retention",
version: "v4",
/** The formula, a term to a line: the sign it enters with and what it is. */
terms: [
{ sign: "", label: "Starting MRR" },
{ sign: "+", label: "Expansion" },
{ sign: "−", label: "Contraction" },
{ sign: "−", label: "Churn" },
],
divisor: "÷ Starting MRR",
value: "112%",
window: "Trailing 12 months",
/** Twelve months of the measure, in percent; the last is the figure above. */
months: [104, 105, 105, 107, 106, 108, 109, 109, 110, 111, 111, 112],
owner: { name: "Mira Castellanos", team: "Finance" },
footer: "Owned by {team} · checked 2 min ago",
}
export const SHARE_PICTURE = {
title: "Monday revenue review",
description: "Every Monday at 09:00 UTC",
people: ["Mira Castellanos", "Kwame Mensah", "Rohan Mehta", "Ines Carvalho", "Tomás Ferreira", "Aiko Tanaka"],
footer: "To 6 people and the revenue channel",
/** This week against last: each change is a ratio, `lowerIsBetter` where down is the win. */
figures: [
{ label: "MRR", value: "$412.8K", change: 0.031, lowerIsBetter: false },
{ label: "Net revenue retention", value: "112%", change: 0.009, lowerIsBetter: false },
{ label: "Open support tickets", value: "38", change: -0.12, lowerIsBetter: true },
],
}
/** Fills `{name}` placeholders in a line of copy. */
export function fill(template: string, values: Record<string, string | number>): string {
return template.replace(/\{(\w+)\}/g, (match, key: string) => (key in values ? String(values[key]) : match))
}npx shadcn@latest add @vibra/how-it-works-03Before and after, timed
how-it-works-04One team's Monday report before Northwind and after it: both routines as bars on one scale, a segment per task, captioned with the time given back, over both as lists of tasks with their tools and minutes.
import * as React from "react"
import { cn } from "@/lib/utils"
import { MetricDelta } from "@/components/ui/metric-delta"
import { AFTER, BEFORE, EYEBROW, HEADLINE, LEDE, TICKS, change, duration, saved, summary, total, type Routine } from "./how-it-works-04.data"
type Tone = "before" | "after"
/** Each routine's colour: the first two chart slots, grey then the first hue. */
const SWATCH: Record<Tone, string> = { before: "bg-chart-1", after: "bg-chart-2" }
/** A share of the scale, as a CSS length. */
const share = (minutes: number, scale: number) => `${(minutes / scale) * 100}%`
/**
* One routine's bar: its title and length over a segment per task, each as
* long as the task took, on the scale both bars share — the longer routine's.
* The bar repeats what the lists below say in words, so it is hidden.
*/
function TimeBar({ routine, tone, scale, against }: { routine: Routine; tone: Tone; scale: number; against?: Routine }) {
return (
<div>
<p className="flex items-baseline justify-between gap-4">
<span className="text-sm font-medium">{routine.title}</span>
<span className="flex items-center gap-3">
{/* Against the routine it replaced: the fall is the win. */}
{against ? <MetricDelta value={change(against, routine)} positiveIsGood={false} variant="pill" size="sm" /> : null}
<span className="type-numeral text-2xl md:text-3xl">{duration(total(routine))}</span>
</span>
</p>
<div aria-hidden="true" className="mt-3 flex h-3 gap-0.5">
{routine.tasks.map((task) => (
<span
key={task.title}
data-slot="time-bar-segment"
className={cn("h-full min-w-1 rounded-xs", SWATCH[tone])}
style={{ width: share(task.minutes, scale) }}
/>
))}
</div>
</div>
)
}
/** A routine as the ordered list of its tasks: what, where, and how long. */
function RoutineList({ routine, tone }: { routine: Routine; tone: Tone }) {
const titleId = React.useId()
return (
<div>
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 border-b border-rule pb-4">
<h3 id={titleId} className="flex items-center gap-2.5 text-base font-semibold">
<span aria-hidden="true" className={cn("size-2.5 shrink-0 rounded-xs", SWATCH[tone])} />
{routine.title}
</h3>
<p className="text-sm text-muted-foreground tabular-nums">{summary(routine)}</p>
</div>
<ol aria-labelledby={titleId}>
{routine.tasks.map((task, index) => (
<li
key={task.title}
className="grid grid-cols-[1.25rem_minmax(0,1fr)_auto] gap-x-3 border-b border-border py-4 last:border-b-0"
>
<span aria-hidden="true" className="text-sm text-muted-foreground tabular-nums">
{index + 1}
</span>
<div className="min-w-0">
<p className="text-sm text-pretty">{task.title}</p>
<p className="mt-1 text-xs text-muted-foreground">{task.tool}</p>
</div>
<p className="text-right text-sm tabular-nums">{duration(task.minutes)}</p>
</li>
))}
</ol>
</div>
)
}
/**
* How it works as a before and after: one team's Monday report as they ran it
* before Northwind and as they run it now. At the top, both routines as bars
* on one scale, a segment per task, with the time given back as the caption;
* under them, both as ordered lists — each task, the tool it happens in and
* how long it took. Every total, count and length is worked out from the
* tasks, so the bars cannot disagree with the lists.
*
* The lists stand side by side from `lg` and stack on a phone, the old way
* first. A server component; every word and minute is in
* `./how-it-works-04.data`.
*/
export function HowItWorks04() {
const titleId = React.useId()
const captionId = React.useId()
const scale = Math.max(total(BEFORE), total(AFTER))
return (
<section
data-section="how-it-works-04"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="max-w-3xl">
<p className="type-eyebrow">{EYEBROW}</p>
<h2 id={titleId} className="type-display mt-4 text-3xl text-balance md:text-4xl">
{HEADLINE}
</h2>
<p className="mt-5 text-base/7 text-pretty text-muted-foreground md:text-lg/8">{LEDE}</p>
</div>
<figure aria-labelledby={captionId} className="mt-12 frame p-5 md:mt-16 md:p-8">
<div className="flex flex-col gap-6">
<TimeBar routine={BEFORE} tone="before" scale={scale} />
<TimeBar routine={AFTER} tone="after" scale={scale} against={BEFORE} />
</div>
{/* The scale in hours, under both bars: marks for the eye only. */}
<div aria-hidden="true" className="relative mt-3 h-5 text-xs text-muted-foreground tabular-nums">
{TICKS.filter((tick) => tick <= scale).map((tick) => (
<span key={tick} className="absolute top-0 border-l border-rule pt-1.5 pl-1.5" style={{ left: share(tick, scale) }}>
{duration(tick).replace(/^0 min$/, "0")}
</span>
))}
</div>
<figcaption id={captionId} className="mt-6 border-t border-rule pt-5 text-base font-medium text-pretty md:mt-8">{saved()}</figcaption>
</figure>
<div className="mt-12 grid gap-12 md:mt-16 lg:grid-cols-2 lg:gap-16">
<RoutineList routine={BEFORE} tone="before" />
<RoutineList routine={AFTER} tone="after" />
</div>
</div>
</section>
)
}
export default HowItWorks04/**
* Every word and minute `how-it-works-04` sets: one team's Monday report as
* they ran it before Northwind and as they run it now, task by task, with the
* tool each task happens in and how long it took. The totals, the time saved,
* the tool counts and every bar's length are worked out from the tasks by the
* helpers below, so the picture can never disagree with the lists. Replace the
* two routines with ones your customers timed.
*/
export const EYEBROW = "Before and after"
export const HEADLINE = "The same Monday report, before Northwind and after it"
export const LEDE =
"Timed with the revenue operations team at Tidewater on a Monday in May, and again on a Monday in July, six weeks after they switched."
/** The comparison's caption: `{saved}` is the time the new routine gives back. */
export const SAVED = "{saved} back every Monday, on the same report."
/** Under each routine's title: `{tasks}` and `{tools}` are counted from its list. */
export const SUMMARY = "{tasks} in {tools}"
/** The marks under the bars, in minutes: the scale is the longer routine's. */
export const TICKS = [0, 60, 120, 180] as const
export type Task = {
title: string
/** Where the task happens, named by what it is, never by a vendor. */
tool: string
minutes: number
}
export type Routine = { title: string; tasks: readonly Task[] }
export const BEFORE: Routine = {
title: "Before Northwind",
tasks: [
{ title: "Export last week's invoices", tool: "Billing", minutes: 20 },
{ title: "Export the open pipeline", tool: "CRM", minutes: 15 },
{ title: "Paste both into the master sheet and fix the account names that do not match", tool: "Spreadsheet", minutes: 70 },
{ title: "Ask support for ticket counts by account, then wait for the reply", tool: "Team chat", minutes: 45 },
{ title: "Rebuild the eight charts in the deck", tool: "Slides", minutes: 55 },
{ title: "Check the totals against last week's deck", tool: "Spreadsheet", minutes: 25 },
],
}
export const AFTER: Routine = {
title: "With Northwind",
tasks: [
{ title: "Open the Monday review, synced at 08:55", tool: "Northwind", minutes: 2 },
{ title: "Read the three alerts raised over the weekend, and add a note to each", tool: "Northwind", minutes: 10 },
{ title: "Send the review's link to the team", tool: "Northwind", minutes: 1 },
],
}
/** A routine's length, in minutes. */
export function total(routine: Routine): number {
return routine.tasks.reduce((sum, task) => sum + task.minutes, 0)
}
/** The change from one routine's length to another's, as a ratio: 230 minutes to 13 is −0.943. */
export function change(from: Routine, to: Routine): number {
const before = total(from)
return before ? (total(to) - before) / before : 0
}
/** How many different tools a routine moves between. */
export function toolCount(routine: Routine): number {
return new Set(routine.tasks.map((task) => task.tool)).size
}
/** Minutes as a reader says them: 13 → "13 min", 230 → "3 h 50 min", 120 → "2 h". */
export function duration(minutes: number): string {
const hours = Math.floor(minutes / 60)
const rest = minutes % 60
if (hours === 0) return `${rest} min`
return rest === 0 ? `${hours} h` : `${hours} h ${rest} min`
}
/** "6 tasks", "1 tool": a count and its noun. */
export function count(n: number, noun: string): string {
return `${n} ${noun}${n === 1 ? "" : "s"}`
}
/** A routine's summary line: "6 tasks in 5 tools". */
export function summary(routine: Routine): string {
return SUMMARY.replace("{tasks}", count(routine.tasks.length, "task")).replace("{tools}", count(toolCount(routine), "tool"))
}
/** The caption: how much time the new routine gives back. */
export function saved(before: Routine = BEFORE, after: Routine = AFTER): string {
return SAVED.replace("{saved}", duration(total(before) - total(after)))
}npx shadcn@latest add @vibra/how-it-works-04Connect, model, share on one rule
how-it-works-05Three stages as discs on one rule — connect, model, share — each with its sentence and a two-line account of what the reader decides and what Northwind does on its own, over the median time through all three.
import * as React from "react"
import Link from "next/link"
import { BlocksIcon, ChevronDownIcon, ChevronRightIcon, PlugIcon, SendIcon, type LucideIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
import {
EYEBROW,
FOOTNOTE,
HEADLINE,
LEDE,
PRIMARY_ACTION,
SECONDARY_ACTION,
SIDES,
STAGES,
type Stage,
type StageIcon,
} from "./how-it-works-05.data"
const STAGE_ICONS: Record<StageIcon, LucideIcon> = {
connect: PlugIcon,
model: BlocksIcon,
share: SendIcon,
}
/**
* The rule from one stage's disc to the next: down the page on a phone, from
* under the disc to just above the next one, and across it from `lg`, from
* beside the disc to just before the next — each ending on a chevron that
* points the way. Decoration: the ordered list already says what comes next.
*/
function Connector() {
return (
<span
aria-hidden="true"
className="absolute top-14 -bottom-8 left-6 flex -translate-x-1/2 flex-col items-center text-muted-foreground lg:top-6 lg:-right-10 lg:bottom-auto lg:left-14 lg:translate-x-0 lg:-translate-y-1/2 lg:flex-row"
>
<span className="w-px flex-1 bg-rule lg:h-px lg:w-auto" />
<ChevronDownIcon className="-mt-1.5 size-4 lg:hidden" />
<ChevronRightIcon className="-ml-1.5 hidden size-4 lg:block" />
</span>
)
}
/** One stage: its disc on the rule, its title and sentence, and who does what. */
function StageItem({ stage, last }: { stage: Stage; last: boolean }) {
const Icon = STAGE_ICONS[stage.icon]
return (
<li className="relative grid grid-cols-[3rem_minmax(0,1fr)] gap-x-5 lg:block">
<span aria-hidden="true" className="flex size-12 items-center justify-center rounded-full bg-card ring-1 ring-border">
<Icon className="size-5" />
</span>
{last ? null : <Connector />}
<div className="min-w-0 lg:mt-7">
<h3 className="type-display text-xl">{stage.title}</h3>
<p className="mt-2 text-sm/6 text-pretty text-muted-foreground">{stage.text}</p>
<dl className="mt-5">
{(["you", "northwind"] as const).map((side) => (
<div key={side} className="grid grid-cols-[4.75rem_minmax(0,1fr)] gap-x-3 border-t border-border py-3 text-sm">
<dt className="type-eyebrow pt-0.5">{SIDES[side]}</dt>
<dd className="text-pretty">{stage[side]}</dd>
</div>
))}
</dl>
</div>
</li>
)
}
/**
* How it works in three stages — connect, model, share — each a disc on one
* rule, its title and sentence, and a two-line account of who does what:
* what the reader decides, and what Northwind does on its own. Across the
* page from `lg`, the rule running from disc to disc with a chevron before
* each; down it on a phone. Under them, the median time through all three and
* the two ways in.
*
* A server component; every word is in `./how-it-works-05.data`.
*/
export function HowItWorks05() {
const titleId = React.useId()
return (
<section
data-section="how-it-works-05"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="mx-auto flex max-w-2xl flex-col items-center text-center">
<p className="type-eyebrow">{EYEBROW}</p>
<h2 id={titleId} className="type-display mt-4 text-3xl text-balance md:text-4xl">
{HEADLINE}
</h2>
<p className="mt-5 text-base/7 text-pretty text-muted-foreground md:text-lg/8">{LEDE}</p>
</div>
<ol className="mt-12 grid gap-y-10 md:mt-16 lg:grid-cols-3 lg:gap-x-12">
{STAGES.map((stage, index) => (
<StageItem key={stage.title} stage={stage} last={index === STAGES.length - 1} />
))}
</ol>
<div className="mt-12 flex flex-col items-center gap-6 border-t border-rule pt-10 text-center md:mt-16">
<p className="text-base font-medium text-balance tabular-nums">{FOOTNOTE}</p>
<div className="flex flex-wrap items-center justify-center gap-3">
<Link href={PRIMARY_ACTION.href} className={cn(buttonVariants({ size: "lg" }), "h-10 px-5")}>
{PRIMARY_ACTION.label}
</Link>
<Link href={SECONDARY_ACTION.href} className={cn(buttonVariants({ variant: "outline", size: "lg" }), "h-10 px-5")}>
{SECONDARY_ACTION.label}
</Link>
</div>
</div>
</div>
</section>
)
}
export default HowItWorks05/**
* Every word `how-it-works-05` sets: the header, the two ways in and three
* stages — connect, model, share — each with a sentence and what it asks of
* the reader beside what Northwind does on its own. `icon` names a picture the
* layout draws. Replace the stages with your own product's and the rule
* joins those.
*/
export type HowLink = { label: string; href: string }
export const EYEBROW = "How it works"
export const HEADLINE = "Connect, model, share"
export const LEDE =
"Three stages, each split between what you decide and what Northwind does for you. None of them needs an engineer, and none of them needs a meeting."
/** The two sides of every stage, as the terms of its description list. */
export const SIDES = { you: "You", northwind: "Northwind" } as const
/** The picture each stage is drawn with: vocabulary of the layout, not copy. */
export type StageIcon = "connect" | "model" | "share"
export type Stage = {
icon: StageIcon
title: string
text: string
/** What the reader decides at this stage. */
you: string
/** What Northwind does at this stage without being asked. */
northwind: string
}
export const STAGES: readonly Stage[] = [
{
icon: "connect",
title: "Connect",
text: "Bring in the systems your numbers already live in.",
you: "Choose the sources and approve read-only access to each.",
northwind: "Reads thirteen months of history, then syncs every five minutes.",
},
{
icon: "model",
title: "Model",
text: "Turn four systems' records into one set of customers and metrics.",
you: "Confirm the few accounts it could not match, and adjust any definition.",
northwind: "Matches every account and builds 47 metrics, checked on every sync.",
},
{
icon: "share",
title: "Share",
text: "Put the numbers where the decisions get made.",
you: "Pick the people, the channel and the hour of the review.",
northwind: "Sends the review every Monday, and an alert when a number moves.",
},
]
export const FOOTNOTE = "The median new workspace gets through all three in 11 minutes."
export const PRIMARY_ACTION: HowLink = { label: "Start free trial", href: "/sign-up" }
export const SECONDARY_ACTION: HowLink = { label: "Read the setup guide", href: "/docs/setup" }npx shadcn@latest add @vibra/how-it-works-05Setup checklist that works
how-it-works-06The claim beside the checklist a new workspace opens on: five tasks, one done at sign-up, that a reader can tick off to watch the count, the bar and the time left move.
import * as React from "react"
import Link from "next/link"
import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import { buttonVariants } from "@/components/ui/button"
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { LogoMark } from "@/components/ui/logo-mark"
import { SetupChecklist } from "./how-it-works-06.client"
import { CHECKLIST, EYEBROW, HEADLINE, LEDE, PRIMARY_ACTION, SECONDARY_ACTION, TASKS } from "./how-it-works-06.data"
/**
* How it works as the product's own first screen: the claim and the two ways
* in beside the setup checklist a new workspace opens on — five tasks, one
* done at sign-up — on the kit's framed Card, under the sample workspace's
* mark and its trial. The checklist works: a reader can tick the tasks off
* and see the count, the bar and the time left move, before doing it for
* real on their own data.
*
* The claim stands left of the card from `lg` and above it on a phone. A
* server component; the checklist is the one island, and every word and
* minute is in `./how-it-works-06.data`.
*/
export function HowItWorks06() {
const titleId = React.useId()
return (
<section
data-section="how-it-works-06"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto grid max-w-7xl gap-12 lg:grid-cols-12 lg:items-center lg:gap-16">
<div className="flex flex-col items-start lg:col-span-5">
<p className="type-eyebrow">{EYEBROW}</p>
<h2 id={titleId} className="type-display mt-4 text-3xl text-balance md:text-4xl">
{HEADLINE}
</h2>
<p className="mt-5 text-base/7 text-pretty text-muted-foreground md:text-lg/8">{LEDE}</p>
<div className="mt-8 flex flex-wrap items-center gap-3">
<Link href={PRIMARY_ACTION.href} className={cn(buttonVariants({ size: "lg" }), "h-10 px-5")}>
{PRIMARY_ACTION.label}
</Link>
<Link href={SECONDARY_ACTION.href} className={cn(buttonVariants({ variant: "outline", size: "lg" }), "h-10 px-5")}>
{SECONDARY_ACTION.label}
</Link>
</div>
</div>
<div className="lg:col-span-7">
<Card className="mx-auto w-full max-w-xl lg:max-w-none">
<CardHeader>
<CardTitle>
<LogoMark name={CHECKLIST.workspace} size="sm" />
</CardTitle>
<CardAction>
<Badge variant="secondary">{CHECKLIST.plan}</Badge>
</CardAction>
</CardHeader>
<CardContent className="p-5">
<SetupChecklist
tasks={TASKS}
copy={{
title: CHECKLIST.title,
remaining: CHECKLIST.remaining,
minute: CHECKLIST.minute,
complete: CHECKLIST.complete,
}}
/>
</CardContent>
</Card>
</div>
</div>
</section>
)
}
export default HowItWorks06"use client"
import * as React from "react"
import { CircleCheckIcon, Clock3Icon } from "lucide-react"
import { OnboardingChecklist } from "@/components/ui/onboarding-checklist"
import type { Task } from "./how-it-works-06.data"
/** The checklist's own words, as the island receives them. */
export type ChecklistCopy = {
title: string
/** `{minutes}` is the open tasks' total with its unit: "About 10 minutes of setup left". */
remaining: string
minute: { one: string; other: string }
complete: string
}
/**
* The setup checklist, working: the kit's OnboardingChecklist with a checkbox
* on every task, so a reader can tick them off — by pointer, or with Space on
* a focused circle — and watch the count and the bar move. Under it, the time
* the open tasks still need, added up from them on every change and said in a
* status line that is always mounted, so the new total is announced.
*
* Every task and word arrives as plain props from the section.
*/
export function SetupChecklist({ tasks, copy }: { tasks: readonly Task[]; copy: ChecklistCopy }) {
const [done, setDone] = React.useState(() => new Set(tasks.filter((task) => task.done).map((task) => task.id)))
const left = tasks.filter((task) => !done.has(task.id)).reduce((sum, task) => sum + task.minutes, 0)
const unit = left === 1 ? copy.minute.one : copy.minute.other
const Icon = left === 0 ? CircleCheckIcon : Clock3Icon
function toggle(id: string, next: boolean) {
setDone((current) => {
const updated = new Set(current)
if (next) updated.add(id)
else updated.delete(id)
return updated
})
}
return (
<div className="flex flex-col gap-5">
<OnboardingChecklist
title={copy.title}
steps={tasks.map((task) => ({ id: task.id, title: task.title, description: task.detail, done: done.has(task.id) }))}
onToggle={toggle}
className="gap-4 rounded-none border-0 p-0"
/>
<p
role="status"
aria-live="polite"
className="flex items-center gap-2 border-t border-border pt-4 text-sm text-muted-foreground tabular-nums"
>
<Icon aria-hidden="true" className={left === 0 ? "size-4 shrink-0 text-success" : "size-4 shrink-0"} />
{left === 0 ? copy.complete : copy.remaining.replace("{minutes}", `${left} ${unit}`)}
</p>
</div>
)
}/**
* Every word `how-it-works-06` sets: the header, the two ways in, and the
* setup checklist a new workspace opens on — its tasks, how long each takes
* and which is done already. The island adds up the minutes of the tasks
* still open for the line under the list, so it always agrees with the ticks.
*/
import type { LogoName } from "@/components/ui/logo-mark"
export type HowLink = { label: string; href: string }
export const EYEBROW = "Your first session"
export const HEADLINE = "Five tasks between you and a working workspace"
export const LEDE =
"This is the checklist every new workspace opens on. Tick the tasks off here to see how setup goes and how long it has left, then do it for real on your own data."
export const PRIMARY_ACTION: HowLink = { label: "Start free trial", href: "/sign-up" }
export const SECONDARY_ACTION: HowLink = { label: "Book a walkthrough", href: "/contact/walkthrough" }
export type Task = {
id: string
title: string
/** What the task involves, under its title. */
detail: string
minutes: number
/** Done when the checklist first opens. */
done: boolean
}
export const CHECKLIST = {
/** The sample workspace the checklist belongs to, on the card's strip. */
workspace: "Tidewater" as LogoName,
plan: "Trial, 14 days left",
title: "Set up Northwind",
/** Under the list while anything is open: `{minutes}` is the open tasks' total, with its unit. */
remaining: "About {minutes} of setup left",
/** The unit after the number, singular and plural. */
minute: { one: "minute", other: "minutes" },
/** Under the list once every task is done. */
complete: "Setup is done. Your first report opens as soon as you are ready.",
}
export const TASKS: readonly Task[] = [
{ id: "workspace", title: "Create the workspace", detail: "Done when you signed up", minutes: 1, done: true },
{ id: "billing", title: "Connect billing", detail: "Read-only, about 3 minutes", minutes: 3, done: false },
{ id: "crm", title: "Connect your CRM", detail: "Read-only, about 3 minutes", minutes: 3, done: false },
{ id: "dashboard", title: "Pick a starting dashboard", detail: "Revenue, retention or support, about 2 minutes", minutes: 2, done: false },
{ id: "invite", title: "Invite the people in your Monday review", detail: "They see what you see, about 2 minutes", minutes: 2, done: false },
]npx shadcn@latest add @vibra/how-it-works-06