Entry screen is the app: phone frame with a live deck inside

The homepage was a marketing brochure -- a hero paragraph, a "three
steps" explainer and a tag list. It described the gesture in prose
while the actual Tinder deck sat behind /deck/[jobId], reachable only
after signing in AND posting a job. Nobody opening the app ever saw
the product.

Now / renders a phone illustration with the real app running inside
it: the same <Deck>, the same drag physics, real verified pros. On a
phone the bezel collapses and the deck simply fills the viewport --
drawing a picture of a phone on a phone is absurd, and it would eat
the width the cards need.

- Removed the fixed app bar and bottom tab bar. AppShell now renders
  the screen title as an in-flow h1; the bar owned the only h1 on
  every screen, so dropping it silently would have left every page
  headingless. The /jobs "post" action moved from bar chrome into the
  content, since the tab bar was its only other route there.

- getShowcaseDeck(): a deck with no job behind it. getDeck is
  job-scoped (joins jobs for category and location, anti-joins
  swipes), which an anonymous visitor has none of, so this centres on
  the launch city. Eligibility rules are copied verbatim -- nobody may
  appear in the shop window who could not appear on a real deck.

- deck.showcase: the only public procedure in the router. list and
  swipe stay behind clientProcedure. It reads nothing about the caller
  and writes nothing, so a right swipe on the entry screen is purely
  local. No real tradesperson is contacted until a job is posted.

- <Deck> filled a hardcoded 560px desktop box; it now fills its
  container. The card counter moved out from between the two action
  buttons so the thumb zone holds nothing but the two controls.

Verified against the seeded database: 22 eligible pros returned, and
all three seeded traps excluded for the right reason -- Pau Ribas
(22km out, 5km radius), Unverified Ulla (pending), Away Arnau (not
accepting). 7 new integration tests cover exactly that.

Also corrects a label I had written as "Plumbers near you" -- the
showcase deck is not category-filtered and shows every trade.

Includes concurrent edits to the mobile shell, ui/ primitives and
DESIGN.md made outside this session.

