Files
linkder/apps/web/src/components/chrome/project-panel.tsx
T
serfaandClaude Opus 5 1808ad4cba 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>
2026-08-23 10:56:31 -04:00

637 lines
24 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[] }[] = [
{
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>
);
}