Files
linkder/apps/web/src/components/chrome/project-panel.tsx
T
serfaandClaude Opus 5 0192585727 Stock the trades we serve, and stop offering the ones we don't
A demo hit "That's everyone nearby" on Electrician after four swipes.
That was the deck working — there were exactly four — but the shape of
the catalogue was worse than it looked: 42 of the 50 trades had NO pros
at all, so tapping Roofer or Cleaner hit the dead end immediately rather
than after seven swipes.

Two halves to the fix, and the second matters more.

Depth: 56 more pros, so the fifteen live trades now run 3–15 deep
instead of 1–12. Every photo was picked by reading Unsplash's written
description and keeping only images that show the trade being DONE, then
checking each URL resolves — one of 33 was a 404 and was dropped rather
than seeded broken. Where a photo already belonged to someone in this
file the new pro takes a name of the same gender: the face and the name
on a card have to agree.

Honesty: the other 35 trades are seeded isActive:false. A category with
nobody behind it is not a feature, it is the product claiming to do
something it cannot — and no amount of invented supply fixes that, it
just moves the lie one screen later. LIVE_TRADES is the list, and
widening it means recruiting pros first and adding the slug second.

Locksmith is called out in the file: photo searches return padlocks, not
locksmiths, so two of its cards carry door hardware. A lock is at least
a locksmith's work. A stock portrait of somebody who is plainly not one
is not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 14:01:34 -04:00

