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
+5 -4
View File
@@ -1,5 +1,6 @@
import { redirect } from 'next/navigation';
import { getApi } from '@/server/caller';
import { BareShell } from '@/components/chrome/app-shell';
import { RoleChooser } from './role-chooser';
export const metadata = { title: 'Welcome' };
@@ -27,12 +28,12 @@ export default async function OnboardingPage() {
if (me.role === 'pro') redirect(me.hasProProfile ? '/pro' : '/pro/onboarding');
return (
<main className="mx-auto flex min-h-dvh max-w-md flex-col justify-center px-6 py-12">
<h1 className="text-2xl font-semibold tracking-tight">What brings you here?</h1>
<p className="mt-2 text-sm text-[var(--muted)]">
<BareShell>
<h1 className="text-h1">What brings you here?</h1>
<p className="mt-3 text-body text-muted">
You can only pick once, so choose the one that fits.
</p>
<RoleChooser />
</main>
</BareShell>
);
}
+5 -37
View File
@@ -3,6 +3,7 @@
import { useRouter } from 'next/navigation';
import { Hammer, Home } from 'lucide-react';
import { api } from '@/lib/trpc';
import { FormError, OptionCard } from '@/components/ui';
export function RoleChooser() {
const router = useRouter();
@@ -14,55 +15,22 @@ export function RoleChooser() {
});
return (
<div className="mt-8 flex flex-col gap-3">
<Choice
<div className="mt-10 flex flex-col gap-3">
<OptionCard
icon={<Home className="h-6 w-6" aria-hidden />}
title="I need something fixed"
body="Post a job and swipe through verified local pros."
disabled={setRole.isPending}
onClick={() => setRole.mutate({ role: 'client' })}
/>
<Choice
<OptionCard
icon={<Hammer className="h-6 w-6" aria-hidden />}
title="I do the fixing"
body="Get sent local jobs that match your trade and your area."
disabled={setRole.isPending}
onClick={() => setRole.mutate({ role: 'pro' })}
/>
{setRole.error && (
<p role="alert" className="text-sm text-[var(--color-stop-500)]">
{setRole.error.message}
</p>
)}
{setRole.error && <FormError>{setRole.error.message}</FormError>}
</div>
);
}
function Choice({
icon,
title,
body,
disabled,
onClick,
}: {
icon: React.ReactNode;
title: string;
body: string;
disabled: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className="flex items-start gap-4 rounded-2xl border border-[var(--border)] bg-[var(--card)] p-5 text-left transition hover:border-[var(--color-brand-500)] disabled:opacity-60"
>
<span className="mt-0.5 text-[var(--color-brand-500)]">{icon}</span>
<span>
<span className="block font-medium">{title}</span>
<span className="mt-0.5 block text-sm text-[var(--muted)]">{body}</span>
</span>
</button>
);
}