Contact
A way to reach a person: a form, the offices and the channels a team answers on.
Contact form with direct lines
contact-01The claim and the three direct ways in — sales, support and the office — beside a form that checks every field and confirms by name where the reply will go.
import * as React from "react"
import { BriefcaseIcon, LifeBuoyIcon, MapPinIcon, type LucideIcon } from "lucide-react"
import { ContactForm } from "./contact-01.client"
import { CHANNELS, EYEBROW, FORM, HEADLINE, LEDE, type ContactIcon } from "./contact-01.data"
const ICONS: Record<ContactIcon, LucideIcon> = {
sales: BriefcaseIcon,
support: LifeBuoyIcon,
office: MapPinIcon,
}
const LINK = "rounded-sm text-foreground underline-offset-4 focus-ring hover:underline"
/**
* The contact page's own band: the claim and the three direct ways in —
* sales, support and the office, each with what it is for — beside the form
* for everything else, on the surface plane.
*
* The ways in are a description list, so each address and number is read
* with what it is for; an address is a `mailto:` link and a number a `tel:`
* link, both reserved for fiction. The form is the one island; this file is a
* server component and every word is in `./contact-01.data`.
*/
export function Contact01() {
const titleId = React.useId()
return (
<section
data-section="contact-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 grid max-w-7xl gap-12 lg:grid-cols-12 lg:gap-16">
<div className="flex flex-col lg:col-span-5">
<p className="type-eyebrow">{EYEBROW}</p>
<h2 id={titleId} className="type-display mt-3 text-3xl text-balance md:text-4xl">
{HEADLINE}
</h2>
<p className="mt-4 text-base/7 text-pretty text-muted-foreground">{LEDE}</p>
<dl className="mt-10 flex flex-col gap-6">
{CHANNELS.map((channel) => {
const Icon = ICONS[channel.icon]
return (
// A group of a definition list holds its term and its details
// and nothing else, so the icon rides in the term, set in the
// gutter the group keeps on its inline start.
<div key={channel.title} className="relative flex min-h-9 min-w-0 flex-col gap-1 ps-13">
<dt className="text-sm font-medium">
<span className="absolute start-0 top-0 flex size-9 items-center justify-center rounded-lg bg-surface text-foreground ring-1 ring-border">
<Icon aria-hidden="true" className="size-4" />
</span>
{channel.title}
</dt>
{channel.lines.map((line) => (
<dd key={line.text} className="text-sm text-muted-foreground tabular-nums">
{line.href ? (
<a href={line.href} className={LINK}>
{line.text}
</a>
) : (
line.text
)}
</dd>
))}
</div>
)
})}
</dl>
</div>
<div className="frame p-6 sm:p-8 lg:col-span-7">
<ContactForm copy={FORM} />
</div>
</div>
</section>
)
}
export default Contact01"use client"
import * as React from "react"
import Link from "next/link"
import { CircleCheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { isEmail } from "@/lib/validation"
import { Button } from "@/components/ui/button"
import { FieldError } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"
import { Textarea } from "@/components/ui/textarea"
import type { ContactField, ContactFormCopy } from "./contact-01.data"
/** The order the fields are checked in, and the order focus looks for the first refusal. */
const ORDER: ContactField[] = ["firstName", "lastName", "email", "company", "size", "message"]
const REQUIRED: ContactField[] = ["firstName", "lastName", "company"]
/** A field's label, its control and its refusal, stacked. */
function Row({
htmlFor,
label,
error,
errorId,
className,
children,
}: {
htmlFor: string
label: string
error?: string
errorId: string
className?: string
children: React.ReactNode
}) {
return (
<div className={cn("flex flex-col gap-2", className)}>
<Label htmlFor={htmlFor}>{label}</Label>
{children}
{error ? <FieldError id={errorId}>{error}</FieldError> : null}
</div>
)
}
/**
* The contact form: name, work email, company, team size and the question.
* The browser's own validation is off so it refuses in its own words — every
* field is checked, each refusal is tied to its field, which is marked
* invalid, and focus goes to the first field refused. A message too short to
* route is refused as well as an empty one. An accepted message clears the
* form and is confirmed in the status line, always mounted so it is
* announced, naming the address the reply will go to.
*
* It sends nothing: call your own endpoint where `setSent` is called.
*/
export function ContactForm({ copy }: { copy: ContactFormCopy }) {
const base = React.useId()
const titleId = `${base}title`
const idOf = (field: ContactField) => `${base}${field}`
const errorIdOf = (field: ContactField) => `${base}${field}-error`
const [errors, setErrors] = React.useState<Partial<Record<ContactField, string>>>({})
const [sent, setSent] = React.useState<string | null>(null)
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
const form = event.currentTarget
const data = new FormData(form)
const value = (field: ContactField) => String(data.get(field) ?? "").trim()
const next: Partial<Record<ContactField, string>> = {}
for (const field of REQUIRED) if (!value(field)) next[field] = copy.errors[field]
const email = value("email")
if (!email) next.email = copy.errors.email
else if (!isEmail(email)) next.email = copy.errors.emailInvalid
if (!value("size")) next.size = copy.errors.size
const message = value("message")
if (!message) next.message = copy.errors.message
else if (message.length < copy.minMessage) next.message = copy.errors.messageShort
setErrors(next)
const first = ORDER.find((field) => next[field])
if (first) {
setSent(null)
const control = form.elements.namedItem(first)
if (control instanceof HTMLElement) control.focus()
return
}
setSent(copy.sent.replace("{name}", value("firstName")).replace("{email}", email))
form.reset()
}
/** Every checked control's invalid state and what describes it. */
const checked = (field: ContactField) => ({
id: idOf(field),
name: field,
"aria-invalid": errors[field] ? true : undefined,
"aria-describedby": errors[field] ? errorIdOf(field) : undefined,
})
const row = (field: ContactField) => ({
htmlFor: idOf(field),
label: copy.labels[field],
error: errors[field],
errorId: errorIdOf(field),
})
return (
<form noValidate onSubmit={submit} aria-labelledby={titleId} className="flex flex-col gap-5">
<h3 id={titleId} className="text-base font-semibold">
{copy.title}
</h3>
<div className="grid gap-5 sm:grid-cols-2">
<Row {...row("firstName")}>
<Input {...checked("firstName")} autoComplete="given-name" className="h-9" />
</Row>
<Row {...row("lastName")}>
<Input {...checked("lastName")} autoComplete="family-name" className="h-9" />
</Row>
<Row {...row("email")}>
<Input {...checked("email")} type="email" autoComplete="email" placeholder={copy.placeholders.email} className="h-9" />
</Row>
<Row {...row("company")}>
<Input {...checked("company")} autoComplete="organization" className="h-9" />
</Row>
</div>
<Row {...row("size")}>
<NativeSelect
{...checked("size")}
defaultValue=""
className="w-full [&_select]:h-9"
>
<NativeSelectOption value="" disabled>
{copy.sizePlaceholder}
</NativeSelectOption>
{copy.sizes.map((size) => (
<NativeSelectOption key={size} value={size}>
{size}
</NativeSelectOption>
))}
</NativeSelect>
</Row>
<Row {...row("message")}>
<Textarea {...checked("message")} rows={4} placeholder={copy.placeholders.message} className="min-h-24" />
</Row>
<div className="flex flex-col gap-3">
<Button type="submit" size="lg" className="h-10 self-start px-5">
{copy.submit}
</Button>
<p className="text-xs text-pretty text-muted-foreground">
{copy.terms.lead}{" "}
<Link
href={copy.terms.privacy.href}
className="rounded-sm text-foreground underline underline-offset-4 focus-ring hover:text-brand"
>
{copy.terms.privacy.label}
</Link>
.
</p>
</div>
<p role="status" aria-live="polite" className="min-h-5 text-sm">
{sent ? (
<span className="inline-flex items-start gap-2 text-foreground">
<CircleCheckIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-success" />
{sent}
</span>
) : null}
</p>
</form>
)
}/**
* Every word `contact-01` sets: the ways to reach the team directly — each a
* reserved fictional number or an address on the example domain — and the
* form: its labels, the team-size choices, every refusal and the
* confirmation. Nothing is sent anywhere: wire the island's `submit` to your
* own endpoint.
*/
export type ContactIcon = "sales" | "support" | "office"
/** One way in: what it is for, and the lines under it — an address and a link, or plain text. */
export type ContactChannel = {
icon: ContactIcon
title: string
lines: readonly { text: string; href?: string }[]
}
export type ContactField = "firstName" | "lastName" | "email" | "company" | "size" | "message"
/** What the form says, handed to its island as plain props. */
export type ContactFormCopy = {
title: string
labels: Record<ContactField, string>
placeholders: Partial<Record<ContactField, string>>
sizePlaceholder: string
sizes: readonly string[]
submit: string
/** The line under the button: what happens next, then the privacy policy as a link. */
terms: { lead: string; privacy: { label: string; href: string } }
errors: Record<ContactField, string> & { emailInvalid: string; messageShort: string }
/** The fewest characters a message may have: a question, not a greeting. */
minMessage: number
/** `{name}` and `{email}` are put in from the form. */
sent: string
}
export const EYEBROW = "Contact"
export const HEADLINE = "Talk to the people who build Northwind"
export const LEDE =
"Questions about plans, a demo on your own data or help with a report: write to us and a person replies, usually within a working day."
export const CHANNELS: readonly ContactChannel[] = [
{
icon: "sales",
title: "Sales",
lines: [
{ text: "sales@example.com", href: "mailto:sales@example.com" },
{ text: "+1 (555) 010-2400", href: "tel:+15550102400" },
{ text: "Weekdays, 8:00 to 18:00 ET" },
],
},
{
icon: "support",
title: "Support",
lines: [
{ text: "support@example.com", href: "mailto:support@example.com" },
{ text: "Replies within four working hours" },
],
},
{
icon: "office",
title: "Office",
lines: [{ text: "48 Lanyard Street, Floor 6" }, { text: "New York, NY 10013" }],
},
]
export const FORM: ContactFormCopy = {
title: "Send us a message",
labels: {
firstName: "First name",
lastName: "Last name",
email: "Work email",
company: "Company",
size: "Team size",
message: "What can we help with?",
},
placeholders: {
email: "name@example.com",
message: "A few lines on what you are trying to answer, and with which tools",
},
sizePlaceholder: "Choose one",
sizes: ["1 to 10 people", "11 to 50 people", "51 to 200 people", "201 to 1,000 people", "More than 1,000 people"],
submit: "Send message",
terms: { lead: "We reply within one working day. Sending this accepts our", privacy: { label: "privacy policy", href: "/legal/privacy" } },
errors: {
firstName: "Enter your first name.",
lastName: "Enter your last name.",
email: "Enter your work email, so we can reply.",
emailInvalid: "Enter an email address in the format name@example.com.",
company: "Enter the company you work for.",
size: "Choose how many people are on your team.",
message: "Tell us what you need help with.",
messageShort: "Add a little more, at least 20 characters, so the right person replies.",
},
minMessage: 20,
sent: "Thanks, {name}. Your message is with the team, and we’ll reply to {email} within a working day.",
}npx shadcn@latest add @vibra/contact-01Offices on a drawn map
contact-02Four offices marked on a dotted world map, sized by the people in each, over the offices themselves — address, local hours, headcount and an address to write to — and the one number that rings whichever is open.
import * as React from "react"
import { MailIcon, PhoneIcon, UsersIcon } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { WorldMap } from "@/components/ui/world-map"
import { EYEBROW, HEADLINE, LEDE, MAP, OFFICES, PHONE } from "./contact-02.data"
const LINK = "rounded-sm text-foreground underline-offset-4 focus-ring hover:underline"
/** "64 people": a marker's value, the way the map reads it out. */
function people(count: number): string {
return MAP.people.replace("{count}", String(count))
}
/**
* Where the company is: the offices on a drawn map — the kit's dotted
* WorldMap in the reader's own ink, a marker at each office sized by how many
* people work there — over the offices themselves, each with its address,
* its hours in its own time zone, its headcount and an address to write to,
* and the one number that rings whichever is open.
*
* No map tiles and no third-party picture: the land is a stipple of dots the
* palette paints, and the map is one image with a summary, its markers
* repeated underneath as text. Each office is a heading with an `<address>`.
* A server component; every word and place is in `./contact-02.data`.
*/
export function Contact02() {
const titleId = React.useId()
return (
<section
data-section="contact-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="max-w-2xl">
<p className="type-eyebrow">{EYEBROW}</p>
<h2 id={titleId} className="type-display mt-3 text-3xl text-balance md:text-4xl">
{HEADLINE}
</h2>
<p className="mt-4 text-base/7 text-pretty text-muted-foreground">{LEDE}</p>
<p className="mt-4 flex items-start gap-2 text-sm text-muted-foreground">
<PhoneIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
<span>
{PHONE.lead}{" "}
<a href={PHONE.href} className={`${LINK} font-medium whitespace-nowrap tabular-nums`}>
{PHONE.label}
</a>
</span>
</p>
</div>
<div className="mt-10 frame px-4 py-6 sm:px-8">
<WorldMap
aria-label={MAP.label}
height={360}
valueFormatter={people}
markers={OFFICES.map((office) => ({
id: office.id,
lat: office.lat,
lng: office.lng,
value: office.people,
label: office.city,
}))}
// The map keeps its proportions inside its box, so on a phone a
// shorter box leaves no band of empty ground above and below it.
className="max-sm:h-44"
/>
</div>
<ul className="mt-10 grid gap-x-8 gap-y-10 sm:grid-cols-2 lg:grid-cols-4">
{OFFICES.map((office) => (
<li key={office.id} className="flex flex-col gap-3 border-t border-rule pt-5">
<h3 className="flex flex-wrap items-center gap-2 text-base font-semibold">
{office.city}
{office.tag ? (
<>
{" "}
<Badge variant="outline">{office.tag}</Badge>
</>
) : null}
</h3>
<address className="text-sm/6 text-pretty text-muted-foreground not-italic">
{office.address.map((line) => (
<span key={line} className="block">
{line}
</span>
))}
</address>
<p className="text-sm text-muted-foreground">{office.hours}</p>
<p className="flex items-center gap-2 text-sm text-muted-foreground tabular-nums">
<UsersIcon aria-hidden="true" className="size-3.5 shrink-0" />
{people(office.people)}
</p>
<p className="flex items-center gap-2 text-sm">
<MailIcon aria-hidden="true" className="size-3.5 shrink-0 text-muted-foreground" />
<a href={`mailto:${office.email}`} className={LINK}>
{office.email}
</a>
</p>
</li>
))}
</ul>
</div>
</section>
)
}
export default Contact02/**
* Every word and place `contact-02` sets: the four offices — each with its
* address, its hours in its own time zone, how many people work there, an
* address on the example domain and where it sits on the map — and the one
* number, reserved for fiction, that rings whichever office is open. The
* streets are made up; replace them with your own.
*/
export type Office = {
id: string
city: string
/** Shown beside the city: the head office says so. */
tag?: string
address: readonly string[]
hours: string
people: number
email: string
lat: number
lng: number
}
export const EYEBROW = "Offices"
export const HEADLINE = "Four offices, and someone at a desk most of the day"
export const LEDE =
"Northwind works from four cities, so a person picks up from Singapore’s morning to New York’s evening. Visit any of them: write first, and we’ll meet you at the door."
export const PHONE = { lead: "One number rings whichever office is open:", label: "+1 (555) 010-2400", href: "tel:+15550102400" }
/** What the map says to assistive technology, and how a marker's value is read. */
export const MAP = {
label: "Map of Northwind’s four offices, New York, Toronto, London and Singapore, each marked by how many people work there.",
people: "{count} people",
}
export const OFFICES: readonly Office[] = [
{
id: "new-york",
city: "New York",
tag: "Head office",
address: ["48 Lanyard Street, Floor 6", "New York, NY 10013", "United States"],
hours: "Weekdays, 9:00 to 18:00 ET",
people: 64,
email: "newyork@example.com",
lat: 40.72,
lng: -74.0,
},
{
id: "toronto",
city: "Toronto",
address: ["210 Foundry Lane, Suite 300", "Toronto, ON M5V 2K7", "Canada"],
hours: "Weekdays, 9:00 to 18:00 ET",
people: 28,
email: "toronto@example.com",
lat: 43.65,
lng: -79.38,
},
{
id: "london",
city: "London",
address: ["9 Chandlery Yard", "London EC1V 4PB", "United Kingdom"],
hours: "Weekdays, 9:00 to 18:00 UK time",
people: 41,
email: "london@example.com",
lat: 51.52,
lng: -0.1,
},
{
id: "singapore",
city: "Singapore",
address: ["1 Keel Road, #12-03", "Singapore 018989", "Singapore"],
hours: "Weekdays, 9:00 to 18:00 SGT",
people: 19,
email: "singapore@example.com",
lat: 1.28,
lng: 103.85,
},
]npx shadcn@latest add @vibra/contact-02Contact routed by what you need
contact-03The reasons to get in touch as a row of chips — buying, using, writing about, partnering — each bringing up the team that answers, how fast, how to reach them and what to include.
import * as React from "react"
import { RouteTabs } from "./contact-03.client"
import { EYEBROW, HEADLINE, LABELS, LEDE, ROUTES } from "./contact-03.data"
/**
* Contact that routes before it asks: the reasons a reader gets in touch as
* a row of chips — buying, using, writing about, partnering — and under them
* the team the chosen one reaches, with who answers, how fast, how to reach
* them, what to include and the one thing to do next.
*
* The router is the island; this file is a server component and every word
* and face is in `./contact-03.data`.
*/
export function Contact03() {
const titleId = React.useId()
return (
<section
data-section="contact-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-2xl">
<p className="type-eyebrow">{EYEBROW}</p>
<h2 id={titleId} className="type-display mt-3 text-3xl text-balance md:text-4xl">
{HEADLINE}
</h2>
<p className="mt-4 text-base/7 text-pretty text-muted-foreground">{LEDE}</p>
</div>
<div className="mt-10">
<RouteTabs routes={ROUTES} labels={LABELS} />
</div>
</div>
</section>
)
}
export default Contact03"use client"
import * as React from "react"
import Link from "next/link"
import { CheckIcon, ClockIcon, MailIcon, MessageCircleIcon, PhoneIcon, type LucideIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { getInitials } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { buttonVariants } from "@/components/ui/button"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import type { ChannelKind, Route } from "./contact-03.data"
const CHANNEL_ICONS: Record<ChannelKind, LucideIcon> = {
email: MailIcon,
phone: PhoneIcon,
chat: MessageCircleIcon,
}
const LINK = "rounded-sm text-foreground underline-offset-4 focus-ring hover:underline"
/**
* The router: each reason to get in touch is a chip, and the one chosen
* brings up its team — who answers, how fast, how to reach them, what helps
* them help you and the one thing to do next. Tabs, because that is what it
* is: one choice drives one panel, the arrow keys move between reasons and
* bring each team up as they land, and the chosen chip wears the accent's
* tint and the 500 weight. Every word arrives as plain props.
*/
export function RouteTabs({
routes,
labels,
}: {
routes: readonly Route[]
labels: { tabs: string; people: string; include: string }
}) {
return (
<Tabs defaultValue={routes[0]?.id} className="gap-6">
<TabsList
variant="line"
aria-label={labels.tabs}
activateOnFocus
className="w-full flex-wrap justify-start gap-2 rounded-none group-data-horizontal/tabs:h-auto"
>
{routes.map((route) => (
<TabsTrigger
key={route.id}
value={route.id}
className="h-auto flex-none rounded-full px-3.5 py-1.5 font-normal ring-1 ring-border after:hidden hover:bg-muted/50 data-active:bg-brand-muted data-active:font-medium data-active:ring-transparent data-active:hover:bg-brand-muted"
>
{route.tab}
</TabsTrigger>
))}
</TabsList>
{routes.map((route) => (
<TabsContent
key={route.id}
value={route.id}
className="grid gap-8 frame p-6 focus-ring sm:p-8 lg:grid-cols-12 lg:gap-12"
>
<div className="flex flex-col items-start gap-4 lg:col-span-7">
<h3 className="text-xl font-semibold">{route.team}</h3>
<p className="text-base/7 text-pretty text-muted-foreground">{route.summary}</p>
<p className="flex items-center gap-2 text-sm">
<ClockIcon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
{route.reply}
</p>
<ul className="flex flex-col gap-2 text-sm">
{route.channels.map((channel) => {
const Icon = CHANNEL_ICONS[channel.kind]
return (
<li key={channel.label} className="flex items-center gap-2">
<Icon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
{channel.kind === "chat" ? (
<Link href={channel.href} className={LINK}>
{channel.label}
</Link>
) : (
<a href={channel.href} className={cn(LINK, "tabular-nums")}>
{channel.label}
</a>
)}
</li>
)
})}
</ul>
<Link href={route.action.href} className={cn(buttonVariants({ size: "lg" }), "mt-2 h-10 px-5")}>
{route.action.label}
</Link>
</div>
<div className="flex flex-col gap-6 lg:col-span-5">
<div className="flex flex-col gap-3">
<h4 className="type-eyebrow">{labels.people}</h4>
<ul className="flex flex-col gap-3">
{route.people.map((person) => (
<li key={person.name} className="flex items-center gap-3">
<Avatar>
<AvatarImage src={person.face} alt="" />
<AvatarFallback>{getInitials(person.name)}</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-col">
<p className="text-sm font-medium">{person.name}</p>
<p className="text-xs text-muted-foreground">{person.role}</p>
</div>
</li>
))}
</ul>
</div>
<div className="flex flex-col gap-3">
<h4 className="type-eyebrow">{labels.include}</h4>
<ul className="flex flex-col gap-2 text-sm">
{route.include.map((item) => (
<li key={item} className="flex items-start gap-2">
<CheckIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
{item}
</li>
))}
</ul>
</div>
</div>
</TabsContent>
))}
</Tabs>
)
}/**
* Every word `contact-03` sets: the four reasons to get in touch, and for
* each the team that answers — who they are, drawn with the kit's face for
* each name from the `avatars` item, how fast they reply, how to reach them,
* what helps them help you and the one thing to do next. Addresses are on the
* example domain and the number is reserved for fiction. Replace the teams
* with your own.
*/
import { avatarFor } from "@/lib/avatars"
export type ChannelKind = "email" | "phone" | "chat"
export type RouteChannel = { kind: ChannelKind; label: string; href: string }
export type TeamMember = { name: string; role: string; face: string }
export type Route = {
id: string
/** The tab: what the reader wants, in their words. */
tab: string
/** The team that answers it. */
team: string
summary: string
reply: string
people: readonly TeamMember[]
channels: readonly RouteChannel[]
include: readonly string[]
action: { label: string; href: string }
}
const member = (name: string, role: string): TeamMember => ({ name, role, face: avatarFor(name) })
export const EYEBROW = "Contact"
export const HEADLINE = "Who should you talk to?"
export const LEDE =
"Pick what you need and we’ll put you in front of the team that answers it, with how fast they reply and what helps them help you."
/** What the row of reasons is called, and the headings inside each panel. */
export const LABELS = { tabs: "What do you need?", people: "Who answers", include: "What helps them help you" }
export const ROUTES: readonly Route[] = [
{
id: "sales",
tab: "Buying Northwind",
team: "Sales",
summary: "Plans and pricing, a trial on your own data, and anything your procurement team needs from us.",
reply: "Replies within two working hours",
people: [member("Tomás Reyes", "Account executive"), member("Hannah Okafor", "Head of sales")],
channels: [
{ kind: "email", label: "sales@example.com", href: "mailto:sales@example.com" },
{ kind: "phone", label: "+1 (555) 010-2400", href: "tel:+15550102400" },
],
include: ["How many people will use it", "The tools you want to connect", "When you need to decide"],
action: { label: "Book a demo", href: "/contact/demo" },
},
{
id: "support",
tab: "Using Northwind",
team: "Support",
summary: "A figure that looks wrong, a source that stopped syncing, or a question about how something works.",
reply: "Replies within four working hours, around the clock on Scale",
people: [
member("Ana Lima", "Support engineer"),
member("Jonas Weber", "Head of support"),
member("Mei Tanaka", "Support engineer"),
],
channels: [
{ kind: "email", label: "support@example.com", href: "mailto:support@example.com" },
{ kind: "chat", label: "Chat from inside the app", href: "/app/help" },
],
include: ["Your workspace’s name", "A link to the report or page", "What you expected, and what you saw"],
action: { label: "Open a ticket", href: "/support/new" },
},
{
id: "press",
tab: "Writing about us",
team: "Press",
summary: "Interviews, figures for a story, product images and the company’s history.",
reply: "Replies within one working day",
people: [member("Clara Novak", "Head of communications")],
channels: [{ kind: "email", label: "press@example.com", href: "mailto:press@example.com" }],
include: ["Your publication and your deadline", "The questions you’d like answered", "Whether you need a customer to speak to"],
action: { label: "Download the press kit", href: "/press" },
},
{
id: "partners",
tab: "Partnering with us",
team: "Partnerships",
summary: "Agencies, consultants and software makers who build on Northwind or bring it to their clients.",
reply: "Replies within two working days",
people: [member("Samir Haddad", "Head of partnerships"), member("Yuki Sato", "Partner engineer")],
channels: [{ kind: "email", label: "partners@example.com", href: "mailto:partners@example.com" }],
include: ["What you build or sell", "The customers you share with us", "What you would want from us"],
action: { label: "Read about the partner programme", href: "/partners" },
},
]npx shadcn@latest add @vibra/contact-03Support ticket with attachments
contact-04What to check before writing and how fast each kind of problem is answered, beside a ticket form with a category, an urgency, what happened and up to three files.
import * as React from "react"
import Link from "next/link"
import { ArrowRightIcon } from "lucide-react"
import { TicketForm } from "./contact-04.client"
import { BEFORE, EYEBROW, FORM, HEADLINE, LEDE, REPLY_TIMES, URGENCIES } from "./contact-04.data"
/**
* The support desk's own band: what to check before writing, how fast each
* kind of problem is answered, and the ticket itself — category, urgency,
* what happened and the files that show it.
*
* The reply times are a description list, the same urgencies the form offers,
* so the promise beside the form and the one inside it cannot disagree. The
* form is the one island; this file is a server component and every word is
* in `./contact-04.data`.
*/
export function Contact04() {
const titleId = React.useId()
return (
<section
data-section="contact-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 grid max-w-7xl gap-12 lg:grid-cols-12 lg:gap-16">
<div className="flex flex-col lg:col-span-4">
<p className="type-eyebrow">{EYEBROW}</p>
<h2 id={titleId} className="type-display mt-3 text-3xl text-balance md:text-4xl">
{HEADLINE}
</h2>
<p className="mt-4 text-base/7 text-pretty text-muted-foreground">{LEDE}</p>
<h3 className="mt-10 text-sm font-semibold">{BEFORE.heading}</h3>
<ul className="mt-3 flex flex-col gap-4">
{BEFORE.links.map((link) => (
<li key={link.href} className="flex flex-col gap-1">
<Link
href={link.href}
className="group/before inline-flex w-fit items-center gap-1 rounded-sm text-sm font-medium underline-offset-4 focus-ring hover:underline"
>
{link.label}
<ArrowRightIcon
aria-hidden="true"
className="size-3.5 transition-transform duration-(--duration-fast) ease-(--ease-standard) group-hover/before:translate-x-0.5 motion-reduce:transition-none"
/>
</Link>
<p className="text-sm text-pretty text-muted-foreground">{link.note}</p>
</li>
))}
</ul>
<h3 className="mt-10 text-sm font-semibold">{REPLY_TIMES.heading}</h3>
<dl className="mt-3 flex flex-col border-t border-border text-sm">
{URGENCIES.map((urgency) => (
<div key={urgency.id} className="flex items-baseline justify-between gap-4 border-b border-border py-2.5">
<dt>{urgency.label}</dt>
<dd className="text-right text-muted-foreground">{urgency.reply}</dd>
</div>
))}
</dl>
</div>
<div className="frame p-6 sm:p-8 lg:col-span-8">
<TicketForm copy={FORM} />
</div>
</div>
</section>
)
}
export default Contact04"use client"
import * as React from "react"
import { CircleCheckIcon } from "lucide-react"
import { isEmail } from "@/lib/validation"
import { Button } from "@/components/ui/button"
import { FieldError } from "@/components/ui/field"
import { FileUpload } from "@/components/ui/file-upload"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Textarea } from "@/components/ui/textarea"
import type { TicketField, TicketFormCopy } from "./contact-04.data"
/** A workspace's name as its address has it: lowercase words joined by single hyphens. */
const WORKSPACE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
const ORDER: TicketField[] = ["email", "workspace", "category", "subject", "details"]
/**
* The ticket: who is writing and from which workspace, what it is about, how
* urgent it is — each urgency with the reply time it buys — the subject, what
* happened and up to three files, through the kit's FileUpload, which turns
* away a wrong type or a file too large and says why. The browser's own
* validation is off so the form refuses in its own words: every field it
* needs is checked, each refusal is tied to its field, which is marked
* invalid, and focus goes to the first one refused. An accepted ticket clears
* the form and is confirmed in the status line — always mounted, so it is
* announced — with the reply time the urgency promised.
*
* It sends nothing: call your help desk where `setSent` is called.
*/
export function TicketForm({ copy }: { copy: TicketFormCopy }) {
const base = React.useId()
const idOf = (field: string) => `${base}${field}`
const errorIdOf = (field: TicketField) => `${base}${field}-error`
const [urgency, setUrgency] = React.useState(copy.urgencies[0]?.id ?? "")
const [files, setFiles] = React.useState<File[]>([])
const [errors, setErrors] = React.useState<Partial<Record<TicketField, string>>>({})
const [sent, setSent] = React.useState<string | null>(null)
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
const form = event.currentTarget
const data = new FormData(form)
const value = (field: TicketField) => String(data.get(field) ?? "").trim()
const next: Partial<Record<TicketField, string>> = {}
const email = value("email")
if (!email) next.email = copy.errors.email
else if (!isEmail(email)) next.email = copy.errors.emailInvalid
const workspace = value("workspace")
if (!workspace) next.workspace = copy.errors.workspace
else if (!WORKSPACE.test(workspace)) next.workspace = copy.errors.workspaceInvalid
if (!value("category")) next.category = copy.errors.category
if (!value("subject")) next.subject = copy.errors.subject
const details = value("details")
if (!details) next.details = copy.errors.details
else if (details.length < copy.minDetails) next.details = copy.errors.detailsShort
setErrors(next)
const first = ORDER.find((field) => next[field])
if (first) {
setSent(null)
const control = form.elements.namedItem(first)
if (control instanceof HTMLElement) control.focus()
return
}
const replyWindow = copy.urgencies.find((item) => item.id === urgency)?.window ?? ""
const attached =
files.length === 0 ? "" : files.length === 1 ? copy.attached.one : copy.attached.many.replace("{count}", String(files.length))
setSent([copy.sent.replace("{email}", email).replace("{window}", replyWindow), attached].filter(Boolean).join(" "))
form.reset()
setFiles([])
setUrgency(copy.urgencies[0]?.id ?? "")
}
/** Every checked control's name, invalid state and what describes it. */
const checked = (field: TicketField, hint?: string) => ({
id: idOf(field),
name: field,
"aria-invalid": errors[field] ? true : undefined,
"aria-describedby": [errors[field] ? errorIdOf(field) : null, hint].filter(Boolean).join(" ") || undefined,
})
const refusal = (field: TicketField) => (errors[field] ? <FieldError id={errorIdOf(field)}>{errors[field]}</FieldError> : null)
return (
<form noValidate onSubmit={submit} aria-label={copy.title} className="flex flex-col gap-5">
<div className="grid gap-5 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label htmlFor={idOf("email")}>{copy.labels.email}</Label>
<Input {...checked("email")} type="email" autoComplete="email" placeholder={copy.placeholders.email} className="h-9" />
{refusal("email")}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={idOf("workspace")}>{copy.labels.workspace}</Label>
<Input
{...checked("workspace", idOf("workspace-hint"))}
autoComplete="off"
spellCheck={false}
placeholder={copy.placeholders.workspace}
className="h-9 font-mono"
/>
{refusal("workspace")}
<p id={idOf("workspace-hint")} className="text-xs text-muted-foreground">
{copy.workspaceHint}
</p>
</div>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={idOf("category")}>{copy.labels.category}</Label>
<NativeSelect
{...checked("category")}
defaultValue=""
className="w-full [&_select]:h-9"
>
<NativeSelectOption value="" disabled>
{copy.categoryPlaceholder}
</NativeSelectOption>
{copy.categories.map((category) => (
<NativeSelectOption key={category} value={category}>
{category}
</NativeSelectOption>
))}
</NativeSelect>
{refusal("category")}
</div>
<div className="flex flex-col gap-3">
<p id={idOf("urgency")} className="text-sm font-medium">
{copy.labels.urgency}
</p>
<RadioGroup
aria-labelledby={idOf("urgency")}
value={urgency}
onValueChange={(next) => setUrgency(String(next))}
className="grid gap-2 sm:grid-cols-3"
>
{copy.urgencies.map((item) => (
<Label
key={item.id}
htmlFor={idOf(`urgency-${item.id}`)}
className="flex cursor-pointer items-start gap-2.5 rounded-lg bg-card p-3 leading-snug ring-1 ring-border has-[[data-checked]]:bg-brand-muted"
>
<RadioGroupItem id={idOf(`urgency-${item.id}`)} value={item.id} className="mt-px" />
<span className="flex flex-col gap-1">
<span>{item.label}</span>{" "}
<span className="text-xs font-normal text-muted-foreground">{item.reply}</span>
</span>
</Label>
))}
</RadioGroup>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={idOf("subject")}>{copy.labels.subject}</Label>
<Input {...checked("subject")} autoComplete="off" placeholder={copy.placeholders.subject} className="h-9" />
{refusal("subject")}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={idOf("details")}>{copy.labels.details}</Label>
<Textarea {...checked("details")} rows={5} placeholder={copy.placeholders.details} className="min-h-28" />
{refusal("details")}
</div>
<FileUpload
label={copy.labels.files}
description={copy.filesHint}
value={files}
onFilesAccepted={(accepted) => setFiles((current) => [...current, ...accepted])}
onRemove={(file) => setFiles((current) => current.filter((item) => item !== file))}
accept={copy.accept}
maxSize={copy.maxSize}
maxFiles={copy.maxFiles}
multiple
/>
<div className="flex flex-col gap-3">
<Button type="submit" size="lg" className="h-10 self-start px-5">
{copy.submit}
</Button>
<p role="status" aria-live="polite" className="min-h-5 text-sm">
{sent ? (
<span className="inline-flex items-start gap-2 text-foreground">
<CircleCheckIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-success" />
{sent}
</span>
) : null}
</p>
</div>
</form>
)
}/**
* Every word `contact-04` sets: what to check before writing, how fast each
* kind of problem is answered, and the ticket form — its labels, hints, the
* categories, the urgencies with their reply times, the files it takes, every
* refusal and the confirmation. Nothing is sent anywhere: wire the island's
* `submit` to your help desk.
*/
export type TicketField = "email" | "workspace" | "category" | "subject" | "details"
/** How urgent a ticket is, and how fast it is answered: said in the form and in the confirmation. */
export type Urgency = { id: string; label: string; reply: string; window: string }
export type TicketFormCopy = {
title: string
labels: Record<TicketField, string> & { urgency: string; files: string }
placeholders: Partial<Record<TicketField, string>>
workspaceHint: string
categoryPlaceholder: string
categories: readonly string[]
urgencies: readonly Urgency[]
filesHint: string
accept: string[]
/** Bytes. */
maxSize: number
maxFiles: number
submit: string
errors: Record<TicketField, string> & { emailInvalid: string; workspaceInvalid: string; detailsShort: string }
minDetails: number
/** `{email}` and `{window}` — the chosen urgency's reply time — are put in from the form. */
sent: string
/** Added to the confirmation when files went with the ticket; `{count}` is how many. */
attached: { one: string; many: string }
}
export const EYEBROW = "Support"
export const HEADLINE = "Open a support ticket"
export const LEDE =
"Tell us what happened and attach whatever shows it. A support engineer replies by email, and the ticket stays open until you close it."
export const BEFORE: { heading: string; links: readonly { label: string; href: string; note: string }[] } = {
heading: "Before you write",
links: [
{ label: "Check the status page", href: "/status", note: "If something is down, it says so there first." },
{ label: "Search the help centre", href: "/help", note: "Most questions about reports already have an answer." },
],
}
export const URGENCIES: readonly Urgency[] = [
{ id: "question", label: "A question", reply: "Within one working day", window: "within one working day" },
{ id: "broken", label: "Something is broken", reply: "Within four hours", window: "within four hours" },
{ id: "blocked", label: "My team is blocked", reply: "Within an hour, day or night", window: "within the hour" },
]
export const REPLY_TIMES = { heading: "How fast we reply" }
export const FORM: TicketFormCopy = {
title: "Your ticket",
labels: {
email: "Work email",
workspace: "Workspace",
category: "What is it about?",
urgency: "How urgent is it?",
subject: "Subject",
details: "What happened?",
files: "Attachments (optional)",
},
placeholders: {
email: "name@example.com",
workspace: "keystone-finance",
subject: "One line on what went wrong",
details: "What you did, what you expected and what you saw instead",
},
workspaceHint: "The name in your workspace’s address: lowercase letters, numbers and hyphens.",
categoryPlaceholder: "Choose one",
categories: ["Billing and plans", "Data sources and syncing", "Reports and dashboards", "Sign-in and access", "Something else"],
urgencies: URGENCIES,
filesHint: "Screenshots, exports or logs help us see what you see.",
accept: [".png", ".jpg", ".pdf", ".csv", ".log"],
maxSize: 10 * 1024 * 1024,
maxFiles: 3,
submit: "Open ticket",
errors: {
email: "Enter your work email, so we can reply.",
emailInvalid: "Enter an email address in the format name@example.com.",
workspace: "Enter your workspace’s name.",
workspaceInvalid: "Use the name from your workspace’s address: lowercase letters, numbers and hyphens.",
category: "Choose what the ticket is about.",
subject: "Give the ticket a subject.",
details: "Tell us what happened.",
detailsShort: "Add a little more, at least 30 characters, so we can reproduce it.",
},
minDetails: 30,
sent: "Ticket opened. A support engineer will reply to {email} {window}.",
attached: { one: "1 file attached.", many: "{count} files attached." },
}npx shadcn@latest add @vibra/contact-04Questions, then a person
contact-05The six questions a buyer asks most, answered in place, beside chat, email and phone, each with its hours or how fast it replies, and the help centre.
import * as React from "react"
import Link from "next/link"
import { ArrowRightIcon, MailIcon, MessageCircleIcon, PhoneIcon, type LucideIcon } from "lucide-react"
import { FaqList } from "./contact-05.client"
import { EYEBROW, HEADLINE, HELP, LEDE, QUESTIONS, type HelpChannel } from "./contact-05.data"
const ICONS: Record<HelpChannel["kind"], LucideIcon> = {
chat: MessageCircleIcon,
email: MailIcon,
phone: PhoneIcon,
}
const LINK = "rounded-sm font-medium text-foreground underline-offset-4 focus-ring hover:underline"
/**
* The questions a buyer asks most, answered in place, beside the three ways
* to reach a person when the answer is not there — chat, email and phone,
* each with its hours or how fast it replies — and the help centre.
*
* The answers are the island (`./contact-05.client`), the kit's Accordion;
* this file is a server component and every word is in `./contact-05.data`.
*/
export function Contact05() {
const titleId = React.useId()
const helpId = React.useId()
return (
<section
data-section="contact-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 grid max-w-7xl gap-12 lg:grid-cols-12 lg:gap-16">
<div className="lg:col-span-7">
<p className="type-eyebrow">{EYEBROW}</p>
<h2 id={titleId} className="type-display mt-3 text-3xl text-balance md:text-4xl">
{HEADLINE}
</h2>
<p className="mt-4 max-w-xl text-base/7 text-pretty text-muted-foreground">{LEDE}</p>
<div className="mt-8">
<FaqList questions={QUESTIONS} />
</div>
</div>
<aside aria-labelledby={helpId} className="flex flex-col gap-6 self-start frame p-6 sm:p-8 lg:col-span-5">
<div className="flex flex-col gap-1">
<h3 id={helpId} className="text-base font-semibold">
{HELP.heading}
</h3>
<p className="text-sm text-pretty text-muted-foreground">{HELP.line}</p>
</div>
<ul className="flex flex-col gap-5">
{HELP.channels.map((channel) => {
const Icon = ICONS[channel.kind]
return (
<li key={channel.kind} className="flex gap-4">
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-card text-foreground ring-1 ring-border">
<Icon aria-hidden="true" className="size-4" />
</span>
<div className="flex min-w-0 flex-col gap-0.5 text-sm">
<p className="font-medium">{channel.title}</p>
<p className="text-muted-foreground">{channel.detail}</p>
{channel.kind === "chat" ? (
<Link href={channel.link.href} className={LINK}>
{channel.link.label}
</Link>
) : (
<a href={channel.link.href} className={`${LINK} w-fit tabular-nums`}>
{channel.link.label}
</a>
)}
</div>
</li>
)
})}
</ul>
<div className="border-t border-border pt-5">
<Link
href={HELP.centre.href}
className="group/centre inline-flex items-center gap-1 rounded-sm text-sm font-medium underline-offset-4 focus-ring hover:underline"
>
{HELP.centre.label}
<ArrowRightIcon
aria-hidden="true"
className="size-3.5 transition-transform duration-(--duration-fast) ease-(--ease-standard) group-hover/centre:translate-x-0.5 motion-reduce:transition-none"
/>
</Link>
</div>
</aside>
</div>
</section>
)
}
export default Contact05"use client"
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import type { Question } from "./contact-05.data"
/**
* The questions as the kit's Accordion: each question a button that says
* whether its answer is open, the first one open to start. More than one can
* be open at a time, and a closed answer stays in the page, hidden until
* found, so the browser's own find-in-page reaches it and opens it.
*/
export function FaqList({ questions }: { questions: readonly Question[] }) {
return (
<Accordion multiple hiddenUntilFound defaultValue={questions.slice(0, 1).map((item) => item.id)} className="border-t border-border">
{questions.map((item) => (
<AccordionItem key={item.id} value={item.id} className="border-b border-border">
<AccordionTrigger className="py-4 text-base">{item.question}</AccordionTrigger>
<AccordionContent className="pb-4 text-sm/6 text-pretty text-muted-foreground">
<p>{item.answer}</p>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
)
}/**
* Every word `contact-05` sets: the questions a buyer asks most, each with
* its answer, and the ways to reach a person when the answer is not there —
* each with its hours or its reply time. Addresses are on the example domain
* and the number is reserved for fiction. Replace them with your own.
*/
export type Question = { id: string; question: string; answer: string }
export type HelpChannel = { kind: "chat" | "email" | "phone"; title: string; detail: string; link: { label: string; href: string } }
export const EYEBROW = "Questions"
export const HEADLINE = "Answers before you ask"
export const LEDE = "The six questions we hear most in a first call. If yours is not here, a person is a message away."
export const QUESTIONS: readonly Question[] = [
{
id: "setup",
question: "How long does setup take?",
answer:
"Most teams connect their first source and read their first report in an afternoon. A rollout with single sign-on takes about two weeks, with a solutions engineer alongside.",
},
{
id: "trial",
question: "Can we try it on our own data?",
answer:
"Yes. The 14-day trial connects to your real sources, and nothing you connect is kept after the trial closes unless you choose a plan.",
},
{
id: "region",
question: "Where is our data stored?",
answer:
"In the region you choose when you create the workspace: the United States, the European Union or Singapore. It stays in that region, backups included.",
},
{
id: "discounts",
question: "Do you offer discounts for nonprofits and schools?",
answer: "Registered nonprofits and schools get 40% off any plan. Write to sales with your registration number.",
},
{
id: "cancel",
question: "What happens if we cancel?",
answer: "You keep read access for 30 days and can export every report and table as CSV before the workspace closes.",
},
{
id: "sso",
question: "Which plans include single sign-on?",
answer: "Scale includes SAML single sign-on and SCIM provisioning. Growth can add them for $4 a seat each month.",
},
]
export const HELP = {
heading: "Still have a question?",
line: "The same people who answer tickets answer these.",
channels: [
{
kind: "chat",
title: "Chat",
detail: "In the app, weekdays 8:00 to 20:00 ET",
link: { label: "Start a chat", href: "/app/help" },
},
{
kind: "email",
title: "Email",
detail: "Replies within four working hours",
link: { label: "support@example.com", href: "mailto:support@example.com" },
},
{
kind: "phone",
title: "Phone",
detail: "Weekdays 9:00 to 17:00 ET",
link: { label: "+1 (555) 010-2400", href: "tel:+15550102400" },
},
] satisfies HelpChannel[],
centre: { label: "Browse the help centre", href: "/help" },
}npx shadcn@latest add @vibra/contact-05Book a call
contact-06Who hosts the call and what it covers beside a calendar of the open weekdays, the times still free on the chosen day and the two fields the invite needs.
import * as React from "react"
import { CheckIcon, ClockIcon, GlobeIcon, VideoIcon, type LucideIcon } from "lucide-react"
import { getInitials } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { BookingPicker } from "./contact-06.client"
import { BOOKING, DIARY, EYEBROW, HEADLINE, HOST, LEDE, openTimes, type HostDetail } from "./contact-06.data"
const DETAIL_ICONS: Record<HostDetail["icon"], LucideIcon> = {
duration: ClockIcon,
video: VideoIcon,
zone: GlobeIcon,
}
/**
* A call booked in place, the way a scheduling page books one: who hosts it
* and what it covers on one side, and on the other a calendar of the open
* weekdays, the times still free on the chosen day and the two fields the
* invite needs.
*
* The open times are counted here, by `openTimes()`, and handed to the
* island as plain props; the host's face is decoration beside the name. A
* server component; every word is in `./contact-06.data`.
*/
export function Contact06() {
const titleId = React.useId()
const days = openTimes()
return (
<section
data-section="contact-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 max-w-7xl">
<div className="max-w-2xl">
<p className="type-eyebrow">{EYEBROW}</p>
<h2 id={titleId} className="type-display mt-3 text-3xl text-balance md:text-4xl">
{HEADLINE}
</h2>
<p className="mt-4 text-base/7 text-pretty text-muted-foreground">{LEDE}</p>
</div>
<div className="mt-10 grid gap-10 frame p-6 sm:p-8 lg:grid-cols-12 lg:gap-12">
<div className="flex flex-col gap-6 lg:col-span-4 lg:border-r lg:border-border lg:pr-12">
<div className="flex items-center gap-3">
<Avatar size="lg">
<AvatarImage src={HOST.face} alt="" />
<AvatarFallback>{getInitials(HOST.name)}</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<p className="text-sm font-semibold">{HOST.name}</p>
<p className="text-sm text-muted-foreground">{HOST.role}</p>
</div>
</div>
<ul className="flex flex-col gap-2.5 text-sm">
{HOST.details.map((detail) => {
const Icon = DETAIL_ICONS[detail.icon]
return (
<li key={detail.text} className="flex items-center gap-2.5">
<Icon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
{detail.text}
</li>
)
})}
</ul>
<div className="flex flex-col gap-3 border-t border-border pt-6">
<h3 className="text-sm font-semibold">{HOST.agendaHeading}</h3>
<ul className="flex flex-col gap-2 text-sm text-muted-foreground">
{HOST.agenda.map((item) => (
<li key={item} className="flex items-start gap-2">
<CheckIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
{item}
</li>
))}
</ul>
</div>
</div>
<div className="lg:col-span-8">
<BookingPicker days={days} today={DIARY.today} host={HOST.name} copy={BOOKING} labelledBy={titleId} />
</div>
</div>
</div>
</section>
)
}
export default Contact06"use client"
import * as React from "react"
import { CircleCheckIcon } from "lucide-react"
import { isEmail } from "@/lib/validation"
import { Button } from "@/components/ui/button"
import { Calendar } from "@/components/ui/calendar"
import { FieldError } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import type { BookableDay, BookingCopy } from "./contact-06.data"
/** A UTC day spelled out — "Tuesday, September 8" — the same for every reader. */
const LONG_DAY = new Intl.DateTimeFormat("en-US", { weekday: "long", month: "long", day: "numeric", timeZone: "UTC" })
const utc = (day: string) => new Date(`${day}T00:00:00Z`)
const dayKey = (date: Date) => date.toISOString().slice(0, 10)
/** "14:30" → "2:30 PM": a wall-clock time as the reader in New York says it. */
function clock(time: string): string {
const [hours, minutes] = time.split(":").map(Number)
return `${hours % 12 || 12}:${String(minutes).padStart(2, "0")} ${hours < 12 ? "AM" : "PM"}`
}
type Field = "time" | "name" | "email"
/**
* The booking: the kit's Calendar, in UTC so a day is the same day for every
* reader, with only the open weekdays of the fortnight enabled; the times
* still open on the chosen day as a row of toggles, the chosen one on the
* accent's tint; and the two fields the invite needs. It refuses what is
* missing in its own words — a time, a name, an address — each tied to what
* it refuses, and confirms a booking in the status line, always mounted so it
* is announced. A booked time leaves the day's list.
*
* It books nothing: call your calendar where `setSent` is called.
*/
export function BookingPicker({
days,
today,
host,
copy,
labelledBy,
}: {
days: readonly BookableDay[]
today: string
host: string
copy: BookingCopy
/** The band's heading, which names the form. */
labelledBy: string
}) {
const base = React.useId()
const idOf = (field: string) => `${base}${field}`
const [booked, setBooked] = React.useState<ReadonlySet<string>>(new Set())
const open = (entry: BookableDay) => entry.times.filter((time) => !booked.has(`${entry.day} ${time}`))
const openDays = new Map(days.map((entry) => [entry.day, open(entry)]))
const [day, setDay] = React.useState(() => days.find((entry) => entry.times.length > 0)?.day ?? days[0]?.day ?? today)
const [time, setTime] = React.useState<string | null>(null)
const [errors, setErrors] = React.useState<Partial<Record<Field, string>>>({})
const [sent, setSent] = React.useState<string | null>(null)
const times = openDays.get(day) ?? []
const spelled = LONG_DAY.format(utc(day))
const first = days[0]?.day ?? today
const last = days[days.length - 1]?.day ?? today
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
const form = event.currentTarget
const data = new FormData(form)
const name = String(data.get("name") ?? "").trim()
const email = String(data.get("email") ?? "").trim()
const next: Partial<Record<Field, string>> = {}
if (!time) next.time = copy.errors.time
if (!name) next.name = copy.errors.name
if (!email) next.email = copy.errors.email
else if (!isEmail(email)) next.email = copy.errors.emailInvalid
setErrors(next)
if (!time || next.name || next.email) {
setSent(null)
const target = next.time ? form.querySelector("[data-slot=toggle-group-item]") : form.elements.namedItem(next.name ? "name" : "email")
if (target instanceof HTMLElement) target.focus()
return
}
setSent(
copy.sent
.replace("{day}", spelled)
.replace("{time}", clock(time))
.replace("{host}", host)
.replace("{email}", email)
)
setBooked((current) => new Set([...current, `${day} ${time}`]))
setTime(null)
form.reset()
}
return (
<form noValidate onSubmit={submit} aria-labelledby={labelledBy} className="grid gap-8 md:grid-cols-2">
<div className="flex flex-col gap-3">
<p className="text-sm font-medium">{copy.calendarLabel}</p>
<Calendar
mode="single"
required
timeZone="UTC"
weekStartsOn={1}
today={utc(today)}
startMonth={utc(first)}
endMonth={utc(last)}
selected={utc(day)}
onSelect={(date) => {
if (!date) return
setDay(dayKey(date))
setTime(null)
setErrors((current) => ({ ...current, time: undefined }))
}}
disabled={(date) => (openDays.get(dayKey(date))?.length ?? 0) === 0}
className="w-fit rounded-lg bg-card ring-1 ring-border [--cell-size:--spacing(9)]"
/>
</div>
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-3">
<p id={idOf("times")} className="text-sm font-medium">
{copy.timesLabel.replace("{day}", spelled)}
</p>
{times.length === 0 ? (
<p className="text-sm text-muted-foreground">{copy.none}</p>
) : (
<ToggleGroup
aria-labelledby={idOf("times")}
aria-describedby={errors.time ? idOf("time-error") : undefined}
variant="outline"
spacing={2}
value={time ? [time] : []}
onValueChange={(next) => {
const [value] = next
setTime(value ?? null)
if (value) setErrors((current) => ({ ...current, time: undefined }))
}}
className="grid w-full grid-cols-3 gap-2"
>
{times.map((slot) => (
<ToggleGroupItem
key={slot}
value={slot}
className="w-full border-border tabular-nums aria-pressed:border-transparent aria-pressed:bg-brand-muted aria-pressed:font-semibold"
>
{clock(slot)}
</ToggleGroupItem>
))}
</ToggleGroup>
)}
{errors.time ? <FieldError id={idOf("time-error")}>{errors.time}</FieldError> : null}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={idOf("name")}>{copy.nameLabel}</Label>
<Input
id={idOf("name")}
name="name"
autoComplete="name"
aria-invalid={errors.name ? true : undefined}
aria-describedby={errors.name ? idOf("name-error") : undefined}
className="h-9"
/>
{errors.name ? <FieldError id={idOf("name-error")}>{errors.name}</FieldError> : null}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={idOf("email")}>{copy.emailLabel}</Label>
<Input
id={idOf("email")}
name="email"
type="email"
autoComplete="email"
placeholder={copy.emailPlaceholder}
aria-invalid={errors.email ? true : undefined}
aria-describedby={errors.email ? idOf("email-error") : undefined}
className="h-9"
/>
{errors.email ? <FieldError id={idOf("email-error")}>{errors.email}</FieldError> : null}
</div>
<Button type="submit" size="lg" className="h-10 w-full">
{copy.submit}
</Button>
<p role="status" aria-live="polite" className="-mt-2 min-h-5 text-sm">
{sent ? (
<span className="inline-flex items-start gap-2 text-foreground">
<CircleCheckIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-success" />
{sent}
</span>
) : null}
</p>
</div>
</form>
)
}/**
* Every word `contact-06` sets, and the diary it books into: who hosts the
* call, what it covers, the fortnight of weekdays open for booking — UTC days
* starting the Monday after the sample world's `REFERENCE_DATE`, which the
* section's test holds them to — the times offered on each, in New York time,
* and the form's labels, refusals and confirmation. Which times are already
* taken is a rule over the day (`openTimes`), never a list written out per
* day. Nothing is booked anywhere: wire the island's `submit` to your
* calendar.
*/
import { avatarFor } from "@/lib/avatars"
export type HostDetail = { icon: "duration" | "video" | "zone"; text: string }
/** One bookable day, a UTC `yyyy-mm-dd`, and the times still open on it. */
export type BookableDay = { day: string; times: readonly string[] }
export type BookingCopy = {
calendarLabel: string
/** `{day}` is the day chosen, spelled out. */
timesLabel: string
none: string
nameLabel: string
emailLabel: string
emailPlaceholder: string
submit: string
zone: string
errors: { time: string; name: string; email: string; emailInvalid: string }
/** `{day}`, `{time}`, `{host}` and `{email}` are put in from the booking. */
sent: string
}
export const EYEBROW = "Book a call"
export const HEADLINE = "Walk through Northwind with a solutions engineer"
export const LEDE =
"Thirty minutes on a video call, on your own numbers if you bring them. Pick a day and a time, and the invite arrives by email."
export const HOST = {
name: "Tomás Reyes",
role: "Solutions engineer",
face: avatarFor("Tomás Reyes"),
details: [
{ icon: "duration", text: "30 minutes" },
{ icon: "video", text: "Video call, the link comes with the invite" },
{ icon: "zone", text: "Times are New York time (ET)" },
] satisfies HostDetail[],
agendaHeading: "What we’ll cover",
agenda: [
"The questions your team asks every week",
"Which of your tools to connect first",
"What a trial on your own data needs",
],
}
/** The diary: today, and the first and last days that can be booked — all UTC days. */
export const DIARY = { today: "2026-09-04", first: "2026-09-07", last: "2026-09-18" }
/** Every time the host offers on a weekday, New York wall-clock time, 24-hour. */
export const TIMES = ["09:00", "09:30", "10:30", "11:00", "13:00", "14:30", "15:00", "16:30"] as const
export const BOOKING: BookingCopy = {
calendarLabel: "Choose a day",
timesLabel: "Times on {day}",
none: "No times are left that day. Try another.",
nameLabel: "Your name",
emailLabel: "Work email",
emailPlaceholder: "name@example.com",
submit: "Book the call",
zone: "ET",
errors: {
time: "Choose a time for the call.",
name: "Enter your name.",
email: "Enter your work email, so the invite reaches you.",
emailInvalid: "Enter an email address in the format name@example.com.",
},
sent: "Booked: {day} at {time} ET with {host}. The invite is on its way to {email}.",
}
const DAY_MS = 86_400_000
/**
* The weekdays from the first bookable day to the last, each with the times
* still open: a time is taken when its place in the list and the day of the
* month add up to a multiple of three, which leaves five or six a day.
*/
export function openTimes(diary = DIARY, times: readonly string[] = TIMES): BookableDay[] {
const days: BookableDay[] = []
for (let at = Date.parse(`${diary.first}T00:00:00Z`); at <= Date.parse(`${diary.last}T00:00:00Z`); at += DAY_MS) {
const date = new Date(at)
const weekday = date.getUTCDay()
if (weekday === 0 || weekday === 6) continue
const open = times.filter((_, index) => (date.getUTCDate() + index) % 3 !== 0)
days.push({ day: date.toISOString().slice(0, 10), times: open })
}
return days
}npx shadcn@latest add @vibra/contact-06Chat card
contact-07Why chat and the figures behind the promise, beside the chat itself as a card: a greeting from a named engineer, three questions to start from and a composer.
import * as React from "react"
import { ClockIcon } from "lucide-react"
import { ChatCard } from "./contact-07.client"
import { CHAT, EYEBROW, FACTS, HEADLINE, HOURS, LEDE } from "./contact-07.data"
/**
* Contact as a conversation: why chat, the two figures behind the promise and
* the hours on one side, and on the other the chat itself as a card — the
* team, a greeting from a named engineer, three questions to start from and
* a composer that asks where the reply should go if the reader leaves.
*
* The figures are a description list, figure first. The chat is the island;
* this file is a server component and every word is in `./contact-07.data`.
*/
export function Contact07() {
const titleId = React.useId()
return (
<section
data-section="contact-07"
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 items-center gap-12 lg:grid-cols-12 lg:gap-16">
<div className="flex flex-col lg:col-span-5">
<p className="type-eyebrow">{EYEBROW}</p>
<h2 id={titleId} className="type-display mt-3 text-3xl text-balance md:text-4xl">
{HEADLINE}
</h2>
<p className="mt-4 text-base/7 text-pretty text-muted-foreground">{LEDE}</p>
<dl className="mt-8 grid grid-cols-2 gap-6 border-t border-border pt-6">
{FACTS.map((fact) => (
<div key={fact.label} className="flex flex-col-reverse gap-1">
<dt className="text-sm text-pretty text-muted-foreground">{fact.label}</dt>
<dd className="type-numeral text-3xl">{fact.value}</dd>
</div>
))}
</dl>
<p className="mt-6 flex items-start gap-2 text-sm text-pretty text-muted-foreground">
<ClockIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
{HOURS}
</p>
</div>
<div className="lg:col-span-7">
<ChatCard copy={CHAT} />
</div>
</div>
</section>
)
}
export default Contact07"use client"
import * as React from "react"
import { CircleCheckIcon, SendIcon } from "lucide-react"
import { getInitials } from "@/lib/format"
import { isEmail } from "@/lib/validation"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { AvatarGroup } from "@/components/ui/avatar-group"
import { Bubble, BubbleContent } from "@/components/ui/bubble"
import { Button } from "@/components/ui/button"
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { FieldError } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Message, MessageAvatar, MessageContent, MessageHeader } from "@/components/ui/message"
import { Textarea } from "@/components/ui/textarea"
import type { ChatCopy } from "./contact-07.data"
/**
* The chat as a card: the team and how fast it replies in the strip, the
* greeting as the kit's Message and Bubble, three suggested questions that
* fill the message for the reader to finish, and a composer that asks for the
* address the reply goes to if they leave. It refuses an empty message and a
* missing or malformed address in its own words, each tied to its field, and
* a message that goes joins the conversation on the reader's side and is
* confirmed in the status line, always mounted so it is announced. The
* suggestions step aside once the reader has written.
*
* It sends nothing and invents no reply: wire `send` to your chat provider.
*/
export function ChatCard({ copy }: { copy: ChatCopy }) {
const base = React.useId()
const idOf = (field: string) => `${base}${field}`
const messageRef = React.useRef<HTMLTextAreaElement>(null)
const [message, setMessage] = React.useState("")
const [sentMessages, setSentMessages] = React.useState<string[]>([])
const [errors, setErrors] = React.useState<{ message?: string; email?: string }>({})
const [status, setStatus] = React.useState<string | null>(null)
function send(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
const form = event.currentTarget
const text = message.trim()
const email = String(new FormData(form).get("email") ?? "").trim()
const next = {
message: text ? undefined : copy.errors.message,
email: !email ? copy.errors.email : isEmail(email) ? undefined : copy.errors.emailInvalid,
}
setErrors(next)
if (next.message || next.email) {
setStatus(null)
const target = next.message ? messageRef.current : form.elements.namedItem("email")
if (target instanceof HTMLElement) target.focus()
return
}
setSentMessages((current) => [...current, text])
setMessage("")
setStatus(copy.sent.replace("{agent}", copy.agent.name.split(" ")[0]).replace("{email}", email))
}
function suggest(text: string) {
setMessage(text)
messageRef.current?.focus()
}
return (
<Card>
<CardHeader>
<CardTitle>
<h3>{copy.team}</h3>
</CardTitle>
<CardDescription>{copy.reply}</CardDescription>
{/* The team's faces are decoration beside its name. */}
<CardAction aria-hidden="true">
<AvatarGroup users={copy.people.map((item) => ({ name: item.name, src: item.face }))} size="sm" />
</CardAction>
</CardHeader>
<CardContent className="flex flex-col gap-4 p-4 sm:p-5">
<ol aria-label={copy.conversationLabel} className="flex flex-col gap-4">
<li>
<Message>
<MessageAvatar>
<Avatar size="sm">
<AvatarImage src={copy.agent.face} alt="" />
<AvatarFallback>{getInitials(copy.agent.name)}</AvatarFallback>
</Avatar>
</MessageAvatar>
<MessageContent className="gap-1.5">
<MessageHeader>{copy.agent.name}</MessageHeader>
{copy.greeting.map((line) => (
<Bubble key={line} variant="muted">
<BubbleContent>{line}</BubbleContent>
</Bubble>
))}
</MessageContent>
</Message>
</li>
{sentMessages.map((text, index) => (
<li key={`${index}-${text}`}>
<Message align="end">
<MessageContent className="gap-1.5">
<MessageHeader>{copy.you}</MessageHeader>
<Bubble align="end">
<BubbleContent>{text}</BubbleContent>
</Bubble>
</MessageContent>
</Message>
</li>
))}
</ol>
{sentMessages.length === 0 ? (
<div role="group" aria-label={copy.suggestionsLabel} className="flex flex-wrap gap-2 pl-10">
{copy.suggestions.map((text) => (
<Button key={text} type="button" variant="outline" size="sm" className="rounded-full" onClick={() => suggest(text)}>
{text}
</Button>
))}
</div>
) : null}
<form noValidate onSubmit={send} aria-label={copy.team} className="flex flex-col gap-3 border-t border-border pt-4">
<div className="flex flex-col gap-2">
<Label htmlFor={idOf("message")}>{copy.messageLabel}</Label>
<Textarea
ref={messageRef}
id={idOf("message")}
name="message"
rows={2}
value={message}
onChange={(event) => setMessage(event.target.value)}
placeholder={copy.messagePlaceholder}
aria-invalid={errors.message ? true : undefined}
aria-describedby={errors.message ? idOf("message-error") : undefined}
className="min-h-16"
/>
{errors.message ? <FieldError id={idOf("message-error")}>{errors.message}</FieldError> : null}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={idOf("email")}>{copy.emailLabel}</Label>
<div className="flex gap-2">
<Input
id={idOf("email")}
name="email"
type="email"
autoComplete="email"
placeholder={copy.emailPlaceholder}
aria-invalid={errors.email ? true : undefined}
aria-describedby={errors.email ? idOf("email-error") : undefined}
className="h-9 min-w-0 flex-1"
/>
<Button type="submit" size="lg" className="shrink-0">
<SendIcon aria-hidden="true" />
{copy.send}
</Button>
</div>
{errors.email ? <FieldError id={idOf("email-error")}>{errors.email}</FieldError> : null}
</div>
<p role="status" aria-live="polite" className="min-h-5 text-sm">
{status ? (
<span className="inline-flex items-start gap-2 text-foreground">
<CircleCheckIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-success" />
{status}
</span>
) : null}
</p>
</form>
</CardContent>
</Card>
)
}/**
* Every word `contact-07` sets: why chat, the two figures behind the promise
* and the hours, and the chat card itself — the team and how fast it
* replies, the greeting, the suggested questions, the composer's labels,
* refusals and confirmation. The people are drawn with the kit's face for
* each name, from the `avatars` item. Nothing is sent anywhere: wire the
* island's `send` to your chat provider.
*/
import { avatarFor } from "@/lib/avatars"
export type ChatPerson = { name: string; face: string }
export type ChatCopy = {
team: string
reply: string
people: readonly ChatPerson[]
agent: ChatPerson
greeting: readonly string[]
conversationLabel: string
you: string
suggestionsLabel: string
suggestions: readonly string[]
messageLabel: string
messagePlaceholder: string
emailLabel: string
emailPlaceholder: string
send: string
errors: { message: string; email: string; emailInvalid: string }
/** `{agent}` and `{email}` are put in when a message goes. */
sent: string
}
const person = (name: string): ChatPerson => ({ name, face: avatarFor(name) })
export const EYEBROW = "Chat"
export const HEADLINE = "Ask in plain words, and a person answers"
export const LEDE =
"The chat inside Northwind reaches the same support engineers who answer tickets. There is no bot in front of them and no menu to get through."
export const FACTS: readonly { value: string; label: string }[] = [
{ value: "6 min", label: "typical first reply on a weekday" },
{ value: "91%", label: "of questions settled in the chat itself" },
]
export const HOURS = "Weekdays 8:00 to 20:00 ET. Outside those hours, the reply comes by email."
export const CHAT: ChatCopy = {
team: "Northwind support",
reply: "Usually replies in 6 minutes",
people: [person("Ana Lima"), person("Jonas Weber"), person("Mei Tanaka")],
agent: person("Ana Lima"),
greeting: ["Hi, I’m Ana from support.", "What are you working on? A line or two is plenty."],
conversationLabel: "Conversation",
you: "You",
suggestionsLabel: "Suggested questions",
suggestions: ["A question about my bill", "Help connecting a source", "A figure looks wrong"],
messageLabel: "Your message",
messagePlaceholder: "Write your question",
emailLabel: "Your email, for the reply",
emailPlaceholder: "name@example.com",
send: "Send",
errors: {
message: "Write a message first.",
email: "Enter your email, so the reply reaches you if you leave.",
emailInvalid: "Enter an email address in the format name@example.com.",
},
sent: "Sent. {agent} will reply here, and at {email} if you have left.",
}npx shadcn@latest add @vibra/contact-07Contact line
contact-08Contact at its smallest: one line between two rules with the heading, what to expect, the address, the number and the hours.
import * as React from "react"
import { ClockIcon, MailIcon, PhoneIcon, type LucideIcon } from "lucide-react"
import { CHANNELS, HEADLINE, NOTE, type LineChannel } from "./contact-08.data"
const ICONS: Record<LineChannel["kind"], LucideIcon> = {
email: MailIcon,
phone: PhoneIcon,
hours: ClockIcon,
}
/**
* Contact at its smallest, for the foot of a pricing page or a help article:
* one line between two rules, the heading and what to expect on one side and
* the address, the number and the hours on the other.
*
* The ways in are a description list — each term read to assistive
* technology, where the icon stands in for it on screen — the address a
* `mailto:` link and the number a `tel:` link. A server component; every
* word is in `./contact-08.data`.
*/
export function Contact08() {
const titleId = React.useId()
return (
<section
data-section="contact-08"
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="flex flex-col gap-5 border-y border-rule py-6 lg:flex-row lg:items-center lg:justify-between lg:gap-10">
<div className="flex flex-col gap-1">
<h2 id={titleId} className="text-lg font-semibold">
{HEADLINE}
</h2>
<p className="text-sm text-pretty text-muted-foreground">{NOTE}</p>
</div>
<dl className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:gap-x-8">
{CHANNELS.map((channel) => {
const Icon = ICONS[channel.kind]
return (
<div key={channel.kind} className="flex items-center gap-2 text-sm">
<Icon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
<dt className="sr-only">{channel.label}</dt>
<dd className="tabular-nums">
{channel.href ? (
<a
href={channel.href}
className="rounded-sm font-medium underline-offset-4 focus-ring hover:underline"
>
{channel.value}
</a>
) : (
<span className="text-muted-foreground">{channel.value}</span>
)}
</dd>
</div>
)
})}
</dl>
</div>
</div>
</section>
)
}
export default Contact08/**
* Every word `contact-08` sets: one line with the two ways to reach a person
* and when they answer. The address is on the example domain and the number
* is reserved for fiction; replace both with your own.
*/
export type LineChannel = { kind: "email" | "phone" | "hours"; label: string; value: string; href?: string }
export const HEADLINE = "Talk to a person"
export const NOTE = "Questions about a plan or a report: a person replies within one working day."
export const CHANNELS: readonly LineChannel[] = [
{ kind: "email", label: "Email", value: "hello@example.com", href: "mailto:hello@example.com" },
{ kind: "phone", label: "Phone", value: "+1 (555) 010-2400", href: "tel:+15550102400" },
{ kind: "hours", label: "Hours", value: "Weekdays, 9:00 to 17:00 ET" },
]npx shadcn@latest add @vibra/contact-08