773 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState } from 'react';
import {
BadgeCheck,
CalendarCheck,
Check,
Copy,
MapPin,
MessagesSquare,
Star,
Zap,
} from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* The right-hand column: what the app in the phone beside it actually is.
*
* Static on purpose. The client reads this while USING the product in the left
* column, so nothing here navigates, nothing here is a screenshot, and nothing
* here moves when they swipe.
*
* Content lives in the arrays below rather than in the markup, so adding a
* feature or swapping a dependency is one line and the layout is untouched.
*
* Both languages live in the SAME entry rather than in two parallel documents —
* a decision and its `today` line have to move together, and the fastest way to
* end up with a Spanish half-truth is to let two copies of this file drift.
*/
type Lang = 'en' | 'es';
/** One string in both languages. Everything the client reads is one of these. */
type Copy = { en: string; es: string };
const LANGS: { code: Lang; label: string }[] = [
{ code: 'en', label: 'English' },
{ code: 'es', label: 'Español' },
];
const DEMO = { phone: '+525500000000', code: '000000' };
const UI = {
language: { en: 'Language', es: 'Idioma' },
copy: { en: 'Copy', es: 'Copiar' },
copied: { en: 'copied', es: 'copiado' },
today: { en: 'Today: ', es: 'Hoy: ' },
} satisfies Record<string, Copy>;
const INTRO = {
title: {
en: 'Hire a tradesperson the way you swipe',
es: 'Contrata a un profesional deslizando',
},
body: {
en: 'A mobile marketplace connecting customers with verified local trades. Post a job, swipe through pros who cover your street, agree a price in chat, book the slot and review each other afterwards.',
es: 'Un marketplace móvil que conecta a clientes con profesionales locales verificados. Publica un trabajo, desliza entre los profesionales que cubren tu calle, acuerda un precio en el chat, reserva la cita y valoraos después.',
},
} satisfies Record<string, Copy>;
const TRY = {
heading: { en: 'Try it yourself', es: 'Pruébalo tú mismo' },
body: {
en: 'Sign in on the phone to the left. The whole product runs in there.',
es: 'Inicia sesión en el móvil de al lado. El producto entero funciona ahí dentro.',
},
phone: { en: 'Mobile', es: 'Móvil' },
code: { en: 'Code', es: 'Código' },
} satisfies Record<string, Copy>;
const STACK: { group: Copy; items: string[] }[] = [
// First, because it is the answer to the question the rest of this list
// provokes: fine, but what do I actually install on a phone?
{
group: { en: 'Mobile', es: 'Móvil' },
items: ['Capacitor', 'iOS', 'Android'],
},
{
group: { en: 'App', es: 'App' },
items: ['Next.js 15', 'React 19', 'TypeScript', 'Tailwind v4', 'Motion'],
},
{ group: { en: 'API', es: 'API' }, items: ['tRPC v11', 'Zod', 'better-auth'] },
{
group: { en: 'Data', es: 'Datos' },
items: ['DO Managed Postgres', 'PostGIS', 'Drizzle ORM', 'DO Managed Redis'],
},
{
group: { en: 'Services', es: 'Servicios' },
items: ['DO Spaces (S3)', 'Mapbox', 'Twilio', 'Resend', 'Sentry'],
},
{ group: { en: 'Tooling', es: 'Herramientas' }, items: ['Turborepo', 'pnpm', 'Vitest'] },
];
const FEATURES: { title: Copy; body: Copy; icon: typeof Zap }[] = [
{
icon: Zap,
title: { en: 'Swipe to hire', es: 'Desliza para contratar' },
body: { en: 'Send a job with one gesture.', es: 'Envía un trabajo con un solo gesto.' },
},
{
icon: MapPin,
title: { en: 'Real distance', es: 'Distancia real' },
body: {
en: 'PostGIS ranks by metres, not postcodes.',
es: 'PostGIS ordena por metros, no por códigos postales.',
},
},
{
icon: BadgeCheck,
title: { en: 'Verified pros', es: 'Profesionales verificados' },
body: {
en: 'ID, insurance and licence checked first.',
es: 'Identidad, seguro y licencia comprobados antes de entrar.',
},
},
{
icon: MessagesSquare,
title: { en: 'Chat per job', es: 'Un chat por trabajo' },
body: { en: 'Private, with photos and receipts.', es: 'Privado, con fotos y recibos.' },
},
{
icon: CalendarCheck,
title: { en: 'Quote to booking', es: 'Del presupuesto a la reserva' },
body: {
en: 'Agree a price, book the slot, confirm.',
es: 'Acordáis un precio, se reserva la cita y se confirma.',
},
},
{
icon: Star,
title: { en: 'Blind reviews', es: 'Valoraciones a ciegas' },
body: {
en: 'Hidden until both sides have written.',
es: 'Ocultas hasta que ambas partes han escrito.',
},
},
];
/**
* The open questions, grouped.
*
* Every one of these has a CURRENT behaviour — nothing here is unbuilt because
* it was forgotten. `today` says what happens if nobody decides, which is the
* only honest way to present a decision: the client is confirming or changing
* something, not filling in a blank.
*/
const DECISIONS: { group: Copy; items: { q: Copy; today: Copy }[] }[] = [
{
group: { en: 'Money', es: 'Dinero' },
items: [
{
q: {
en: 'Do we hold the money until the job is done, or do customers pay the pro directly?',
es: '¿Retenemos el dinero hasta que el trabajo esté hecho, o el cliente paga directamente al profesional?',
},
today: {
en: 'Nothing is charged. Quotes and bookings work; no payment is taken at any point.',
es: 'No se cobra nada. Los presupuestos y las reservas funcionan; no se cobra en ningún momento.',
},
},
{
q: {
en: 'What is the commission, and who pays it — the customer, the pro, or split?',
es: '¿Cuál es la comisión y quién la paga: el cliente, el profesional o a medias?',
},
today: {
en: 'Set to 15% in config, applied nowhere.',
es: 'Fijada al 15% en la configuración, aplicada en ninguna parte.',
},
},
{
q: {
en: 'Deposit up front, or the whole amount on completion?',
es: '¿Señal por adelantado o el importe completo al terminar?',
},
today: {
en: 'Neither. The slot is booked on a promise.',
es: 'Ninguna de las dos. La cita se reserva con una promesa.',
},
},
{
q: {
en: 'A customer cancels the day before — what do they owe?',
es: 'Un cliente cancela el día antes: ¿qué debe pagar?',
},
today: {
en: 'Free up to 24h before, then 25%. The rule is written and tested; no money moves.',
es: 'Gratis hasta 24 h antes, después el 25%. La regla está escrita y probada; no se mueve dinero.',
},
},
],
},
{
group: { en: 'Scheduling', es: 'Agenda' },
items: [
{
q: {
en: 'Do pros publish real availability, or is a time agreed in the chat?',
es: '¿Los profesionales publican disponibilidad real, o se acuerda la hora en el chat?',
},
today: {
en: 'Agreed in chat. The customer picks any date and time when accepting a quote.',
es: 'Se acuerda en el chat. El cliente elige cualquier fecha y hora al aceptar un presupuesto.',
},
},
{
q: {
en: 'Should the system stop a pro being double-booked?',
es: '¿Debe el sistema impedir que un profesional tenga dos reservas a la vez?',
},
today: {
en: 'No check. Two customers can book the same pro for the same hour.',
es: 'No hay ninguna comprobación. Dos clientes pueden reservar al mismo profesional a la misma hora.',
},
},
{
q: {
en: 'If a customer never confirms the work is finished, should it auto-confirm?',
es: 'Si el cliente nunca confirma que el trabajo está terminado, ¿debe confirmarse solo?',
},
today: {
en: 'It waits forever. A 72-hour rule is written but nothing runs it.',
es: 'Espera para siempre. Hay una regla de 72 horas escrita, pero nada la ejecuta.',
},
},
],
},
{
group: { en: 'Trust and safety', es: 'Confianza y seguridad' },
items: [
{
q: {
en: 'Should we block phone numbers and emails in chat?',
es: '¿Bloqueamos teléfonos y correos en el chat?',
},
today: {
en: 'Anything can be sent. Two people can agree to take the job off the platform.',
es: 'Se puede enviar cualquier cosa. Dos personas pueden acordar sacar el trabajo de la plataforma.',
},
},
{
q: {
en: 'What happens when the two sides disagree about finished work?',
es: '¿Qué pasa cuando las dos partes no se ponen de acuerdo sobre un trabajo terminado?',
},
today: {
en: 'A disputed state exists in the model. Nothing can reach it.',
es: 'Existe un estado «en disputa» en el modelo. Nada puede llegar a él.',
},
},
{
q: {
en: 'ID checks — automated, or a person reviewing documents?',
es: 'Verificación de identidad: ¿automática o revisada por una persona?',
},
today: {
en: 'A person. Documents are uploaded and reviewed by hand in the admin queue.',
es: 'Una persona. Los documentos se suben y se revisan a mano en la cola de administración.',
},
},
{
q: {
en: 'A pros insurance expires. Do they come off the platform automatically?',
es: 'El seguro de un profesional caduca. ¿Sale de la plataforma automáticamente?',
},
today: {
en: 'The expiry date is stored. Nothing checks it.',
es: 'La fecha de caducidad se guarda. Nada la comprueba.',
},
},
],
},
{
group: { en: 'Launch', es: 'Lanzamiento' },
items: [
{
q: {
en: 'Launch with the trades we have supply for, or all fifty?',
es: '¿Lanzamos con los oficios para los que hay oferta, o con los cincuenta?',
},
today: {
en: 'Fifty trades listed; eight have any pros. The rest look empty to a customer.',
es: 'Hay cincuenta oficios listados; ocho tienen profesionales. El resto se ven vacíos para un cliente.',
},
},
{
q: {
en: 'One city, or several from the start?',
es: '¿Una ciudad o varias desde el principio?',
},
today: {
en: 'One. The city is a setting, so a second is configuration rather than a rebuild.',
es: 'Una. La ciudad es un ajuste, así que una segunda es configuración, no rehacer nada.',
},
},
{
q: {
en: 'Which events are worth an SMS, given each one costs money?',
es: '¿Qué eventos merecen un SMS, teniendo en cuenta que cada uno cuesta dinero?',
},
today: {
en: 'A pro is texted about a new job and an answer. Messages and bookings are silent.',
es: 'Al profesional se le avisa por SMS de un trabajo nuevo y de una respuesta. Los mensajes y las reservas son silenciosos.',
},
},
],
},
];
/**
* Running costs, paid to DigitalOcean rather than to us.
*
* Listed per line rather than as one number because they scale independently —
* the database is the first thing that needs a bigger tier, and storage is the
* only one that grows with use.
*/
const HOSTING: { item: Copy; detail: Copy; usd: number }[] = [
{
item: { en: 'Managed Postgres', es: 'Postgres gestionado' },
detail: { en: 'The database, with PostGIS', es: 'La base de datos, con PostGIS' },
usd: 15,
},
{
item: { en: 'Managed Redis', es: 'Redis gestionado' },
detail: { en: 'Sessions, caching, job queue', es: 'Sesiones, caché y cola de trabajos' },
usd: 15,
},
{
item: { en: 'App Platform', es: 'App Platform' },
detail: { en: 'Runs the app itself', es: 'Ejecuta la propia aplicación' },
usd: 24,
},
{
item: { en: 'Spaces', es: 'Spaces' },
detail: { en: 'Photos and documents', es: 'Fotos y documentos' },
usd: 5,
},
];
const HOSTING_TOTAL = HOSTING.reduce((sum, h) => sum + h.usd, 0);
/**
* The third-party services the stack above names but never costed.
*
* Every one of these is free at launch volumes and none of them is free
* forever, so a flat monthly figure would be wrong in both directions. What
* matters to somebody deciding is the SHAPE of each bill — what has to happen
* before it starts, and what it climbs with.
*/
const SERVICES: { item: Copy; free: Copy; then: Copy }[] = [
{
item: { en: 'Twilio', es: 'Twilio' },
free: { en: 'Per message', es: 'Por mensaje' },
then: {
en: 'No monthly fee, but every sign-in code and job alert is a few cents, plus a rented number. The only one that costs money from day one.',
es: 'Sin cuota mensual, pero cada código de acceso y cada aviso de trabajo cuesta unos céntimos, más un número alquilado. El único que cuesta dinero desde el primer día.',
},
},
{
item: { en: 'Mapbox', es: 'Mapbox' },
free: { en: 'Free to start', es: 'Gratis al principio' },
then: {
en: 'Free up to tens of thousands of address lookups a month. Climbs with searches, not with customers.',
es: 'Gratis hasta decenas de miles de búsquedas de direcciones al mes. Sube con las búsquedas, no con los clientes.',
},
},
{
item: { en: 'Resend', es: 'Resend' },
free: { en: 'Free to start', es: 'Gratis al principio' },
then: {
en: 'Free for the first few thousand emails a month, about $20 after that.',
es: 'Gratis para los primeros miles de correos al mes, unos 20 $ a partir de ahí.',
},
},
{
item: { en: 'Sentry', es: 'Sentry' },
free: { en: 'Free to start', es: 'Gratis al principio' },
then: {
en: 'Free tier covers early error volumes, around $26 once it does not. Optional — it reports crashes, it does not run anything.',
es: 'El plan gratuito cubre los primeros errores, unos 26 $ cuando deje de hacerlo. Opcional: informa de fallos, no ejecuta nada.',
},
},
];
/**
* What it costs to have the app exist in a store.
*
* Not our fee and not hosting — these are accounts in the client's own name,
* for the same reason the DigitalOcean account is: an app published under our
* developer account is an app they cannot take with them.
*/
const STORE_ACCOUNTS: { item: Copy; cost: Copy; detail: Copy }[] = [
{
item: { en: 'Apple Developer Program', es: 'Apple Developer Program' },
cost: { en: '$99 / year', es: '99 $ / año' },
detail: {
en: 'Required to put anything on the App Store, and it lapses if unpaid — the app comes down with it. A company account also needs a D-U-N-S number, which is free but takes a couple of weeks, so it is worth starting early.',
es: 'Obligatoria para publicar cualquier cosa en la App Store, y caduca si no se renueva: la app se cae con ella. Una cuenta de empresa necesita además un número D-U-N-S, gratuito pero que tarda un par de semanas, así que conviene empezarlo pronto.',
},
},
{
item: { en: 'Google Play Console', es: 'Google Play Console' },
cost: { en: '$25 once', es: '25 $ una vez' },
detail: {
en: 'One payment, for the life of the account. Review is faster and less strict than Apples.',
es: 'Un solo pago, para toda la vida de la cuenta. La revisión es más rápida y menos estricta que la de Apple.',
},
},
];
const SECTIONS = {
features: { en: 'What it does', es: 'Qué hace' },
stack: { en: 'Built with', es: 'Hecho con' },
stackBody: {
en: 'One codebase, three places to install it. The same app runs in a browser and ships to the App Store and Google Play wrapped in Capacitor — so there is one thing to build and one thing to fix, rather than a website and two native apps drifting apart.',
es: 'Un solo código, tres sitios donde instalarlo. La misma app funciona en el navegador y se publica en la App Store y en Google Play envuelta en Capacitor: hay una sola cosa que construir y una sola que arreglar, en lugar de una web y dos apps nativas que se van separando.',
},
decisions: { en: 'Still to decide', es: 'Aún por decidir' },
decisionsBody: {
en: 'Everything below already has a behaviour. These are the ones worth choosing deliberately rather than inheriting.',
es: 'Todo lo de abajo ya tiene un comportamiento. Estas son las decisiones que conviene tomar a propósito en lugar de heredarlas.',
},
cost: { en: 'Delivery and cost', es: 'Entrega y coste' },
build: { en: 'To build and launch', es: 'Construirlo y lanzarlo' },
buildNote: {
en: 'One-off. Where it lands depends on the answers above.',
es: 'Pago único. Dónde caiga depende de las respuestas de arriba.',
},
timeline: { en: 'Timeline', es: 'Plazo' },
timelineValue: { en: '610 weeks', es: '610 semanas' },
timelineNote: {
en: 'Six if the open questions are settled early, ten if they are not.',
es: 'Seis si las preguntas abiertas se cierran pronto, diez si no.',
},
hosting: { en: 'Hosting, per month', es: 'Alojamiento, al mes' },
total: { en: 'Total', es: 'Total' },
perMonth: { en: '/mo', es: '/mes' },
services: { en: 'Services it calls', es: 'Servicios que utiliza' },
servicesNote: {
en: 'Separate accounts, separate bills, all in your name. None of these is charged by us and none has a markup.',
es: 'Cuentas separadas, facturas separadas, todas a tu nombre. Ninguno lo cobramos nosotros y ninguno lleva recargo.',
},
stores: { en: 'App store accounts', es: 'Cuentas de las tiendas' },
storesNote: {
en: 'The app is published under your developer accounts, not ours — the same reason the hosting is yours. An app on our account is an app you cannot take with you, and moving one afterwards means a new listing and losing its reviews and ranking.',
es: 'La app se publica con tus cuentas de desarrollador, no con las nuestras, por la misma razón que el alojamiento es tuyo. Una app en nuestra cuenta es una app que no te puedes llevar, y moverla después significa una ficha nueva y perder sus valoraciones y su posición.',
},
} satisfies Record<string, Copy>;
/** The three caveats under the hosting table — lead sentence, then the rest. */
const HOSTING_NOTES: { lead: Copy; rest: Copy }[] = [
{
lead: { en: 'You pay DigitalOcean directly.', es: 'Pagas directamente a DigitalOcean.' },
rest: {
en: ' This is not part of our fee and there is no markup on it — the account is yours, so you can see the bill and change the plan without going through us.',
es: ' No forma parte de nuestros honorarios y no lleva ningún recargo: la cuenta es tuya, así que puedes ver la factura y cambiar de plan sin pasar por nosotros.',
},
},
{
lead: {
en: 'DigitalOcean is the quote, not the requirement.',
es: 'DigitalOcean es el presupuesto, no el requisito.',
},
rest: {
en: ' What runs is a container and a Postgres database, so it runs just as well on AWS, Google Cloud, Hetzner or whatever you already have an account with — only the figures above change. We price DigitalOcean because it is the cheapest of the managed options at this size and its bill is legible.',
es: ' Lo que se ejecuta es un contenedor y una base de datos Postgres, así que funciona igual de bien en AWS, Google Cloud, Hetzner o donde ya tengas cuenta: solo cambian las cifras de arriba. Presupuestamos DigitalOcean porque es la más barata de las opciones gestionadas a este tamaño y su factura se entiende.',
},
},
{
lead: {
en: `$${HOSTING_TOTAL} is the smallest tier of each.`,
es: `${HOSTING_TOTAL} $ es el plan más pequeño de cada uno.`,
},
rest: {
en: ' Enough to launch on and to run while the platform is finding its first customers.',
es: ' Suficiente para lanzar y para funcionar mientras la plataforma consigue sus primeros clientes.',
},
},
{
lead: {
en: 'Costs rise with use, unevenly.',
es: 'Los costes suben con el uso, de forma desigual.',
},
rest: {
en: ' The database is the first thing that will need a larger plan; storage creeps up slowly as photos accumulate; the app itself can stay where it is for a long time.',
es: ' La base de datos es lo primero que necesitará un plan mayor; el almacenamiento crece despacio a medida que se acumulan fotos; la aplicación en sí puede quedarse donde está mucho tiempo.',
},
},
];
export function ProjectPanel() {
const [lang, setLang] = useState<Lang>('en');
const t = (copy: Copy) => copy[lang];
return (
// `lang` on the wrapper, not only in state: it is what tells a screen reader
// which voice to read this in and a browser which dictionary to hyphenate by.
<div lang={lang} className="flex flex-col gap-10 px-6 py-10 lg:px-12 lg:py-14">
{/* Above the title it changes, so the client sees the switch before they
have started reading. Right-aligned: it is a control on the panel, not
a heading of it, and the title keeps the left edge to itself. */}
<div className="flex justify-end">
<div
role="group"
aria-label={t(UI.language)}
className="inline-flex gap-0.5 rounded-pill border border-hairline bg-raised p-1"
>
{LANGS.map(({ code, label }) => (
<button
key={code}
type="button"
lang={code}
onClick={() => setLang(code)}
aria-pressed={lang === code}
className={cn(
'rounded-pill px-3 py-1 text-meta',
'transition-colors duration-[120ms] ease-standard',
'focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-brand-200',
lang === code
? 'bg-accent-soft font-semibold text-accent'
: 'text-muted hover:text-strong',
)}
>
{label}
</button>
))}
</div>
</div>
<header>
<p className="text-overline uppercase text-accent">Linkdr</p>
<h1 className="mt-2 text-h1">{t(INTRO.title)}</h1>
<p className="mt-3 max-w-[60ch] text-body text-muted text-pretty">{t(INTRO.body)}</p>
</header>
<section>
<h2 className="mb-2 text-h3">{t(TRY.heading)}</h2>
<p className="mb-4 max-w-[56ch] text-body-sm text-muted">{t(TRY.body)}</p>
{/* Side by side: two short values do not need two full-width rows.
The code column is content-width so the number keeps the room. */}
<div className="flex max-w-lg flex-wrap gap-2">
<CopyRow
label={t(TRY.phone)}
value={DEMO.phone}
copyLabel={t(UI.copy)}
copiedLabel={t(UI.copied)}
className="min-w-56 flex-1"
/>
<CopyRow
label={t(TRY.code)}
value={DEMO.code}
copyLabel={t(UI.copy)}
copiedLabel={t(UI.copied)}
className="shrink-0"
/>
</div>
</section>
<section>
<h2 className="mb-4 text-h3">{t(SECTIONS.features)}</h2>
{/* Tight two-column grid: small icon, title and its line on one row.
Read standing up, mid-sentence — so it has to scan, not be read. */}
<dl className="grid gap-x-6 gap-y-3 sm:grid-cols-2">
{FEATURES.map((f) => (
// Keyed on the English string throughout: the key has to survive the
// toggle, or React remounts every row on a language change.
<div key={f.title.en} className="flex items-start gap-2.5">
<f.icon className="mt-0.5 h-4 w-4 shrink-0 text-accent" aria-hidden />
<span className="min-w-0">
<dt className="inline font-semibold text-body-sm text-strong">{t(f.title)}</dt>
<dd className="inline text-body-sm text-muted"> {t(f.body)}</dd>
</span>
</div>
))}
</dl>
</section>
<section>
<h2 className="mb-1 text-h3">{t(SECTIONS.stack)}</h2>
<p className="mb-4 max-w-[56ch] text-body-sm text-muted">{t(SECTIONS.stackBody)}</p>
<div className="flex flex-col gap-3">
{STACK.map((row) => (
<div key={row.group.en} className="flex flex-wrap items-baseline gap-x-3 gap-y-2">
{/* w-20, not w-16: "Herramientas" is twice the width of "Tooling". */}
<span className="w-20 shrink-0 text-meta text-faint">{t(row.group)}</span>
{row.items.map((item) => (
<span
key={item}
className="rounded-pill border border-hairline px-3 py-1 text-meta text-strong"
>
{item}
</span>
))}
</div>
))}
</div>
</section>
<section>
<h2 className="mb-1 text-h3">{t(SECTIONS.decisions)}</h2>
<p className="mb-4 max-w-[56ch] text-body-sm text-muted">{t(SECTIONS.decisionsBody)}</p>
<div className="flex flex-col gap-6">
{DECISIONS.map((section) => (
<div key={section.group.en}>
<h3 className="mb-2 text-overline uppercase text-faint">{t(section.group)}</h3>
<ul className="flex flex-col gap-3">
{section.items.map((item) => (
<li key={item.q.en} className="border-l-2 border-hairline pl-3">
<p className="text-body-sm font-semibold text-strong text-pretty">
{t(item.q)}
</p>
<p className="mt-0.5 text-meta text-muted text-pretty">
<span className="text-faint">{t(UI.today)}</span>
{t(item.today)}
</p>
</li>
))}
</ul>
</div>
))}
</div>
</section>
{/* Last, and deliberately after the decisions: the range IS the answer to
those questions, so quoting a single number above them would be a
promise made before the scope exists. */}
<section>
<h2 className="mb-4 text-h3">{t(SECTIONS.cost)}</h2>
<div className="mb-5 flex flex-col gap-3 sm:flex-row">
<div className="flex-1 rounded-card border border-hairline bg-raised p-5">
<p className="text-meta text-faint">{t(SECTIONS.build)}</p>
<p className="mt-1 font-display text-h2 text-strong tabular-nums">$4,4006,000</p>
<p className="mt-1 text-body-sm text-muted">{t(SECTIONS.buildNote)}</p>
</div>
<div className="flex-1 rounded-card border border-hairline bg-raised p-5">
<p className="text-meta text-faint">{t(SECTIONS.timeline)}</p>
<p className="mt-1 font-display text-h2 text-strong tabular-nums">
{t(SECTIONS.timelineValue)}
</p>
<p className="mt-1 text-body-sm text-muted">{t(SECTIONS.timelineNote)}</p>
</div>
</div>
<h3 className="mb-2 text-overline uppercase text-faint">{t(SECTIONS.hosting)}</h3>
<ul className="flex flex-col gap-1.5">
{HOSTING.map((h) => (
<li key={h.item.en} className="flex items-baseline gap-3 text-body-sm">
<span className="font-semibold text-strong">{t(h.item)}</span>
<span className="min-w-0 flex-1 truncate text-meta text-muted">{t(h.detail)}</span>
<span className="shrink-0 text-strong tabular-nums">${h.usd}</span>
</li>
))}
<li className="mt-1.5 flex items-baseline gap-3 border-t border-hairline pt-2 text-body-sm">
<span className="flex-1 font-semibold text-strong">{t(SECTIONS.total)}</span>
<span className="shrink-0 font-display text-h4 text-strong tabular-nums">
${HOSTING_TOTAL}
{t(SECTIONS.perMonth)}
</span>
</li>
</ul>
<div className="mt-3 flex max-w-[60ch] flex-col gap-1.5 text-meta text-muted">
{HOSTING_NOTES.map((note) => (
<p key={note.lead.en}>
<span className="font-semibold text-strong">{t(note.lead)}</span>
{t(note.rest)}
</p>
))}
</div>
{/*
The stack above names five outside services and the table above costs
none of them. No running total here on purpose: four services that are
each free until a different threshold do not add up to a number, and
printing one would be a figure nobody could check.
*/}
<h3 className="mt-8 mb-2 text-overline uppercase text-faint">{t(SECTIONS.services)}</h3>
<ul className="flex flex-col gap-3">
{SERVICES.map((s) => (
<li key={s.item.en}>
<div className="flex items-baseline gap-3 text-body-sm">
<span className="font-semibold text-strong">{t(s.item)}</span>
<span className="min-w-0 flex-1 border-b border-dotted border-hairline" />
<span className="shrink-0 text-strong">{t(s.free)}</span>
</div>
<p className="mt-0.5 max-w-[60ch] text-meta text-muted">{t(s.then)}</p>
</li>
))}
</ul>
<p className="mt-3 max-w-[60ch] text-meta text-muted">{t(SECTIONS.servicesNote)}</p>
<h3 className="mt-8 mb-2 text-overline uppercase text-faint">{t(SECTIONS.stores)}</h3>
<ul className="flex flex-col gap-3">
{STORE_ACCOUNTS.map((s) => (
<li key={s.item.en}>
<div className="flex items-baseline gap-3 text-body-sm">
<span className="font-semibold text-strong">{t(s.item)}</span>
<span className="min-w-0 flex-1 border-b border-dotted border-hairline" />
<span className="shrink-0 text-strong tabular-nums">{t(s.cost)}</span>
</div>
<p className="mt-0.5 max-w-[60ch] text-meta text-muted">{t(s.detail)}</p>
</li>
))}
</ul>
<p className="mt-3 max-w-[60ch] text-meta text-muted">{t(SECTIONS.storesNote)}</p>
</section>
</div>
);
}
/**
* A value to hand over verbatim, with one tap to copy.
*
* Reading a phone number off a screen into a form while somebody watches is a
* small humiliation; mistyping one in front of a client is a worse one.
*/
function CopyRow({
label,
value,
copyLabel,
copiedLabel,
className,
}: {
label: string;
value: string;
copyLabel: string;
copiedLabel: string;
className?: string;
}) {
const [copied, setCopied] = useState(false);
return (
<div
className={cn(
'flex items-center gap-3 rounded-lg border border-hairline bg-raised px-4 py-2.5',
className,
)}
>
<span className="shrink-0 text-meta text-faint">{label}</span>
<code className="min-w-0 flex-1 truncate font-mono text-body-sm text-strong tabular-nums">
{value}
</code>
<button
type="button"
onClick={async () => {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 1600);
} catch {
// Clipboard can be refused (insecure origin). The value is on screen
// and readable, which is the fallback that always works.
}
}}
// The state is in the accessible name too, not only the icon — §8.
aria-label={copied ? `${label} ${copiedLabel}` : `${copyLabel} ${label.toLowerCase()}`}
className={cn(
'flex h-9 w-9 shrink-0 items-center justify-center rounded-lg',
'transition-colors duration-[120ms] ease-standard',
'focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-brand-200',
copied ? 'text-go-600' : 'text-muted hover:bg-inset hover:text-accent',
)}
>
{copied ? (
<Check className="h-4 w-4" aria-hidden />
) : (
<Copy className="h-4 w-4" aria-hidden />
)}
</button>
</div>
);
}