Panel: an architecture diagram, store costs, and Spanish that reads as Spanish

The right-hand panel now answers the question the stack list provoked — what
does a client actually install? Capacitor, iOS and Android lead "Built with",
and a new SVG diagram shows the two ways in: customers through the installed
app, staff through the admin dashboard behind Cloudflare Access.

Costs were half-quoted. The stack named Mapbox, Twilio, Resend and Sentry and
priced none of them, so each now states the shape of its bill rather than a
figure that would be wrong at every scale. App store accounts are listed too,
in the client's own name for the same reason the hosting is.

Currency was the serious one. Every figure is US dollars and the panel wrote
them as "$59", but this launches in Mexico, where "$" is the peso — the Spanish
column understated the running cost roughly seventeenfold to the only reader
who matters. money() now prints "$59" in English and "59 USD" in Spanish.

The rest of the Spanish was a literal translation of dense English: peninsular
vocabulary in a Mexican market, two stray vosotros forms among the tuteo, and
calques that were not grammatical — "aplicada en ninguna parte", "todo lo
discontinuo" for dashed lines. Reviewed all 126 strings. The architecture note
was 120 words in one block covering five topics; it is now two paragraphs, one
idea each, and the PostGIS sentence went because "What it does" already makes
that promise in language a client can act on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
serfa
2026-08-23 14:57:05 -04:00
co-authored by Claude Opus 5
parent 0192585727
commit cb148c3dc8
17 changed files with 894 additions and 73 deletions
+55
View File
@@ -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) => (
<Chip
key={c.id}
size="compact"
selected={categoryId === c.id}
onClick={() => setCategoryId(c.id)}
>
@@ -206,6 +254,13 @@ export function NewJobForm({
<Button type="submit" size="lg" block busy={create.isPending} disabled={!categoryId}>
Find me a pro
</Button>
{/* 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 && (
<p className="mt-3 text-center text-meta text-faint">
You will sign in on the next step. Nothing you have typed is lost.
</p>
)}
</StickyAction>
</form>
);
+16 -5
View File
@@ -14,13 +14,24 @@ export default async function NewJobPage({
}) {
const api = await getApi();
let me: Awaited<ReturnType<typeof api.user.me>>;
/*
* 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<ReturnType<typeof api.user.me>> | 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 (
<AppShell title="Post a job">
<AppShell title="Post a job" back={{ href: '/', label: 'Back' }}>
<ScreenIntro>
Describe it once. We will show you verified pros nearby who can take it on.
</ScreenIntro>
<NewJobForm categories={categories} sendTo={sendTo} />
<NewJobForm categories={categories} sendTo={sendTo} signedIn={signedIn} />
</AppShell>
);
}
+1 -1
View File
@@ -33,7 +33,7 @@ export default async function ProOnboardingPage() {
}
return (
<AppShell title="Set up your profile">
<AppShell title="Set up your profile" back={{ href: '/', label: 'Back' }}>
<ScreenIntro>
We check every pro&rsquo;s ID, licence and insurance before any customer sees them. It
usually takes a day.
+1 -1
View File
@@ -23,7 +23,7 @@ export default async function ProHomePage() {
const status = profile.verificationStatus;
return (
<AppShell title="Your account">
<AppShell title="Your account" back={{ href: '/', label: 'Back' }}>
<ScreenIntro>{profile.headline}</ScreenIntro>
{status === 'pending' && (
+86 -6
View File
@@ -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<string | null>(null);
const [tab, setTab] = useState<PhoneTab>(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<DeckCard | null>(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<DeckCard | null>(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<Map<string, boolean>>(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' ? (
<JobsPanel state={jobs} onChange={setJobs} />
) : 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. */
<ProProfilePanel
pro={viewing}
onBack={() => 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) => {
+32 -8
View File
@@ -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({
<main
className={cn(
'h-full overflow-y-auto bg-page',
'px-5 pt-8 pb-[calc(2rem+env(safe-area-inset-bottom))]',
// Less top padding when there is a back link: it brings its own row.
back
? 'px-5 pt-[calc(0.5rem+env(safe-area-inset-top))] pb-[calc(2rem+env(safe-area-inset-bottom))]'
: 'px-5 pt-8 pb-[calc(2rem+env(safe-area-inset-bottom))]',
className,
)}
>
<h1 className="mb-6 text-h1">{title}</h1>
{back && <BackLink href={back.href} label={back.label} />}
<h1 className={cn('text-h1', back ? 'mb-6 mt-2' : 'mb-6')}>{title}</h1>
{children}
</main>
);
@@ -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 (
<main className="relative flex h-full flex-col justify-center overflow-y-auto bg-page px-5 py-10">
<main className="flex h-full flex-col overflow-y-auto bg-page px-5 pb-10 pt-[calc(0.5rem+env(safe-area-inset-top))]">
{back && (
<div className="absolute left-5 top-[calc(0.5rem+env(safe-area-inset-top))] z-10">
<div className="shrink-0">
<BackLink />
</div>
)}
{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. */}
<div className="flex min-h-0 flex-1 flex-col justify-center">{children}</div>
</main>
);
}
@@ -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 (
<g>
<rect
x={x}
y={y}
width={w}
height={h}
rx="12"
fill={fill}
stroke={stroke}
strokeWidth="1"
strokeDasharray={dashed ? '4 4' : undefined}
/>
<text
x={x + w / 2}
y={sub ? y + h / 2 - 4 : y + h / 2 + 4}
textAnchor="middle"
fill={titleFill}
fontSize="12.5"
fontWeight="600"
>
{title}
</text>
{sub && (
<text
x={x + w / 2}
y={y + h / 2 + 13}
textAnchor="middle"
fill={subFill}
fontSize="10.5"
fontWeight="500"
>
{sub}
</text>
)}
</g>
);
}
/** A band heading — the word down the left-hand margin. */
function Lane({ x = 0, y, label }: { x?: number; y: number; label: string }) {
return (
<text x={x} y={y} fill="var(--text-faint)" fontSize="9.5" fontWeight="700" letterSpacing="1.2">
{label.toUpperCase()}
</text>
);
}
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.
<div className="-mx-1 overflow-x-auto px-1 pb-1">
<svg
viewBox="0 0 720 534"
className="h-auto w-full min-w-[580px]"
role="img"
aria-label={`${labels.customers} (${labels.customersNote}) and ${labels.admin} behind ${labels.gate}, both reaching ${labels.platform}, then ${labels.app}, then ${labels.data}, plus ${labels.external}.`}
>
<defs>
<marker
id="arch-arrow"
viewBox="0 0 8 8"
refX="7"
refY="4"
markerWidth="6"
markerHeight="6"
orient="auto-start-reverse"
>
<path d="M0,0 L8,4 L0,8 z" fill="var(--hairline)" />
</marker>
</defs>
{/* ── two doors, drawn apart on purpose ── */}
<Lane y={14} label={labels.customers} />
<Lane x={430} y={14} label={labels.admin} />
{/* Customers. The only way in for the public, and it is an app store. */}
<Box x={0} y={26} w={190} h={56} title="iOS" sub="Capacitor" />
<Box x={206} y={26} w={190} h={56} title="Android" sub="Capacitor" />
<text x="0" y={100} fill="var(--text-faint)" fontSize="10" fontWeight="500">
{labels.customersNote}
</text>
{/* A rule, not a gap: the separation between the two doors is the point. */}
<path d="M418,20 V214" stroke="var(--hairline)" strokeWidth="1" strokeDasharray="3 5" />
{/* Admin. A browser, but never a public one. */}
<Box x={442} y={26} w={278} h={56} title={labels.admin} sub={labels.adminNote} />
{/* The gate. Everything from the browser is authenticated by Cloudflare
before it is allowed to touch the origin at all. */}
<path
d="M581,82 V106"
fill="none"
stroke="var(--hairline)"
strokeWidth="1.5"
markerEnd="url(#arch-arrow)"
/>
<g>
<rect
x="442"
y="106"
width="278"
height="58"
rx="12"
fill="var(--accent-soft)"
stroke="var(--accent)"
strokeWidth="1.5"
/>
{/* Padlock, so the box reads as a gate at a glance rather than as one
more service in the chain. */}
<g transform="translate(468, 126)" fill="none" stroke="var(--accent)" strokeWidth="1.6">
<rect x="0" y="6" width="13" height="10" rx="2.5" />
<path d="M2.8,6 V3.6 a3.7,3.7 0 0 1 7.4,0 V6" />
</g>
<text x="492" y="132" fill="var(--text-strong)" fontSize="12.5" fontWeight="600">
{labels.gate}
</text>
<text x="492" y="147" fill="var(--text-faint)" fontSize="10.5" fontWeight="500">
{labels.gateNote}
</text>
</g>
{/* Customer traffic funnels; admin traffic drops out of the gate. Both
arrive at the same edge, which is the point of showing them together. */}
<path
d="M95,82 V190 H300 M301,82 V190 M581,164 V190 H300 M300,190 V214"
fill="none"
stroke="var(--hairline)"
strokeWidth="1.5"
markerEnd="url(#arch-arrow)"
/>
<text x="112" y="182" fill="var(--text-faint)" fontSize="10" fontWeight="600">
HTTPS
</text>
{/* ── the platform IS the runtime: one box, not proxy-then-app ── */}
<rect
x="0"
y="214"
width="720"
height="124"
rx="16"
fill="var(--surface-inset)"
stroke="var(--accent)"
strokeWidth="1.5"
/>
<text x="18" y="240" fill="var(--text-strong)" fontSize="12.5" fontWeight="700">
DigitalOcean App Platform
</text>
<text x="18" y="256" fill="var(--text-faint)" fontSize="10.5" fontWeight="500">
{labels.platformNote}
</text>
<Lane x={556} y={240} label={labels.app} />
<Box x={16} y={264} w={214} h={58} title="Next.js 15" sub="React 19 · SSR" tone="accent" />
<Box x={252} y={264} w={214} h={58} title="tRPC v11" sub="Zod · superjson" tone="accent" />
<Box x={488} y={264} w={216} h={58} title="better-auth" sub="OTP · OAuth" tone="accent" />
{/* One deployable, so the joins are hairlines rather than arrows. */}
<path d="M230,293 H252 M466,293 H488" stroke="var(--hairline)" strokeWidth="1.5" />
{/* ── data ── */}
<path
d="M200,338 V376 M520,338 V376"
fill="none"
stroke="var(--hairline)"
strokeWidth="1.5"
markerEnd="url(#arch-arrow)"
/>
<Lane y={394} label={labels.data} />
<Box x={60} y={376} w={280} h={58} title="DO Managed Postgres" sub="PostGIS · Drizzle ORM" />
<Box x={380} y={376} w={280} h={58} title="DO Managed Redis" sub={labels.queues} />
{/* ── 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.
*/}
<path
d="M690,338 V448 M66,448 H690 M66,448 V462 M212,448 V462 M354,448 V462 M496,448 V462 M648,448 V462"
fill="none"
stroke="var(--hairline)"
strokeWidth="1.5"
strokeDasharray="4 4"
/>
<Lane y={480} label={labels.external} />
{[
{ 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) => (
<Box key={n.t} x={n.x} y={462} w={n.w} h={52} title={n.t} sub={n.s} dashed />
))}
{/* Legend. Without it "why is that one dashed?" is a question the
picture raises and does not answer. */}
<g transform="translate(0, 530)">
<rect
x="0"
y="-8"
width="14"
height="10"
rx="3"
fill="var(--surface-raised)"
stroke="var(--hairline)"
/>
<text x="21" y="1" fill="var(--text-faint)" fontSize="10" fontWeight="500">
{labels.ours}
</text>
<rect
x={labels.ours.length * 5.4 + 34}
y="-8"
width="14"
height="10"
rx="3"
fill="var(--surface-page)"
stroke="var(--hairline)"
strokeDasharray="3 3"
/>
<text
x={labels.ours.length * 5.4 + 55}
y="1"
fill="var(--text-faint)"
fontSize="10"
fontWeight="500"
>
{labels.theirs}
</text>
</g>
</svg>
</div>
);
}
@@ -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. */}
<div className="relative h-full w-full overflow-hidden bg-page sm:rounded-[2.6rem]">
<div className="phone-screen relative h-full w-full overflow-hidden bg-page sm:rounded-[2.6rem]">
{/* Dynamic Island. Pointer-events-none so it never eats a drag that
starts near the top of the card. */}
<div
+140 -40
View File
@@ -12,6 +12,7 @@ import {
Zap,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { ArchitectureDiagram } from './architecture-diagram';
/**
* The right-hand column: what the app in the phone beside it actually is.
@@ -53,8 +54,8 @@ const INTRO = {
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.',
en: 'A mobile marketplace connecting customers with verified local trades. Post a job, swipe through pros who cover your area, 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 zona, acuerda un precio en el chat, reserva la cita y valora al profesional cuando termine.',
},
} satisfies Record<string, Copy>;
@@ -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<string, Copy>;
@@ -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 Apples.',
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<string, Copy>;
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: '610 weeks', es: '610 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<string, Copy>;
@@ -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<Lang>('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() {
</div>
</section>
<section>
<h2 className="mb-1 text-h3">{t(SECTIONS.architecture)}</h2>
<div className="mb-5 flex max-w-[56ch] flex-col gap-3 text-body-sm text-muted">
{ARCH_BODY.map((para) => (
<p key={para.en}>{t(para)}</p>
))}
</div>
<ArchitectureDiagram
labels={{
customers: t(ARCH.customers),
customersNote: t(ARCH.customersNote),
admin: t(ARCH.admin),
adminNote: t(ARCH.adminNote),
gate: t(ARCH.gate),
gateNote: t(ARCH.gateNote),
platform: t(ARCH.platform),
platformNote: t(ARCH.platformNote),
app: t(ARCH.app),
data: t(ARCH.data),
external: t(ARCH.external),
ours: t(ARCH.ours),
theirs: t(ARCH.theirs),
queues: t(ARCH.queues),
geocode: t(ARCH.geocode),
email: t(ARCH.email),
errors: t(ARCH.errors),
}}
/>
</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>
@@ -630,7 +728,9 @@ export function ProjectPanel() {
<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 font-display text-h2 text-strong tabular-nums">
{m('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">
@@ -648,13 +748,13 @@ export function ProjectPanel() {
<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>
<span className="shrink-0 text-strong tabular-nums">{m(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}
{m(HOSTING_TOTAL)}
{t(SECTIONS.perMonth)}
</span>
</li>
+43
View File
@@ -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<string>;
}
@@ -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 */}
<img
+12 -3
View File
@@ -19,8 +19,12 @@ export function Chip({
* 'sm' is for dense horizontal strips — the trade filter has to fit four
* trades across a 390px phone before the fifth is cut off as a scroll hint.
* 'md' stays the default everywhere a chip is a primary choice.
* 'compact' sits between them, for a WRAPPING list that is a primary choice
* but too long to spend full-size rows on: fifteen trades at 'md' fill the
* whole job form before the description field is reachable. Roughly a third
* shorter than 'md' and still on the 8px grid.
*/
size?: 'sm' | 'md';
size?: 'sm' | 'compact' | 'md';
}) {
return (
<button
@@ -29,9 +33,14 @@ export function Chip({
{...props}
className={cn(
'inline-flex items-center rounded-pill',
size === 'sm' ? 'border' : 'border-[1.5px]',
size === 'md' ? 'border-[1.5px]' : 'border',
'transition-[color,background-color,border-color] duration-[120ms] ease-standard',
size === 'sm' ? 'gap-1 px-2.5 py-1 text-[0.6875rem] leading-tight' : 'gap-1.5 px-4 py-3 text-body-sm',
size === 'sm' && 'gap-1 px-2.5 py-1 text-[0.6875rem] leading-tight',
// An arbitrary length, not `text-meta`: tailwind-merge cannot tell a
// custom font-size token from a colour token, so `text-strong` below
// silently won and the chip kept body size. `sm` avoids it the same way.
size === 'compact' && 'gap-1.5 px-3 py-2 text-[0.75rem] font-medium leading-[1.45]',
size === 'md' && 'gap-1.5 px-4 py-3 text-body-sm',
'disabled:pointer-events-none disabled:opacity-45',
selected
? 'border-brand-500 bg-brand-100 font-semibold text-ink-950'
+73
View File
@@ -0,0 +1,73 @@
'use client';
import type { AddressValue } from '@/components/ui';
/**
* A job someone filled in before they had an account.
*
* Posting used to demand a sign-in before the form was even visible, which asks
* a stranger to open an account for a product they have not seen do anything.
* The form comes first now — and the moment it does, this becomes necessary:
* the sign-in round trip would otherwise throw away everything they typed, and
* nobody types a job description twice.
*
* `sessionStorage`, matching pending-hire.ts and for the same two reasons. A URL
* param would put "my boiler is leaking and I am home alone until Friday" in the
* address bar; `localStorage` would resurrect a draft from last Tuesday as
* though it were still wanted.
*
* Nothing downstream treats this as load-bearing — private mode disables it and
* the flow still works, it just cannot resume.
*/
const KEY = 'linkdr:pending-job';
export interface PendingJob {
categoryId: string;
title: string;
description: string;
urgency: string;
address: AddressValue;
budgetMin: string;
budgetMax: string;
/** A pro this was being posted for, so the send survives the round trip too. */
sendTo?: string | null;
}
export function setPendingJob(draft: PendingJob): void {
try {
sessionStorage.setItem(KEY, JSON.stringify(draft));
} catch {
// Storage disabled. The draft is lost, not the flow.
}
}
export function readPendingJob(): PendingJob | null {
try {
const raw = sessionStorage.getItem(KEY);
if (!raw) return null;
const parsed: unknown = JSON.parse(raw);
// Shape-check the fields the form actually reads back. A half-written or
// hand-edited entry must not be able to put `undefined` into a controlled
// input, which is how a form silently becomes uncontrolled mid-session.
if (
typeof parsed === 'object' &&
parsed !== null &&
typeof (parsed as PendingJob).title === 'string' &&
typeof (parsed as PendingJob).description === 'string' &&
typeof (parsed as PendingJob).categoryId === 'string'
) {
return parsed as PendingJob;
}
return null;
} catch {
return null;
}
}
export function clearPendingJob(): void {
try {
sessionStorage.removeItem(KEY);
} catch {
/* nothing to clear if it could never be written */
}
}
+87
View File
@@ -227,6 +227,93 @@
color: var(--color-ink-950);
}
/*
* Scrollbars.
*
* The default one is a chunky grey slab drawn hard against the right edge of
* its scroll container. Inside the phone frame that edge is a 2.6rem rounded
* corner, so the bar visibly rides over the bezel and reads as the app
* leaking out of the device.
*
* Firefox gets the two properties it supports. WebKit gets the real fix: a
* 10px-wide track whose thumb is only 6px of that, the difference painted as
* a transparent border. That inset is what keeps the thumb clear of the
* rounded corner, and `background-clip: padding-box` is what stops the
* border from being filled in with the thumb colour.
*
* Accent blue, per §2.1 — a scrollbar IS a control, so blue is correct here
* rather than decorative. Held at 55% so a long page does not read as a
* bright stripe down one side, and brought to full strength on hover.
*/
* {
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--accent) 55%, transparent) transparent;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: color-mix(in srgb, var(--accent) 55%, transparent);
border: 2px solid transparent;
background-clip: padding-box;
border-radius: var(--radius-pill);
}
::-webkit-scrollbar-thumb:hover {
background-color: var(--accent);
}
/* A corner square in the default grey undoes all of the above. */
::-webkit-scrollbar-corner {
background: transparent;
}
/*
* Inside the phone, the same bar — inset so it cannot touch the bezel.
*
* The problem was never the colour, it was the geometry: the scroll container
* runs edge to edge, so the bar is painted hard against a 2.6rem rounded
* corner and reads as sitting on the frame.
*
* Two insets fix it, and both are needed. A 4px transparent border on the
* thumb holds it clear of the right edge (background-clip keeps the border
* from being filled in). And a vertical margin on the TRACK stops the bar
* ever reaching the top and bottom curves, which is the part a narrower thumb
* alone could not solve.
*/
.phone-screen *::-webkit-scrollbar {
width: 12px;
}
.phone-screen *::-webkit-scrollbar-track {
background: transparent;
margin: 16px 0;
}
.phone-screen *::-webkit-scrollbar-thumb {
background-color: color-mix(in srgb, var(--accent) 70%, transparent);
border: 4px solid transparent;
background-clip: padding-box;
border-radius: var(--radius-pill);
}
.phone-screen *::-webkit-scrollbar-thumb:hover {
background-color: var(--accent);
}
/* Firefox cannot inset a scrollbar, so it gets the thin one and the colour. */
.phone-screen * {
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--accent) 70%, transparent) transparent;
}
::placeholder {
color: var(--text-faint);
}