Move the demo market to Mexico City, priced in US dollars
The showcase was a Barcelona market: Catalan names, +34 numbers, euro rates and "Carrer Example 12" on every job. Presented to a Mexican client, all of that reads as somebody else's product. City comes from NEXT_PUBLIC_CITY_* as before, now Ciudad de México at 19.4326/-99.1332, with MAPBOX_COUNTRY=mx. The seed's fallbacks were Barcelona literals, so an unset env quietly seeded a different city than the app rendered — they now agree. Two db tests pinned the Barcelona centre as a hardcoded constant, which is why the deck returned zero cards on the first run here: every pro was a continent outside the radius. They read the same env as the seed now, so the trap cannot recur. Money: formatCents defaults to USD/en-US, and the nine hardcoded euro signs across the card, search rows, quote strip and forms are dollars. The rate NUMBERS are unchanged and still read high for CDMX — that is a pricing decision, not a currency one, and is left alone deliberately. Seed people are Mexican, addressed on real Roma/Condesa streets rotated by index rather than one placeholder repeated. Phones moved to +52 55, which moves the demo login to +525500000000 / 000000. Also in here, from the same session: - Sending a job now confirms. The mutation always succeeded; the sheet just closed with no receipt, which from the customer's side is indistinguishable from a dead button. Dismissing that receipt resolves as 'sent', so the card does not return to the deck. - Media moves to DigitalOcean Spaces, with the public origin derived from bucket and region instead of a second env var to keep in sync. - Managed-Postgres TLS: DATABASE_CA_CERT takes a path or inline PEM. - The client-facing project panel beside the running app. - Two profiles removed and four renamed to match their photos. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,636 @@
|
||||
'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[] }[] = [
|
||||
{
|
||||
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 pro’s 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);
|
||||
|
||||
const SECTIONS = {
|
||||
features: { en: 'What it does', es: 'Qué hace' },
|
||||
stack: { en: 'Built with', es: 'Hecho con' },
|
||||
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: '6–10 weeks', es: '6–10 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' },
|
||||
} 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: `$${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-4 text-h3">{t(SECTIONS.stack)}</h2>
|
||||
<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,400–6,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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user