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:
serfa
2026-08-23 10:56:31 -04:00
co-authored by Claude Opus 5
parent 5086a238ea
commit 1808ad4cba
113 changed files with 1944 additions and 472 deletions
@@ -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 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);
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: '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' },
} 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,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>
</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>
);
}