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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user