'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; 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; 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; 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; /** 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('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.
{/* 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. */}
{LANGS.map(({ code, label }) => ( ))}

Linkdr

{t(INTRO.title)}

{t(INTRO.body)}

{t(TRY.heading)}

{t(TRY.body)}

{/* 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. */}

{t(SECTIONS.features)}

{/* 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. */}
{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.
{t(f.title)}
— {t(f.body)}
))}

{t(SECTIONS.stack)}

{STACK.map((row) => (
{/* w-20, not w-16: "Herramientas" is twice the width of "Tooling". */} {t(row.group)} {row.items.map((item) => ( {item} ))}
))}

{t(SECTIONS.decisions)}

{t(SECTIONS.decisionsBody)}

{DECISIONS.map((section) => (

{t(section.group)}

    {section.items.map((item) => (
  • {t(item.q)}

    {t(UI.today)} {t(item.today)}

  • ))}
))}
{/* 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. */}

{t(SECTIONS.cost)}

{t(SECTIONS.build)}

$4,400–6,000

{t(SECTIONS.buildNote)}

{t(SECTIONS.timeline)}

{t(SECTIONS.timelineValue)}

{t(SECTIONS.timelineNote)}

{t(SECTIONS.hosting)}

    {HOSTING.map((h) => (
  • {t(h.item)} {t(h.detail)} ${h.usd}
  • ))}
  • {t(SECTIONS.total)} ${HOSTING_TOTAL} {t(SECTIONS.perMonth)}
{HOSTING_NOTES.map((note) => (

{t(note.lead)} {t(note.rest)}

))}
); } /** * 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 (
{label} {value}
); }