diff --git a/DESIGN.md b/DESIGN.md index 7645bbe..4183388 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -258,6 +258,16 @@ Pill, `1.5px` border, `12px 16px` padding, `body-sm`. Unselected: `ink-200` bord transparent. Selected: `brand-500` border, `brand-100` fill, weight 600, leading check icon. Selection must never rely on fill alone. +Three sizes. `md` is the default wherever a chip is a primary choice. `sm` is the dense +horizontal strip — the trade filter fits four across a 390px phone before the fifth is cut off +as a scroll hint. `compact` sits between them, for a **wrapping** list that is still a primary +choice but too long to spend full-size rows on: fifteen trades at `md` fill the job form before +the description field is reachable. + +`compact` is about a third shorter than `md` and stays on the 8px grid. It also drops under the +44px target in §8, which is a deliberate trade and only acceptable where the chips wrap with a +gap between them — never for a lone control. + ### 6.5 Banner / status card `radius-card`, 20px padding, 12px icon-to-text gap, tinted surface + matching border: diff --git a/apps/web/src/app/jobs/new/form.tsx b/apps/web/src/app/jobs/new/form.tsx index 2429cf8..dc8e6d6 100644 --- a/apps/web/src/app/jobs/new/form.tsx +++ b/apps/web/src/app/jobs/new/form.tsx @@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'; import type { RouterOutputs } from '@/lib/trpc'; import { api } from '@/lib/trpc'; import { clearPendingHire, readPendingHire } from '@/lib/pending-hire'; +import { clearPendingJob, readPendingJob, setPendingJob } from '@/lib/pending-job'; import { AddressField, EMPTY_ADDRESS, @@ -33,10 +34,14 @@ const URGENCIES = [ export function NewJobForm({ categories, sendTo = null, + signedIn = true, }: { categories: Categories; /** A pro this job is being posted for — see the entry deck's send sheet. */ sendTo?: string | null; + /** False for a visitor with no account. The form still renders; only the + * final submit needs them, and the draft is parked across sign-in. */ + signedIn?: boolean; }) { const router = useRouter(); const [categoryId, setCategoryId] = useState(''); @@ -58,6 +63,27 @@ export function NewJobForm({ if (pending?.proId === sendTo) setSendToName(pending.name); }, [sendTo]); + /* + * Put back whatever they typed before signing in. + * + * Runs once, and only when signed in: restoring for an anonymous visitor + * would refill the form they are still looking at. Cleared immediately — + * a draft that survives being restored comes back on the next fresh job. + */ + useEffect(() => { + if (!signedIn) return; + const draft = readPendingJob(); + if (!draft) return; + clearPendingJob(); + setCategoryId(draft.categoryId); + setTitle(draft.title); + setDescription(draft.description); + setUrgency(draft.urgency as (typeof URGENCIES)[number]['value']); + setAddress(draft.address); + setBudgetMin(draft.budgetMin); + setBudgetMax(draft.budgetMax); + }, [signedIn]); + /** * Posting for a specific pro sends it to them as well. * @@ -92,6 +118,27 @@ export function NewJobForm({ event.preventDefault(); setError(null); + /* + * The one thing that genuinely needs an account. + * + * Park the draft first, THEN leave: the whole point of showing the form + * early is lost if signing in costs them the description they just wrote. + */ + if (!signedIn) { + setPendingJob({ + categoryId, + title, + description, + urgency, + address, + budgetMin, + budgetMax, + sendTo, + }); + router.push('/sign-in?next=/jobs/new'); + return; + } + const toCents = (v: string) => (v.trim() ? Math.round(Number(v) * 100) : undefined); const min = toCents(budgetMin); const max = toCents(budgetMax); @@ -125,6 +172,7 @@ export function NewJobForm({ {categories.map((c) => ( setCategoryId(c.id)} > @@ -206,6 +254,13 @@ export function NewJobForm({ + {/* Said here rather than sprung on them after they press it. The form + is free to fill in; the account is what posting costs. */} + {!signedIn && ( +

+ You will sign in on the next step. Nothing you have typed is lost. +