typecheck, lint, build clean; 141 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
serfowi
2026-08-21 01:38:00 -04:00
co-authored by Claude Opus 5
parent c617bc9687
commit 582f13fa99
28 changed files with 1219 additions and 571 deletions
+92 -135
View File
@@ -12,6 +12,18 @@ import {
} from '@linkder/shared';
import { cn } from '@/lib/utils';
import { uploadFile } from '@/lib/upload';
import {
Button,
Chip,
Field,
FieldNote,
FieldSet,
FormError,
Input,
Section,
StickyAction,
Textarea,
} from '@/components/ui';
type Categories = RouterOutputs['job']['categories'];
type Profile = RouterOutputs['pro']['me'];
@@ -90,21 +102,22 @@ export function OnboardingWizard({
}
return (
<div className="mt-8">
<ol className="mb-8 flex gap-2" aria-label="Progress">
<div>
<ol className="mb-10 flex gap-2" aria-label="Progress">
{STEPS.map((label, i) => (
<li key={label} className="flex-1">
<div
className={cn(
'h-1 rounded-full',
i <= step ? 'bg-[var(--color-brand-500)]' : 'bg-[var(--border)]',
'h-1 rounded-pill transition-colors duration-[200ms] ease-standard',
i <= step ? 'bg-brand-500' : 'bg-inset',
)}
/>
<span
className={cn(
'mt-1.5 block text-xs',
i === step ? 'font-medium' : 'text-[var(--muted)]',
'mt-2 block text-meta',
i === step ? 'font-semibold text-strong' : 'text-muted',
)}
aria-current={i === step ? 'step' : undefined}
>
{label}
</span>
@@ -121,24 +134,17 @@ export function OnboardingWizard({
{categories.map((c) => {
const selected = categoryIds.includes(c.id);
return (
<button
<Chip
key={c.id}
type="button"
selected={selected}
onClick={() =>
setCategoryIds((prev) =>
selected ? prev.filter((id) => id !== c.id) : [...prev, c.id].slice(0, 5),
)
}
className={cn(
'rounded-full border px-4 py-2 text-sm transition',
selected
? 'border-[var(--color-brand-500)] bg-[var(--color-brand-500)]/10 font-medium'
: 'border-[var(--border)]',
)}
>
{selected && <Check className="mr-1 inline h-3.5 w-3.5" aria-hidden />}
{c.name}
</button>
</Chip>
);
})}
</div>
@@ -155,41 +161,40 @@ export function OnboardingWizard({
{step === 1 && (
<Section title="About you" hint="This is what a customer reads on your card.">
<Field label="Headline" hint="e.g. Emergency plumber, 15 years in Barcelona">
<input
<Input
value={headline}
onChange={(e) => setHeadline(e.target.value)}
maxLength={100}
className={inputClass}
/>
</Field>
<Field label="About your work">
<textarea
<Textarea
value={bio}
onChange={(e) => setBio(e.target.value)}
rows={5}
maxLength={2000}
className={inputClass}
/>
<span className="text-xs text-[var(--muted)]">{bio.length}/2000, minimum 30</span>
<FieldNote>{bio.length}/2000, minimum 30</FieldNote>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Hourly rate (€)">
<input
<Input
value={hourlyRate}
onChange={(e) => setHourlyRate(e.target.value.replace(/[^0-9.]/g, ''))}
inputMode="decimal"
className={inputClass}
/>
</Field>
<Field label="Years experience">
<input
<Input
value={yearsExperience}
onChange={(e) => setYearsExperience(e.target.value.replace(/\D/g, ''))}
inputMode="numeric"
className={inputClass}
/>
</Field>
</div>
<Field
label={`How far will you travel? ${radiusKm} km`}
hint="You will only be shown jobs inside this radius."
@@ -200,22 +205,25 @@ export function OnboardingWizard({
max={MAX_SERVICE_RADIUS_M / 1000}
value={radiusKm}
onChange={(e) => setRadiusKm(Number(e.target.value))}
className="w-full"
className="w-full accent-brand-500"
/>
</Field>
<button
<Button
type="button"
variant="ghost"
size="sm"
className="self-start"
onClick={() =>
navigator.geolocation?.getCurrentPosition(
(pos) =>
setLocation({ lat: pos.coords.latitude, lng: pos.coords.longitude }),
(pos) => setLocation({ lat: pos.coords.latitude, lng: pos.coords.longitude }),
() => setError('We could not get your location. The city centre will be used.'),
)
}
className="text-sm text-[var(--color-brand-500)] underline underline-offset-4"
>
Use my current location as my base
</button>
</Button>
<Nav
onBack={() => setStep(0)}
busy={upsert.isPending}
@@ -251,18 +259,18 @@ export function OnboardingWizard({
hint="We check these by hand before your profile goes live. They are never shown to customers."
>
{!hasContactableEmail && (
<Field
label="Email address"
hint="Required for pros — payout statements, tax records and dispute notices go here."
>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className={inputClass}
/>
<button
<div className="flex flex-col gap-2">
<Field
label="Email address"
hint="Required for pros — payout statements, tax records and dispute notices go here."
>
<Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
</Field>
<Button
type="button"
variant="ghost"
size="sm"
className="self-start"
onClick={async () => {
try {
await setEmailMutation.mutateAsync({ email });
@@ -272,15 +280,14 @@ export function OnboardingWizard({
setError((e as Error).message);
}
}}
className="mt-2 text-sm text-[var(--color-brand-500)] underline underline-offset-4"
>
Save email
</button>
</Field>
</Button>
</div>
)}
{(['id', 'insurance', 'licence'] as const).map((kind) => (
<Field
<FieldSet
key={kind}
label={
kind === 'id'
@@ -300,80 +307,37 @@ export function OnboardingWizard({
}}
onError={setError}
/>
</Field>
</FieldSet>
))}
<div className="mt-6 flex gap-3">
<button
type="button"
onClick={() => setStep(2)}
className="rounded-xl border border-[var(--border)] px-5 py-3 text-sm"
>
Back
</button>
<button
type="button"
onClick={() => submit.mutate()}
disabled={submit.isPending}
className="flex flex-1 items-center justify-center gap-2 rounded-xl bg-[var(--color-brand-500)] px-5 py-3 font-medium text-white disabled:opacity-60"
>
{submit.isPending && <Loader2 className="h-4 w-4 animate-spin" aria-hidden />}
Submit for review
</button>
</div>
<StickyAction>
<div className="flex gap-3">
<Button type="button" variant="outline" size="lg" onClick={() => setStep(2)}>
Back
</Button>
<Button
type="button"
size="lg"
className="flex-1"
busy={submit.isPending}
onClick={() => submit.mutate()}
>
Submit for review
</Button>
</div>
</StickyAction>
</Section>
)}
{error && (
<p role="alert" className="mt-4 text-sm text-[var(--color-stop-500)]">
{error}
</p>
<div className="mt-6">
<FormError>{error}</FormError>
</div>
)}
</div>
);
}
const inputClass =
'w-full rounded-xl border border-[var(--border)] bg-[var(--card)] px-4 py-3 text-base outline-none focus:border-[var(--color-brand-500)]';
function Section({
title,
hint,
children,
}: {
title: string;
hint?: string;
children: React.ReactNode;
}) {
return (
<section className="flex flex-col gap-4">
<div>
<h2 className="text-lg font-medium">{title}</h2>
{hint && <p className="mt-1 text-sm text-[var(--muted)]">{hint}</p>}
</div>
{children}
</section>
);
}
function Field({
label,
hint,
children,
}: {
label: string;
hint?: string;
children: React.ReactNode;
}) {
return (
<label className="flex flex-col gap-1.5">
<span className="text-sm font-medium">{label}</span>
{hint && <span className="text-xs text-[var(--muted)]">{hint}</span>}
{children}
</label>
);
}
function Nav({
onBack,
onNext,
@@ -384,26 +348,18 @@ function Nav({
busy?: boolean;
}) {
return (
<div className="mt-4 flex gap-3">
{onBack && (
<button
type="button"
onClick={onBack}
className="rounded-xl border border-[var(--border)] px-5 py-3 text-sm"
>
Back
</button>
)}
<button
type="button"
onClick={() => void onNext()}
disabled={busy}
className="flex flex-1 items-center justify-center gap-2 rounded-xl bg-[var(--color-brand-500)] px-5 py-3 font-medium text-white disabled:opacity-60"
>
{busy && <Loader2 className="h-4 w-4 animate-spin" aria-hidden />}
Continue
</button>
</div>
<StickyAction>
<div className="flex gap-3">
{onBack && (
<Button type="button" variant="outline" size="lg" onClick={onBack}>
Back
</Button>
)}
<Button type="button" size="lg" className="flex-1" busy={busy} onClick={() => void onNext()}>
Continue
</Button>
</div>
</StickyAction>
);
}
@@ -422,15 +378,15 @@ function UploadList({
const presign = api.upload.presign.useMutation();
return (
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-3">
{existing.length > 0 && (
<ul className="flex flex-wrap gap-2">
{existing.map((url) => (
<li
key={url}
className="flex items-center gap-1.5 rounded-lg border border-[var(--border)] px-3 py-1.5 text-xs"
className="flex items-center gap-1.5 rounded-pill border border-go-100 bg-go-50 px-3 py-1.5 text-meta text-go-700"
>
<Check className="h-3.5 w-3.5 text-[var(--color-go-500)]" aria-hidden />
<Check className="h-4 w-4" aria-hidden />
Uploaded
</li>
))}
@@ -438,8 +394,9 @@ function UploadList({
)}
<label
className={cn(
'flex cursor-pointer items-center justify-center gap-2 rounded-xl border border-dashed',
'border-[var(--border)] px-4 py-6 text-sm text-[var(--muted)] hover:border-[var(--color-brand-500)]',
'flex cursor-pointer items-center justify-center gap-2 rounded-card border border-dashed',
'border-hairline px-4 py-8 text-body-sm text-muted',
'transition-colors duration-[120ms] ease-standard hover:border-brand-500 hover:text-strong',
)}
>
{busy ? (
@@ -450,7 +407,7 @@ function UploadList({
{busy ? 'Uploading…' : 'Choose a file'}
<input
type="file"
className="hidden"
className="sr-only"
accept={kind === 'credential' ? 'image/*,application/pdf' : 'image/*'}
onChange={async (event) => {
const file = event.target.files?.[0];