+ )} ); diff --git a/apps/web/src/app/jobs/new/page.tsx b/apps/web/src/app/jobs/new/page.tsx index 1a9ffc3..d797bd2 100644 --- a/apps/web/src/app/jobs/new/page.tsx +++ b/apps/web/src/app/jobs/new/page.tsx @@ -14,13 +14,24 @@ export default async function NewJobPage({ }) { const api = await getApi(); - let me: Awaited>; + /* + * Anonymous visitors get the form. + * + * This used to redirect them to sign-in before they had seen a single field, + * which asks a stranger to open an account for a product that has not yet + * done anything for them. The account is needed to POST the job, not to + * describe one — so the ask moves to the submit button, and the draft + * survives the round trip (see lib/pending-job.ts). + */ + let me: Awaited> | null = null; try { me = await api.user.me(); } catch { - redirect('/sign-in?next=/jobs/new'); + me = null; } - if (me.role === 'pro') redirect('/pro'); + // A pro has no jobs to post; that redirect is about role, not about auth. + if (me?.role === 'pro') redirect('/pro'); + const signedIn = me !== null; const [categories, { pro }] = await Promise.all([api.job.categories(), searchParams]); @@ -29,11 +40,11 @@ export default async function NewJobPage({ const sendTo = /^[0-9a-f-]{36}$/i.test(pro ?? '') ? pro! : null; return ( - + Describe it once. We will show you verified pros nearby who can take it on. - + ); } diff --git a/apps/web/src/app/pro/onboarding/page.tsx b/apps/web/src/app/pro/onboarding/page.tsx index 2d38e20..df5990f 100644 --- a/apps/web/src/app/pro/onboarding/page.tsx +++ b/apps/web/src/app/pro/onboarding/page.tsx @@ -33,7 +33,7 @@ export default async function ProOnboardingPage() { } return ( - + We check every pro’s ID, licence and insurance before any customer sees them. It usually takes a day. diff --git a/apps/web/src/app/pro/page.tsx b/apps/web/src/app/pro/page.tsx index 55d7a0e..cdaa303 100644 --- a/apps/web/src/app/pro/page.tsx +++ b/apps/web/src/app/pro/page.tsx @@ -23,7 +23,7 @@ export default async function ProHomePage() { const status = profile.verificationStatus; return ( - + {profile.headline} {status === 'pending' && ( diff --git a/apps/web/src/app/showcase-deck.tsx b/apps/web/src/app/showcase-deck.tsx index e22bfd2..a51444c 100644 --- a/apps/web/src/app/showcase-deck.tsx +++ b/apps/web/src/app/showcase-deck.tsx @@ -5,12 +5,13 @@ import Link from 'next/link'; import { X } from 'lucide-react'; import type { DeckCard } from '@linkdr/db'; import { Deck, type SwipeVerdict } from '@/components/deck'; -import { Chip, ScrollStrip } from '@/components/ui'; +import { Chip, ScrollStrip, useToast } from '@/components/ui'; import { PhoneTabs, type PhoneTab } from '@/components/chrome/phone-tabs'; import { SettingsPanel } from './settings-panel'; import { INITIAL_SEARCH_STATE, SearchPanel, type SearchState } from './search-panel'; import { ProfilePanel } from './profile-panel'; import { INITIAL_JOBS_STATE, JobsPanel, type JobsState } from './jobs-panel'; +import { ProProfilePanel } from '@/components/pro/pro-profile-panel'; import { SendJobSheet } from '@/components/hire/send-job-sheet'; import { AskSheet } from '@/components/hire/ask-sheet'; import { clearPendingHire, readPendingHire } from '@/lib/pending-hire'; @@ -54,6 +55,7 @@ export function ShowcaseDeck({ */ initialTab?: PhoneTab; }) { + const toast = useToast(); const [categoryId, setCategoryId] = useState(null); const [tab, setTab] = useState(initialTab); // Search state lives here, beside the tab state, because the panels unmount on @@ -112,17 +114,80 @@ export function ShowcaseDeck({ /* ── the three secondary deck actions ── */ const [asking, setAsking] = useState(null); + /** + * The card someone tapped rather than swiped. + * + * Held as the DeckCard, not an id: ProProfilePanel paints from the row it was + * given while its own query is in flight, so the screen has a name and a face + * on it from the first frame instead of a spinner. + */ + const [viewing, setViewing] = useState(null); // Errors for an anonymous visitor, same as the unread badge — no session, // nothing watched. const watched = api.watch.mine.useQuery(undefined, { retry: false }); - const watchedIds = useMemo( - () => new Set((watched.data ?? []).map((w) => w.proId)), - [watched.data], - ); + /** + * Optimistic watch state. + * + * `watch.mine` is the truth, but it is a round trip away and it is the ONLY + * thing that fills the eye. Waiting for it meant the button sat unchanged for + * the length of a request, which reads as a dead control — and for an + * anonymous visitor, where the mutation is rejected outright, it never + * changed at all. Held separately and cleared on settle, so the server still + * wins; this only covers the gap. + */ + const [pendingWatch, setPendingWatch] = useState>(new Map()); + + const watchedIds = useMemo(() => { + const ids = new Set((watched.data ?? []).map((w) => w.proId)); + for (const [proId, on] of pendingWatch) { + if (on) ids.add(proId); + else ids.delete(proId); + } + return ids; + }, [watched.data, pendingWatch]); const toggleWatch = api.watch.toggle.useMutation({ - onSettled: () => void utils.watch.mine.invalidate(), + onMutate: ({ proId }) => { + setPendingWatch((m) => new Map(m).set(proId, !watchedIds.has(proId))); + }, + onSuccess: (result, { proId }) => { + const name = cards.find((c) => c.proId === proId)?.name ?? 'This pro'; + if (!result.watching) { + toast(`You will not be told about ${name} any more.`, { tone: 'info' }); + return; + } + // The pro is available RIGHT NOW, so a "we'll tell you when they're free" + // message would be nonsense. Say what the button actually bought them. + toast( + result.availableNow + ? `Watching ${name}. They are taking work now — swipe right whenever you are ready.` + : `Watching ${name}. We will tell you the moment they are free.`, + { tone: 'success' }, + ); + }, + onError: (error, { proId }) => { + // Roll the eye back: the server said no. + setPendingWatch((m) => { + const next = new Map(m); + next.delete(proId); + return next; + }); + toast( + error.data?.code === 'UNAUTHORIZED' + ? 'Sign in first and we can tell you when this pro is free.' + : 'Could not save that just now. Try again in a moment.', + { tone: error.data?.code === 'UNAUTHORIZED' ? 'info' : 'error' }, + ); + }, + onSettled: (_data, _error, { proId }) => { + setPendingWatch((m) => { + const next = new Map(m); + next.delete(proId); + return next; + }); + void utils.watch.mine.invalidate(); + }, }); /** @@ -179,6 +244,17 @@ export function ShowcaseDeck({ /> ) : tab === 'jobs' ? ( + ) : viewing ? ( + /* Tapped a card. Same screen a search result opens, so the profile is + one thing in this product rather than two that drift apart. */ + setViewing(null)} + onHire={(pro) => { + setViewing(null); + setHiring(pro); + }} + /> ) : ( <> {/* Trade strip. Above the card, never over the photo — so it cannot steal @@ -228,6 +304,10 @@ export function ShowcaseDeck({ onDecide={decide} onRewind={rewind} rewindSignal={rewindAt} + onOpenProfile={(proId) => { + const card = cards.find((c) => c.proId === proId); + if (card) setViewing(card); + }} onWatch={(proId) => toggleWatch.mutate({ proId })} watchedProIds={watchedIds} onAsk={(proId) => { diff --git a/apps/web/src/components/chrome/app-shell.tsx b/apps/web/src/components/chrome/app-shell.tsx index d762ded..de08607 100644 --- a/apps/web/src/components/chrome/app-shell.tsx +++ b/apps/web/src/components/chrome/app-shell.tsx @@ -13,10 +13,20 @@ import { BackLink } from './back-link'; */ export function AppShell({ title, + back, className, children, }: { title: string; + /** + * Where this screen came from. + * + * These are pushed screens reached from inside the app, and without this they + * are dead ends — the tab bar is not rendered here, so a person who opens the + * job form and changes their mind has no way back short of the browser's own + * button, which a wrapped mobile app does not show them. + */ + back?: { href: string; label: string }; className?: string; children: React.ReactNode; }) { @@ -24,11 +34,15 @@ export function AppShell({
-

{title}

+ {back && } +

{title}

{children}
); @@ -39,9 +53,17 @@ export function AppShell({ * screen. Vertically centred, because these are single-decision screens with * little on them. * - * `back` puts a link home in the top-left corner. It is absolutely positioned so - * that adding it does not push the centred content off centre — these screens - * are composed around the middle of the viewport, not around the top. + * `back` puts a link home at the top left, IN FLOW. + * + * It used to be absolutely positioned, on the reasoning that reserving space + * for it would push the centred content off centre. That held only while the + * content was short. In the 390px phone frame the centred block rides up under + * the link and the two sets of words print on top of each other — the back + * label and the page's own kicker in the same pixels. + * + * So the link takes a row, and the content centres in what is left. It is half + * a row lower than before and unambiguously readable, which is the better + * trade: a screen whose text overlaps is not centred, it is broken. */ export function BareShell({ back = false, @@ -51,13 +73,15 @@ export function BareShell({ children: React.ReactNode; }) { return ( -
+
{back && ( -
+
)} - {children} + {/* min-h-0 so a tall form scrolls inside the column instead of + overflowing it, now that the column no longer sizes to its content. */} +
{children}
); } diff --git a/apps/web/src/components/chrome/architecture-diagram.tsx b/apps/web/src/components/chrome/architecture-diagram.tsx new file mode 100644 index 0000000..0f265c3 --- /dev/null +++ b/apps/web/src/components/chrome/architecture-diagram.tsx @@ -0,0 +1,329 @@ +/** + * How the pieces fit together, drawn once. + * + * The "Built with" list above answers *what* is installed; it cannot answer + * what talks to what, which is the question a client actually has when they are + * deciding whether this is a real system or a pile of logos. + * + * Inline SVG rather than an image: every colour is a CSS variable, so it + * follows the theme, stays sharp at any zoom, and its text is selectable and + * searchable. A PNG would be none of those and would need regenerating by hand + * every time the stack moves. + * + * The load-bearing claim of this drawing is that there are TWO doors, not one + * row of clients. Customers arrive only through the mobile apps. The browser is + * not a way in for them — it is the admin dashboard, and it sits behind + * Cloudflare Access, which authenticates a person BEFORE any request reaches + * our origin. Drawing those as siblings would have said the opposite: that the + * web is simply another client anyone may use. + * + * There is deliberately no reverse-proxy box. App Platform is a PaaS: it + * terminates TLS, routes the domain, runs the health check and scales the + * container. Drawing a Traefik or nginx in front of it would be inventing + * infrastructure that does not exist in this deployment — the demo on Dokploy + * has one, production does not. + * + * Solid means we run it. Dashed means somebody else does, and it can fail on + * its own. + */ + +interface Labels { + customers: string; + customersNote: string; + admin: string; + adminNote: string; + gate: string; + gateNote: string; + platform: string; + platformNote: string; + app: string; + data: string; + external: string; + ours: string; + theirs: string; + /** + * Sub-labels that are ordinary words rather than product or protocol names. + * S3, SMS, SSR, superjson, OTP and OAuth stay put in every language — they + * are what the thing is called. These four are just English, and were the + * only text on the diagram that did not turn over with the toggle. + */ + queues: string; + geocode: string; + email: string; + errors: string; +} + +/** One rounded box with a title and an optional second line. */ +function Box({ + x, + y, + w, + h, + title, + sub, + tone = 'plain', + dashed = false, +}: { + x: number; + y: number; + w: number; + h: number; + title: string; + sub?: string; + tone?: 'plain' | 'accent' | 'soft'; + dashed?: boolean; +}) { + const fill = + tone === 'accent' + ? 'var(--accent)' + : tone === 'soft' + ? 'var(--accent-soft)' + : 'var(--surface-raised)'; + const stroke = tone === 'accent' ? 'var(--accent)' : 'var(--hairline)'; + const titleFill = tone === 'accent' ? '#fff' : 'var(--text-strong)'; + const subFill = tone === 'accent' ? 'rgba(255,255,255,.78)' : 'var(--text-faint)'; + + return ( + + + + {title} + + {sub && ( + + {sub} + + )} + + ); +} + +/** A band heading — the word down the left-hand margin. */ +function Lane({ x = 0, y, label }: { x?: number; y: number; label: string }) { + return ( + + {label.toUpperCase()} + + ); +} + +export function ArchitectureDiagram({ labels }: { labels: Labels }) { + return ( + // Wide content scrolls inside its own box rather than pushing the page + // sideways — the panel column is narrow on a laptop. +
+ + + + + + + + {/* ── two doors, drawn apart on purpose ── */} + + + + {/* Customers. The only way in for the public, and it is an app store. */} + + + + {labels.customersNote} + + + {/* A rule, not a gap: the separation between the two doors is the point. */} + + + {/* Admin. A browser, but never a public one. */} + + + {/* The gate. Everything from the browser is authenticated by Cloudflare + before it is allowed to touch the origin at all. */} + + + + {/* Padlock, so the box reads as a gate at a glance rather than as one + more service in the chain. */} + + + + + + {labels.gate} + + + {labels.gateNote} + + + + {/* Customer traffic funnels; admin traffic drops out of the gate. Both + arrive at the same edge, which is the point of showing them together. */} + + + HTTPS + + + {/* ── the platform IS the runtime: one box, not proxy-then-app ── */} + + + DigitalOcean App Platform + + + {labels.platformNote} + + + + + + + {/* One deployable, so the joins are hairlines rather than arrows. */} + + + {/* ── data ── */} + + + + + + {/* ── external: someone else's uptime ── */} + {/* + A bus, not a single stalk. + + This line used to drop from between the two data boxes and stop in the + gap above the external row, joining nothing to nothing — and worse, it + started below Postgres and Redis, which said the DATABASE calls Twilio. + It does not. The application does. + + So it leaves the App Platform box down the right margin, clear of the + data row, then runs across as a bus with a stub into each service. All + five hang off the same caller, which is the true shape. + */} + + + {[ + { x: 0, w: 132, t: 'DO Spaces', s: 'S3' }, + { x: 148, w: 128, t: 'Mapbox', s: labels.geocode }, + { x: 292, w: 124, t: 'Twilio', s: 'SMS' }, + { x: 432, w: 128, t: 'Resend', s: labels.email }, + { x: 576, w: 144, t: 'Sentry', s: labels.errors }, + ].map((n) => ( + + ))} + + {/* Legend. Without it "why is that one dashed?" is a question the + picture raises and does not answer. */} + + + + {labels.ours} + + + + {labels.theirs} + + + +
+ ); +} diff --git a/apps/web/src/components/chrome/phone-frame.tsx b/apps/web/src/components/chrome/phone-frame.tsx index b1b7b93..1ced44b 100644 --- a/apps/web/src/components/chrome/phone-frame.tsx +++ b/apps/web/src/components/chrome/phone-frame.tsx @@ -40,7 +40,7 @@ export function PhoneFrame({ > {/* The screen. overflow-hidden is what makes a swiped card disappear at the bezel instead of flying across the page. */} -
+
{/* Dynamic Island. Pointer-events-none so it never eats a drag that starts near the top of the card. */}
; @@ -62,9 +63,9 @@ 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.', + es: 'Inicia sesión en el celular de al lado. El producto entero funciona ahí dentro.', }, - phone: { en: 'Mobile', es: 'Móvil' }, + phone: { en: 'Mobile', es: 'Celular' }, code: { en: 'Code', es: 'Código' }, } satisfies Record; @@ -86,7 +87,7 @@ const STACK: { group: Copy; items: string[] }[] = [ }, { group: { en: 'Services', es: 'Servicios' }, - items: ['DO Spaces (S3)', 'Mapbox', 'Twilio', 'Resend', 'Sentry'], + items: ['DO Spaces (S3)', 'Cloudflare Access', 'Mapbox', 'Twilio', 'Resend', 'Sentry'], }, { group: { en: 'Tooling', es: 'Herramientas' }, items: ['Turborepo', 'pnpm', 'Vitest'] }, ]; @@ -110,7 +111,7 @@ const FEATURES: { title: Copy; body: Copy; icon: typeof Zap }[] = [ title: { en: 'Verified pros', es: 'Profesionales verificados' }, body: { en: 'ID, insurance and licence checked first.', - es: 'Identidad, seguro y licencia comprobados antes de entrar.', + es: 'Verificamos identidad, seguro y licencia antes de que aparezca en la app.', }, }, { @@ -123,7 +124,7 @@ const FEATURES: { title: Copy; body: Copy; icon: typeof Zap }[] = [ 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.', + es: 'Se acuerda un precio, se reserva la cita y se confirma.', }, }, { @@ -165,7 +166,7 @@ const DECISIONS: { group: Copy; items: { q: Copy; today: Copy }[] }[] = [ }, today: { en: 'Set to 15% in config, applied nowhere.', - es: 'Fijada al 15% en la configuración, aplicada en ninguna parte.', + es: 'Está fijada al 15% en la configuración, pero no se aplica en ningún lado.', }, }, { @@ -220,7 +221,7 @@ const DECISIONS: { group: Copy; items: { q: Copy; today: Copy }[] }[] = [ }, 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.', + es: 'Espera indefinidamente. La regla de 72 horas está escrita, pero nada la ejecuta.', }, }, ], @@ -245,7 +246,7 @@ const DECISIONS: { group: Copy; items: { q: Copy; today: Copy }[] }[] = [ }, 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.', + es: 'En el modelo de datos existe un estado «en disputa», pero no hay forma de llegar a él.', }, }, { @@ -275,12 +276,12 @@ const DECISIONS: { group: Copy; items: { q: Copy; today: Copy }[] }[] = [ 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?', + en: 'How many trades do we open with — and who recruits the pros for them?', + es: '¿Con cuántos oficios abrimos, y quién recluta a los profesionales?', }, 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.', + en: 'Fifteen are live. The other thirty-five are hidden rather than listed empty, so a customer is never offered a trade nobody can do. Widening the list means recruiting the supply first.', + es: 'Quince están activos. Los otros treinta y cinco están ocultos en lugar de aparecer vacíos, así que a un cliente nunca se le ofrece un oficio que nadie puede hacer. Para ampliar la lista hay que conseguir profesionales primero.', }, }, { @@ -290,7 +291,7 @@ const DECISIONS: { group: Copy; items: { q: Copy; today: Copy }[] }[] = [ }, 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.', + es: 'Una. La ciudad es un ajuste, así que añadir una segunda es configurarla, no rehacer nada.', }, }, { @@ -300,7 +301,7 @@ const DECISIONS: { group: Copy; items: { q: Copy; today: Copy }[] }[] = [ }, 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.', + es: 'Al profesional se le avisa por SMS cuando entra un trabajo nuevo y cuando alguien contesta. Los mensajes y las reservas son silenciosos.', }, }, ], @@ -339,6 +340,19 @@ const HOSTING: { item: Copy; detail: Copy; usd: number }[] = [ const HOSTING_TOTAL = HOSTING.reduce((sum, h) => sum + h.usd, 0); +/** + * Money, and it has to say which money. + * + * Every figure on this panel is US dollars, and in Mexico — where this launches, + * where the seed data lives and where the demo number dials — "$" is the peso. + * "$59" read as pesos is roughly three dollars, so the Spanish column understated + * the entire running cost by a factor of seventeen to the only reader who + * matters. English keeps the bare symbol; a dollar sign in an English quote is + * not ambiguous to anybody. + */ +const money = (value: number | string, lang: Lang) => + lang === 'en' ? `$${value}` : `${value} USD`; + /** * The third-party services the stack above names but never costed. * @@ -353,7 +367,7 @@ const SERVICES: { item: Copy; free: Copy; then: Copy }[] = [ 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.', + es: 'No cobra mensualidad por la cuenta, pero cada código de acceso y cada aviso de trabajo cuesta unos centavos, y el número tiene renta mensual. El único que cuesta desde el primer día.', }, }, { @@ -361,7 +375,7 @@ const SERVICES: { item: Copy; free: Copy; then: Copy }[] = [ 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.', + es: 'Gratis hasta decenas de miles de búsquedas de direcciones al mes. El costo sube con las búsquedas, no con el número de clientes.', }, }, { @@ -369,7 +383,7 @@ const SERVICES: { item: Copy; free: Copy; then: Copy }[] = [ 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í.', + es: 'Gratis para los primeros miles de correos al mes, unos 20 USD a partir de ahí.', }, }, { @@ -377,7 +391,7 @@ const SERVICES: { item: Copy; free: Copy; then: Copy }[] = [ 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.', + es: 'El plan gratuito cubre el volumen de errores inicial; unos 26 USD cuando se queda corto. Opcional: reporta fallas, no ejecuta nada.', }, }, ]; @@ -392,39 +406,92 @@ const SERVICES: { item: Copy; free: Copy; then: Copy }[] = [ 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' }, + cost: { en: '$99 / year', es: '99 USD al 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.', + es: 'Sin ella no puedes publicar nada en la App Store. Se renueva cada año: si se vence, la app deja de estar disponible. Una cuenta de empresa necesita además un número D-U-N-S, que es gratis pero tarda un par de semanas, así que conviene solicitarlo pronto.', }, }, { item: { en: 'Google Play Console', es: 'Google Play Console' }, - cost: { en: '$25 once', es: '25 $ una vez' }, + cost: { en: '$25 once', es: '25 USD, pago único' }, detail: { en: 'One payment, for the life of the account. Review is faster and less strict than Apple’s.', - 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.', + es: 'Un solo pago que no se renueva. La revisión es más rápida y menos estricta que la de Apple.', }, }, ]; +/** + * The architecture note, as three paragraphs rather than one. + * + * It was a single 120-word block covering five unrelated things, written for + * somebody who already knows what terminating TLS means. The reader is a client + * deciding whether to fund this. Each paragraph now carries one idea: how people + * get in, who runs the servers, and what happens when a supplier breaks. + * + * The PostGIS sentence went entirely. "Real distance — PostGIS ranks by metres, + * not postcodes" is already in What it does, where it reads as a product promise + * instead of a database detail. + */ +const ARCH_BODY: Copy[] = [ + { + en: 'Two ways in, and only one of them is a browser. Customers only ever reach this as an installed app, so there is no public website — which is why trust has to come from the store listing and the verification badge rather than from a web address. The browser is for your staff: it opens the admin dashboard behind Cloudflare Access, where a person proves who they are before their request reaches the server.', + es: 'Dos formas de entrar, y solo una es un navegador. Los clientes llegan siempre por la app instalada, así que no hay una web pública: por eso la confianza tiene que venir de la ficha en la tienda y del sello de verificación, y no de una dirección web. El navegador es para tu equipo: abre el panel de administración detrás de Cloudflare Access, donde la persona demuestra quién es antes de que su petición llegue al servidor.', + }, + { + en: 'Nothing in the blue box is a server we look after. DigitalOcean runs it — certificates, domain, health checks, scaling — and the app, the API and sign-in live in one service instead of three.', + es: 'Nada de lo que hay en la caja azul es un servidor que mantengamos nosotros. Lo opera DigitalOcean — certificados, dominio, comprobaciones de estado y escalado — y la app, la API y el inicio de sesión viven en un solo servicio en lugar de tres.', + }, +]; + +const ARCH = { + customers: { en: 'Customers', es: 'Clientes' }, + customersNote: { + en: 'Mobile only — the product ships to the app stores and nowhere else.', + es: 'Solo móvil: el producto se publica en las tiendas de apps y en ningún otro sitio.', + }, + admin: { en: 'Admin dashboard', es: 'Panel de administración' }, + adminNote: { en: 'browser · staff only', es: 'navegador · solo para el equipo' }, + gate: { en: 'Cloudflare Access', es: 'Cloudflare Access' }, + gateNote: { + en: 'Zero Trust — identity checked before the request reaches us', + es: 'Zero Trust: identidad verificada antes de que la petición nos llegue', + }, + platform: { en: 'Platform', es: 'Plataforma' }, + platformNote: { + en: 'TLS · domain routing · health checks · scaling — all managed', + es: 'TLS · dominio · comprobaciones de estado · escalado, todo gestionado', + }, + app: { en: 'Application', es: 'Aplicación' }, + data: { en: 'Data', es: 'Datos' }, + external: { en: 'External', es: 'Externos' }, + ours: { en: 'we run it', es: 'lo operamos' }, + theirs: { en: 'someone else does', es: 'lo opera un tercero' }, + queues: { en: 'sessions · queues', es: 'sesiones · colas' }, + geocode: { en: 'geocode', es: 'geocodificación' }, + email: { en: 'email', es: 'correo' }, + errors: { en: 'errors', es: 'errores' }, +} satisfies Record; + 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.', + en: 'One codebase, two stores. The customer product is mobile only and ships to the App Store and Google Play wrapped in Capacitor — one thing to build and one thing to fix, rather than two native apps drifting apart.', + es: 'Un solo código, dos tiendas. El producto para clientes es solo móvil y se publica en la App Store y en Google Play empaquetado con Capacitor: una sola cosa que construir y una sola que arreglar, en lugar de dos apps nativas que acaban divergiendo.', }, + architecture: { en: 'Architecture', es: 'Arquitectura' }, 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' }, + cost: { en: 'Delivery and cost', es: 'Entrega y costo' }, 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.', + es: 'Pago único. Dónde caiga dentro del rango depende de las respuestas de arriba.', }, timeline: { en: 'Timeline', es: 'Plazo' }, timelineValue: { en: '6–10 weeks', es: '6–10 semanas' }, @@ -435,15 +502,15 @@ const SECTIONS = { 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' }, + services: { en: 'Services it calls', es: 'Servicios que usa' }, 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.', + es: 'Cuentas separadas, facturas separadas, todas a tu nombre. Ninguno de estos servicios lo cobramos nosotros ni le agregamos 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.', + es: 'La app se publica desde tus cuentas de desarrollador, no desde 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: moverla después implica crear una ficha nueva y perder sus reseñas y su posición.', }, } satisfies Record; @@ -453,23 +520,23 @@ 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.', + es: ' No es parte de lo que nos pagas a nosotros ni lleva recargo: la cuenta es tuya, así que ves la factura y cambias de plan sin pasar por nosotros.', }, }, { lead: { en: 'DigitalOcean is the quote, not the requirement.', - es: 'DigitalOcean es el presupuesto, no el requisito.', + es: 'Cotizamos DigitalOcean, pero no es obligatorio.', }, 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.', + es: ' Esto es un contenedor y una base de datos Postgres, así que corre igual de bien en AWS, Google Cloud, Hetzner o donde ya tengas cuenta: solo cambian las cifras de arriba. Cotizamos DigitalOcean porque es la opción administrada más barata a esta escala y su factura se entiende de un vistazo.', }, }, { lead: { en: `$${HOSTING_TOTAL} is the smallest tier of each.`, - es: `${HOSTING_TOTAL} $ es el plan más pequeño de cada uno.`, + es: `${HOSTING_TOTAL} USD es la suma de los planes más pequeños de cada servicio.`, }, rest: { en: ' Enough to launch on and to run while the platform is finding its first customers.', @@ -479,11 +546,11 @@ const HOSTING_NOTES: { lead: Copy; rest: Copy }[] = [ { lead: { en: 'Costs rise with use, unevenly.', - es: 'Los costes suben con el uso, de forma desigual.', + es: 'Los costos suben con el uso, y no de forma pareja.', }, 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.', + es: ' La base de datos es lo primero que va a necesitar un plan más grande; el almacenamiento crece despacio conforme se acumulan fotos; la aplicación puede seguir en el mismo plan mucho tiempo.', }, }, ]; @@ -491,6 +558,7 @@ const HOSTING_NOTES: { lead: Copy; rest: Copy }[] = [ export function ProjectPanel() { const [lang, setLang] = useState('en'); const t = (copy: Copy) => copy[lang]; + const m = (value: number | string) => money(value, lang); return ( // `lang` on the wrapper, not only in state: it is what tells a screen reader @@ -596,6 +664,36 @@ export function ProjectPanel() {
+
+

{t(SECTIONS.architecture)}

+
+ {ARCH_BODY.map((para) => ( +

{t(para)}

+ ))} +
+ +
+

{t(SECTIONS.decisions)}

{t(SECTIONS.decisionsBody)}

@@ -630,7 +728,9 @@ export function ProjectPanel() {

{t(SECTIONS.build)}

-

$4,400–6,000

+

+ {m('4,400–6,000')} +

{t(SECTIONS.buildNote)}

@@ -648,13 +748,13 @@ export function ProjectPanel() {
  • {t(h.item)} {t(h.detail)} - ${h.usd} + {m(h.usd)}
  • ))}
  • {t(SECTIONS.total)} - ${HOSTING_TOTAL} + {m(HOSTING_TOTAL)} {t(SECTIONS.perMonth)}
  • diff --git a/apps/web/src/components/deck.tsx b/apps/web/src/components/deck.tsx index 62459eb..899cad1 100644 --- a/apps/web/src/components/deck.tsx +++ b/apps/web/src/components/deck.tsx @@ -6,6 +6,14 @@ import { Check, Eye, MapPin, MessageCircle, Star, Undo2, X } from 'lucide-react' import type { DeckCard } from '@linkdr/db'; import { cn, formatDistance, formatResponseTime } from '@/lib/utils'; +/** + * How far a pointer may travel and still count as a tap rather than a swipe. + * Ten pixels is roughly the wobble of a thumb pressing a phone screen. + */ +const TAP_SLOP_PX = 10; +/** And how long. Longer than this is a press, which is not a tap either. */ +const TAP_MAX_MS = 500; + /** Horizontal drag past this many pixels commits the swipe. */ const COMMIT_PX = 110; @@ -42,6 +50,8 @@ export interface DeckProps { rewindSignal?: number; onWatch?: (proId: string) => void; onAsk?: (proId: string) => void; + /** Tapping the card — as opposed to swiping it — opens the full profile. */ + onOpenProfile?: (proId: string) => void; /** Which pros the viewer already watches, for the filled state. */ watchedProIds?: ReadonlySet; } @@ -53,6 +63,7 @@ export function Deck({ rewindSignal = 0, onWatch, onAsk, + onOpenProfile, watchedProIds, }: DeckProps) { const [index, setIndex] = useState(0); @@ -111,6 +122,9 @@ export function Deck({ card={card} depth={i} onDecide={i === 0 ? decide : undefined} + // Only the top card. The ones behind are pointer-events-none + // anyway, and a tap must never open somebody you cannot see. + onOpenProfile={i === 0 ? onOpenProfile : undefined} /> )) .reverse()} @@ -175,13 +189,24 @@ export function Card({ card, depth = 0, onDecide, + onOpenProfile, }: { card: DeckCard; depth?: number; onDecide?: (proId: string, direction: 'left' | 'right') => void; + onOpenProfile?: (proId: string) => void; }) { const x = useMotionValue(0); const rotate = useTransform(x, [-300, 0, 300], [-14, 0, 14]); + /** + * Where the pointer went down, so a tap can be told apart from a swipe. + * + * A plain onClick fires at the end of a drag too, so every swipe would also + * open the profile. Framer reports the gesture's offset on drag end, but not + * on a press that never became a drag — hence tracking it here rather than + * leaning on `onDragEnd`. + */ + const pressedAt = useRef<{ x: number; y: number; t: number } | null>(null); const hireOpacity = useTransform(x, [40, COMMIT_PX], [0, 1]); const passOpacity = useTransform(x, [-COMMIT_PX, -40], [1, 0]); @@ -210,6 +235,24 @@ export function Card({ if (info.offset.x > COMMIT_PX) onDecide(card.proId, 'right'); else if (info.offset.x < -COMMIT_PX) onDecide(card.proId, 'left'); }} + onPointerDown={(e) => { + pressedAt.current = { x: e.clientX, y: e.clientY, t: Date.now() }; + }} + onPointerUp={(e) => { + const from = pressedAt.current; + pressedAt.current = null; + if (!from || !onOpenProfile) return; + // A tap, not a swipe and not a press-and-hold: within TAP_SLOP_PX in + // both axes and over quickly. Vertical movement counts too — the deck + // scrolls under some layouts and a flick past the card is not a tap. + const moved = Math.hypot(e.clientX - from.x, e.clientY - from.y); + if (moved <= TAP_SLOP_PX && Date.now() - from.t <= TAP_MAX_MS) { + onOpenProfile(card.proId); + } + }} + onPointerCancel={() => { + pressedAt.current = null; + }} > {/* eslint-disable-next-line @next/next/no-img-element */} { it("honours the searcher's own distance limit", async () => { const near = await searchPros(db, { ...CENTRE, maxDistanceM: 3_000, limit: 50 }); expect(near.every((p) => p.distanceM <= 3_000)).toBe(true); - expect(near.map((p) => p.name)).not.toContain('Marta Villanueva'); // 9.1 km out + expect(near.map((p) => p.name)).not.toContain('Mario Villanueva'); // 9.1 km out }); it('sorts by distance, price and rating', async () => { diff --git a/packages/db/test/showcase.test.ts b/packages/db/test/showcase.test.ts index 6fe3d29..31ee9a2 100644 --- a/packages/db/test/showcase.test.ts +++ b/packages/db/test/showcase.test.ts @@ -66,12 +66,12 @@ describe('getShowcaseDeck', () => { }); it('honours the searcher own range, not just the pro one', async () => { - // Marta Villanueva is seeded 9.1 km out with a 30 km radius: she would travel + // Mario Villanueva is seeded 9.1 km out with a 30 km radius: they would travel // here happily, but someone who said "within 3 km" did not ask for her. const near = await getShowcaseDeck(db, { ...CENTRE, maxDistanceM: 3_000, limit: 100 }); const nearNames = near.map((c) => c.name); - expect(nearNames).not.toContain('Marta Villanueva'); + expect(nearNames).not.toContain('Mario Villanueva'); expect(nearNames).toContain('Sergio Fabela'); // 1.1 km away expect(near.every((c) => c.distanceM <= 3_000)).toBe(true); });