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
+11 -24
View File
@@ -1,8 +1,7 @@
import { notFound, redirect } from 'next/navigation';
import Link from 'next/link';
import { ArrowLeft } from 'lucide-react';
import { TRPCError } from '@trpc/server';
import { getApi } from '@/server/caller';
import { DeckShell } from '@/components/chrome/app-shell';
import { DeckClient } from './deck-client';
export const dynamic = 'force-dynamic';
@@ -26,31 +25,19 @@ export default async function DeckPage({ params }: { params: Promise<{ jobId: st
}
return (
<main className="mx-auto flex min-h-dvh max-w-2xl flex-col px-4 py-6">
<header className="mb-6">
<Link
href="/jobs"
className="mb-4 inline-flex items-center gap-1.5 text-sm text-[var(--muted)] hover:text-[var(--fg)]"
>
<ArrowLeft className="h-4 w-4" aria-hidden />
My jobs
</Link>
<p className="text-sm text-[var(--muted)]">
{job.category.name} · {job.addressText}
</p>
<h1 className="text-xl font-semibold">{job.title}</h1>
<DeckShell>
<h1 className="text-h3">{job.title}</h1>
<p className="mb-4 mt-1 text-body-sm text-muted">
{job.category.name} · {job.addressText}
{job.pendingRequests > 0 && (
<p className="mt-1 text-sm text-[var(--muted)]">
{job.pendingRequests} pro{job.pendingRequests === 1 ? '' : 's'} already considering this
</p>
<>
{' · '}
{job.pendingRequests} pro{job.pendingRequests === 1 ? '' : 's'} considering this
</>
)}
</header>
</p>
<DeckClient jobId={jobId} initialCards={deck.cards} />
<p className="mt-8 text-center text-xs text-[var(--muted)]">
Swipe right to send this job to a pro, left to pass. Drag the card or use the buttons.
</p>
</main>
</DeckShell>
);
}
+75 -97
View File
@@ -2,10 +2,20 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Loader2 } from 'lucide-react';
import type { RouterOutputs } from '@/lib/trpc';
import { api } from '@/lib/trpc';
import { cn } from '@/lib/utils';
import {
Button,
Chip,
Field,
FieldNote,
FieldSet,
FormError,
Input,
OptionCard,
StickyAction,
Textarea,
} from '@/components/ui';
type Categories = RouterOutputs['job']['categories'];
@@ -65,44 +75,34 @@ export function NewJobForm({ categories }: { categories: Categories }) {
}
return (
<form onSubmit={submit} className="mt-8 flex flex-col gap-5">
<fieldset className="flex flex-col gap-2">
<legend className="text-sm font-medium">Trade</legend>
<div className="mt-1 flex flex-wrap gap-2">
<form onSubmit={submit} className="flex flex-col gap-6">
<FieldSet label="Trade">
<div className="flex flex-wrap gap-2">
{categories.map((c) => (
<button
<Chip
key={c.id}
type="button"
selected={categoryId === c.id}
onClick={() => setCategoryId(c.id)}
className={cn(
'rounded-full border px-4 py-2 text-sm transition',
categoryId === c.id
? 'border-[var(--color-brand-500)] bg-[var(--color-brand-500)]/10 font-medium'
: 'border-[var(--border)]',
)}
>
{c.name}
</button>
</Chip>
))}
</div>
</fieldset>
</FieldSet>
<label className="flex flex-col gap-1.5">
<span className="text-sm font-medium">Short summary</span>
<input
<Field label="Short summary">
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
required
minLength={5}
maxLength={120}
placeholder="Kitchen sink leaking under the cupboard"
className={inputClass}
/>
</label>
</Field>
<label className="flex flex-col gap-1.5">
<span className="text-sm font-medium">What is wrong?</span>
<textarea
<Field label="What is wrong?">
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
required
@@ -110,99 +110,77 @@ export function NewJobForm({ categories }: { categories: Categories }) {
maxLength={4000}
rows={5}
placeholder="The more detail you give, the more accurate the quotes you get back."
className={inputClass}
/>
<span className="text-xs text-[var(--muted)]">{description.length}/4000, minimum 20</span>
</label>
<FieldNote>{description.length}/4000, minimum 20</FieldNote>
</Field>
<fieldset className="flex flex-col gap-2">
<legend className="text-sm font-medium">How soon?</legend>
<div className="mt-1 flex flex-col gap-2">
<FieldSet label="How soon?">
<div className="flex flex-col gap-3">
{URGENCIES.map((u) => (
<button
<OptionCard
key={u.value}
type="button"
selected={urgency === u.value}
title={u.label}
body={u.hint}
onClick={() => setUrgency(u.value)}
className={cn(
'rounded-xl border px-4 py-3 text-left text-sm transition',
urgency === u.value
? 'border-[var(--color-brand-500)] bg-[var(--color-brand-500)]/10'
: 'border-[var(--border)]',
)}
>
<span className="block font-medium">{u.label}</span>
<span className="text-xs text-[var(--muted)]">{u.hint}</span>
</button>
/>
))}
</div>
</fieldset>
</FieldSet>
<label className="flex flex-col gap-1.5">
<span className="text-sm font-medium">Address</span>
<span className="text-xs text-[var(--muted)]">
Only shared with a pro once you have booked them.
</span>
<input
value={addressText}
onChange={(e) => setAddressText(e.target.value)}
required
minLength={3}
maxLength={255}
className={inputClass}
/>
<button
<div className="flex flex-col gap-2">
<Field label="Address" hint="Only shared with a pro once you have booked them.">
<Input
value={addressText}
onChange={(e) => setAddressText(e.target.value)}
required
minLength={3}
maxLength={255}
/>
</Field>
<Button
type="button"
variant="ghost"
size="sm"
className="self-start"
onClick={() =>
navigator.geolocation?.getCurrentPosition(
(pos) => setLocation({ lat: pos.coords.latitude, lng: pos.coords.longitude }),
() => setError('We could not get your location, so we will search from the centre.'),
)
}
className="self-start text-sm text-[var(--color-brand-500)] underline underline-offset-4"
>
Use my current location
</button>
</label>
</Button>
</div>
<fieldset className="grid grid-cols-2 gap-3">
<legend className="mb-2 text-sm font-medium">Budget (optional)</legend>
<label className="flex flex-col gap-1.5">
<span className="text-xs text-[var(--muted)]">From </span>
<input
value={budgetMin}
onChange={(e) => setBudgetMin(e.target.value.replace(/[^0-9.]/g, ''))}
inputMode="decimal"
className={inputClass}
/>
</label>
<label className="flex flex-col gap-1.5">
<span className="text-xs text-[var(--muted)]">To </span>
<input
value={budgetMax}
onChange={(e) => setBudgetMax(e.target.value.replace(/[^0-9.]/g, ''))}
inputMode="decimal"
className={inputClass}
/>
</label>
</fieldset>
<FieldSet label="Budget (optional)">
<div className="grid grid-cols-2 gap-3">
<Field label="From €">
<Input
value={budgetMin}
onChange={(e) => setBudgetMin(e.target.value.replace(/[^0-9.]/g, ''))}
inputMode="decimal"
/>
</Field>
<Field label="To €">
<Input
value={budgetMax}
onChange={(e) => setBudgetMax(e.target.value.replace(/[^0-9.]/g, ''))}
inputMode="decimal"
/>
</Field>
</div>
</FieldSet>
{error && (
<p role="alert" className="text-sm text-[var(--color-stop-500)]">
{error}
</p>
)}
{error && <FormError>{error}</FormError>}
<button
type="submit"
disabled={create.isPending || !categoryId}
className="flex items-center justify-center gap-2 rounded-xl bg-[var(--color-brand-500)] px-5 py-3 font-medium text-white transition hover:bg-[var(--color-brand-600)] disabled:opacity-60"
>
{create.isPending && <Loader2 className="h-4 w-4 animate-spin" aria-hidden />}
Find me a pro
</button>
{/* §9 — the primary action stays in thumb reach on a long form. */}
<StickyAction>
<Button type="submit" size="lg" block busy={create.isPending} disabled={!categoryId}>
Find me a pro
</Button>
</StickyAction>
</form>
);
}
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)]';
+6 -5
View File
@@ -1,5 +1,7 @@
import { redirect } from 'next/navigation';
import { getApi } from '@/server/caller';
import { AppShell } from '@/components/chrome/app-shell';
import { ScreenIntro } from '@/components/ui';
import { NewJobForm } from './form';
export const metadata = { title: 'Post a job' };
@@ -19,12 +21,11 @@ export default async function NewJobPage() {
const categories = await api.job.categories();
return (
<main className="mx-auto max-w-lg px-6 py-10">
<h1 className="text-2xl font-semibold tracking-tight">What needs doing?</h1>
<p className="mt-2 text-sm text-[var(--muted)]">
<AppShell title="Post a job">
<ScreenIntro>
Describe it once. We will show you verified pros nearby who can take it on.
</p>
</ScreenIntro>
<NewJobForm categories={categories} />
</main>
</AppShell>
);
}
+65 -39
View File
@@ -1,17 +1,21 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { ArrowRight, Plus } from 'lucide-react';
import { ChevronRight, Plus } from 'lucide-react';
import { getApi } from '@/server/caller';
import { AppShell } from '@/components/chrome/app-shell';
import { buttonClasses, EmptyState } from '@/components/ui';
import { cn } from '@/lib/utils';
export const metadata = { title: 'Your jobs' };
export const dynamic = 'force-dynamic';
const STATUS_LABEL: Record<string, string> = {
open: 'Looking for pros',
matched: 'Pros interested',
booked: 'Booked',
completed: 'Done',
cancelled: 'Cancelled',
/** Label plus the tint it carries. §8 — status is never colour alone. */
const STATUS: Record<string, { label: string; className: string }> = {
open: { label: 'Looking for pros', className: 'border-brand-200 bg-brand-100 text-ink-950' },
matched: { label: 'Pros interested', className: 'border-go-100 bg-go-50 text-go-700' },
booked: { label: 'Booked', className: 'border-go-100 bg-go-50 text-go-700' },
completed: { label: 'Done', className: 'border-hairline bg-inset text-muted' },
cancelled: { label: 'Cancelled', className: 'border-stop-100 bg-stop-50 text-stop-600' },
};
export default async function JobsPage() {
@@ -25,45 +29,67 @@ export default async function JobsPage() {
}
return (
<main className="mx-auto max-w-2xl px-6 py-10">
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-semibold tracking-tight">Your jobs</h1>
<AppShell title="Your jobs">
{jobs.length === 0 ? (
<EmptyState
title="No jobs yet"
body="Post a job and we will build you a deck of verified pros who cover your area."
action={
<Link
href="/jobs/new"
className={cn('mt-2', buttonClasses({ variant: 'primary', size: 'md' }))}
>
Post a job
</Link>
}
/>
) : (
<>
<Link
href="/jobs/new"
className="inline-flex items-center gap-1.5 rounded-xl bg-[var(--color-brand-500)] px-4 py-2.5 text-sm font-medium text-white"
className={cn('mb-4', buttonClasses({ variant: 'primary', size: 'md', block: true }))}
>
<Plus className="h-4 w-4" aria-hidden />
<Plus className="h-5 w-5" aria-hidden />
Post a job
</Link>
</div>
{jobs.length === 0 ? (
<p className="mt-10 rounded-2xl border border-dashed border-[var(--border)] p-8 text-center text-sm text-[var(--muted)]">
Nothing yet. Post a job and start swiping.
</p>
) : (
<ul className="mt-6 space-y-2">
{jobs.map((job) => (
<li key={job.id}>
<Link
href={`/deck/${job.id}`}
className="group flex items-center justify-between gap-4 rounded-xl border border-[var(--border)] bg-[var(--card)] px-4 py-3 transition hover:border-[var(--color-brand-500)]"
>
<span>
<span className="block font-medium">{job.title}</span>
<span className="block text-sm text-[var(--muted)]">
{STATUS_LABEL[job.status] ?? job.status} · {job.addressText}
<ul className="flex flex-col gap-3">
{jobs.map((job) => {
const status = STATUS[job.status] ?? {
label: job.status,
className: 'border-hairline bg-inset text-muted',
};
return (
<li key={job.id}>
<Link
href={`/deck/${job.id}`}
className={cn(
'flex items-center justify-between gap-3 rounded-card border border-hairline',
'bg-raised p-4 transition-[border-color] duration-[120ms] ease-standard',
'active:border-brand-500',
)}
>
<span className="min-w-0">
<span className="block font-display text-h4">{job.title}</span>
<span className="mt-2 flex flex-wrap items-center gap-2">
<span
className={cn(
'inline-flex items-center rounded-pill border px-3 py-1 text-meta',
status.className,
)}
>
{status.label}
</span>
<span className="truncate text-body-sm text-muted">{job.addressText}</span>
</span>
</span>
</span>
<ArrowRight
className="h-4 w-4 shrink-0 text-[var(--color-brand-500)] transition group-hover:translate-x-0.5"
aria-hidden
/>
</Link>
</li>
))}
<ChevronRight className="h-5 w-5 shrink-0 text-accent" aria-hidden />
</Link>
</li>
);
})}
</ul>
</>
)}
</main>
</AppShell>
);
}
+30 -4
View File
@@ -1,7 +1,25 @@
import type { Metadata, Viewport } from 'next';
import { Wix_Madefor_Display, Wix_Madefor_Text } from 'next/font/google';
import { TRPCProvider } from '@/lib/trpc';
import '@/styles/globals.css';
/**
* DESIGN.md §3.1. Self-hosted through next/font rather than a <link> to
* fonts.googleapis.com — that link is a render-blocking round trip on the
* critical path, and the deck is the first thing people see.
*/
const display = Wix_Madefor_Display({
subsets: ['latin'],
display: 'swap',
variable: '--font-madefor-display',
});
const text = Wix_Madefor_Text({
subsets: ['latin'],
display: 'swap',
variable: '--font-madefor-text',
});
export const metadata: Metadata = {
title: {
default: 'Linkder — hire a verified local pro',
@@ -9,23 +27,31 @@ export const metadata: Metadata = {
},
description:
'Describe the job once, then swipe through verified local plumbers, electricians and handymen. Quote, book and pay in one place.',
appleWebApp: { capable: true, statusBarStyle: 'default', title: 'Linkder' },
};
export const viewport: Viewport = {
themeColor: [
{ media: '(prefers-color-scheme: light)', color: '#fbfbfd' },
{ media: '(prefers-color-scheme: dark)', color: '#121319' },
{ media: '(prefers-color-scheme: light)', color: '#ffffff' },
{ media: '(prefers-color-scheme: dark)', color: '#000624' },
],
width: 'device-width',
initialScale: 1,
// The deck is a drag surface — double-tap zoom fights it.
maximumScale: 1,
// Required for env(safe-area-inset-*) to report anything but zero. §4.1
viewportFit: 'cover',
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className="min-h-dvh antialiased">
<html lang="en" className={`${display.variable} ${text.variable}`}>
{/*
The app is a single 480px column, but the column is owned by the screen
shells rather than by this layout — the entry screen renders a phone
illustration that has to go full-bleed past it. §4
*/}
<body className="bg-sunken text-strong antialiased">
<TRPCProvider>{children}</TRPCProvider>
</body>
</html>
+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>
);
}
+20 -43
View File
@@ -1,53 +1,30 @@
import Link from 'next/link';
import { ArrowRight } from 'lucide-react';
import { getApi } from '@/server/caller';
import { PhoneFrame } from '@/components/chrome/phone-frame';
import { ShowcaseDeck } from './showcase-deck';
export const dynamic = 'force-dynamic';
/**
* The entry screen.
*
* Not a marketing page. This is the product itself, running: a phone with a
* live, draggable deck of real verified pros inside it. Linkder's whole promise
* is a gesture, and a paragraph describing a gesture is worth nothing next to
* being able to do it.
*
* On a phone the frame collapses and the deck simply is the screen (see
* PhoneFrame) — this is a mobile product, and the illustration exists only so
* the desktop visitor understands that.
*/
export default async function Home() {
const api = await getApi();
const categories = await api.job.categories();
const { cards } = await api.deck.showcase();
return (
<main className="mx-auto max-w-2xl px-6 py-16">
<p className="text-sm font-medium text-[var(--color-brand-500)]">Linkder</p>
<h1 className="mt-2 text-4xl font-semibold tracking-tight text-balance">
Describe the job once. Swipe through verified local pros.
</h1>
<p className="mt-4 text-lg text-[var(--muted)] text-pretty">
Every pro on the deck has had their ID, trade licence and insurance checked. Quote, book and
pay in one place your money is held until the work is done.
</p>
<div className="mt-8 flex flex-wrap gap-3">
<Link
href="/sign-in"
className="rounded-xl bg-[var(--color-brand-500)] px-5 py-3 font-medium text-white transition hover:bg-[var(--color-brand-600)]"
>
Post a job
</Link>
<Link
href="/sign-in?next=/pro/onboarding"
className="inline-flex items-center gap-1.5 rounded-xl border border-[var(--border)] px-5 py-3 font-medium transition hover:border-[var(--color-brand-500)]"
>
Work with us
<ArrowRight className="h-4 w-4" aria-hidden />
</Link>
</div>
<section className="mt-12">
<h2 className="text-sm font-medium text-[var(--muted)]">Trades we cover</h2>
<ul className="mt-3 flex flex-wrap gap-2">
{categories.map((c) => (
<li
key={c.id}
className="rounded-full border border-[var(--border)] px-3 py-1.5 text-sm"
>
{c.name}
</li>
))}
</ul>
</section>
</main>
<div className="flex min-h-dvh items-center justify-center sm:p-8">
<PhoneFrame>
<ShowcaseDeck cards={cards} />
</PhoneFrame>
</div>
);
}
+11 -6
View File
@@ -1,5 +1,7 @@
import { redirect } from 'next/navigation';
import { getApi } from '@/server/caller';
import { AppShell } from '@/components/chrome/app-shell';
import { ScreenIntro } from '@/components/ui';
import { OnboardingWizard } from './wizard';
export const metadata = { title: 'Set up your profile' };
@@ -22,22 +24,25 @@ export default async function ProOnboardingPage() {
const [categories, profile] = await Promise.all([api.job.categories(), api.pro.me()]);
// Already submitted or approved — nothing to fill in.
if (profile && profile.verificationStatus !== 'draft' && profile.verificationStatus !== 'rejected') {
if (
profile &&
profile.verificationStatus !== 'draft' &&
profile.verificationStatus !== 'rejected'
) {
redirect('/pro');
}
return (
<main className="mx-auto max-w-lg px-6 py-10">
<h1 className="text-2xl font-semibold tracking-tight">Set up your profile</h1>
<p className="mt-2 text-sm text-[var(--muted)]">
<AppShell title="Set up your profile">
<ScreenIntro>
We check every pro&rsquo;s ID, licence and insurance before any customer sees them. It
usually takes a day.
</p>
</ScreenIntro>
<OnboardingWizard
categories={categories}
initialProfile={profile}
hasContactableEmail={me.hasContactableEmail}
/>
</main>
</AppShell>
);
}
+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];
+27 -74
View File
@@ -1,6 +1,7 @@
import { redirect } from 'next/navigation';
import { Clock, ShieldCheck, XCircle } from 'lucide-react';
import { getApi } from '@/server/caller';
import { AppShell } from '@/components/chrome/app-shell';
import { Banner, ScreenIntro, Stat } from '@/components/ui';
export const metadata = { title: 'Your account' };
export const dynamic = 'force-dynamic';
@@ -22,97 +23,49 @@ export default async function ProHomePage() {
const status = profile.verificationStatus;
return (
<main className="mx-auto max-w-lg px-6 py-10">
<h1 className="text-2xl font-semibold tracking-tight">{profile.headline}</h1>
<AppShell title="Your account">
<ScreenIntro>{profile.headline}</ScreenIntro>
{status === 'pending' && (
<StatusCard
icon={<Clock className="h-5 w-5" aria-hidden />}
tone="waiting"
title="We are checking your documents"
body="This usually takes a day. We will text you the moment you are live and jobs start arriving."
/>
<Banner tone="warning" title="We are checking your documents">
This usually takes a day. We will text you the moment you are live and jobs start
arriving.
</Banner>
)}
{status === 'verified' && (
<StatusCard
icon={<ShieldCheck className="h-5 w-5" aria-hidden />}
tone="good"
title="You are live"
body="Customers nearby can see you now. Keep your response time short — it is the second biggest factor in where you appear."
/>
<Banner tone="success" title="You are live">
Customers nearby can see you now. Keep your response time short it is the second
biggest factor in where you appear.
</Banner>
)}
{status === 'rejected' && (
<StatusCard
icon={<XCircle className="h-5 w-5" aria-hidden />}
tone="bad"
title="We could not approve your account"
body="Check your email for what we need. You can update your documents and submit again."
/>
<Banner tone="error" title="We could not approve your account">
Check your email for what we need. You can update your documents and submit again.
</Banner>
)}
{status === 'suspended' && (
<StatusCard
icon={<XCircle className="h-5 w-5" aria-hidden />}
tone="bad"
title="Your account is suspended"
body={profile.suspendedReason ?? 'Contact support to sort this out.'}
/>
<Banner tone="error" title="Your account is suspended">
{profile.suspendedReason ?? 'Contact support to sort this out.'}
</Banner>
)}
{status === 'draft' && (
<StatusCard
icon={<Clock className="h-5 w-5" aria-hidden />}
tone="waiting"
title="Your profile is not finished"
body="Finish setting up and submit it for review."
/>
<Banner tone="warning" title="Your profile is not finished">
Finish setting up and submit it for review.
</Banner>
)}
<dl className="mt-8 grid grid-cols-2 gap-4 text-sm">
{/* §4 — a 2-up grid is allowed for stat tiles and nothing else. */}
<dl className="mt-8 grid grid-cols-2 gap-3">
<Stat label="Jobs completed" value={String(profile.completedJobs)} />
<Stat
label="Rating"
value={profile.ratingAvg ? `${Number(profile.ratingAvg).toFixed(1)} / 5` : 'No reviews yet'}
value={
profile.ratingAvg ? `${Number(profile.ratingAvg).toFixed(1)} / 5` : 'No reviews yet'
}
/>
<Stat label="Travels up to" value={`${Math.round(profile.serviceRadiusM / 1000)} km`} />
<Stat label="Rate" value={`${(profile.hourlyRateCents / 100).toFixed(0)}/hr`} />
</dl>
</main>
);
}
function StatusCard({
icon,
tone,
title,
body,
}: {
icon: React.ReactNode;
tone: 'good' | 'waiting' | 'bad';
title: string;
body: string;
}) {
const toneClass =
tone === 'good'
? 'border-[var(--color-go-500)]/30 bg-[var(--color-go-500)]/10'
: tone === 'bad'
? 'border-[var(--color-stop-500)]/30 bg-[var(--color-stop-500)]/10'
: 'border-[var(--border)] bg-[var(--card)]';
return (
<div className={`mt-6 flex gap-3 rounded-2xl border p-5 ${toneClass}`}>
<span className="mt-0.5 shrink-0">{icon}</span>
<span>
<span className="block font-medium">{title}</span>
<span className="mt-1 block text-sm text-[var(--muted)]">{body}</span>
</span>
</div>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-xl border border-[var(--border)] p-4">
<dt className="text-xs text-[var(--muted)]">{label}</dt>
<dd className="mt-1 font-medium">{value}</dd>
</div>
</AppShell>
);
}
+43
View File
@@ -0,0 +1,43 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import type { DeckCard } from '@linkder/db';
import { Deck } from '@/components/deck';
import { buttonClasses } from '@/components/ui';
/**
* The live demo deck inside the phone on the entry screen.
*
* Real pros, real ranking, real drag physics — but `onDecide` deliberately
* writes NOTHING. A visitor with no session swiping right must not send a job
* to a real tradesperson; the card simply leaves. The funnel starts when they
* tap "Post a job", which is where a real deck (deck.list / deck.swipe, both
* authenticated) takes over.
*/
export function ShowcaseDeck({ cards }: { cards: DeckCard[] }) {
const [seen, setSeen] = useState(0);
const done = seen >= cards.length;
return (
<div className="flex h-full flex-col px-4 pb-4 pt-[3.25rem]">
<header className="mb-3 flex shrink-0 items-baseline justify-between">
<p className="text-h4">Verified pros near you</p>
{!done && cards.length > 0 && (
<p className="text-meta tabular-nums text-muted">{cards.length - seen} left</p>
)}
</header>
<div className="min-h-0 flex-1">
<Deck cards={cards} onDecide={() => setSeen((n) => n + 1)} />
</div>
<Link
href="/jobs/new"
className={buttonClasses({ variant: 'primary', size: 'lg', block: true })}
>
Post a job
</Link>
</div>
);
}
+6 -5
View File
@@ -1,19 +1,20 @@
import { Suspense } from 'react';
import { BareShell } from '@/components/chrome/app-shell';
import { SignInForm } from './sign-in-form';
export const metadata = { title: 'Sign in' };
export default function SignInPage() {
return (
<main className="mx-auto flex min-h-dvh max-w-sm flex-col justify-center px-6 py-12">
<p className="text-sm font-medium text-[var(--color-brand-500)]">Linkder</p>
<h1 className="mt-2 text-2xl font-semibold tracking-tight">Sign in</h1>
<p className="mt-2 text-sm text-[var(--muted)]">
<BareShell>
<p className="text-overline uppercase text-accent">Linkder</p>
<h1 className="mt-3 text-h1">Sign in</h1>
<p className="mt-3 text-body text-muted">
We will text you a 6-digit code. No password to forget.
</p>
<Suspense fallback={null}>
<SignInForm />
</Suspense>
</main>
</BareShell>
);
}
+54 -69
View File
@@ -2,8 +2,8 @@
import { useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Loader2 } from 'lucide-react';
import { authClient } from '@/lib/auth-client';
import { Button, Field, FormError, Input } from '@/components/ui';
type Step = 'phone' | 'code';
@@ -58,98 +58,83 @@ export function SignInForm() {
}
return (
<div className="mt-8 flex flex-col gap-6">
<div className="mt-10 flex flex-col gap-6">
{step === 'phone' ? (
<form onSubmit={sendCode} className="flex flex-col gap-3">
<label htmlFor="phone" className="text-sm font-medium">
Mobile number
</label>
<input
id="phone"
name="phone"
type="tel"
autoComplete="tel"
inputMode="tel"
required
placeholder="+34 600 123 456"
value={phone}
onChange={(e) => setPhone(e.target.value.replace(/\s/g, ''))}
className="rounded-xl border border-[var(--border)] bg-[var(--card)] px-4 py-3 text-base outline-none focus:border-[var(--color-brand-500)]"
/>
<SubmitButton busy={busy}>Send code</SubmitButton>
<form onSubmit={sendCode} className="flex flex-col gap-6">
<Field label="Mobile number">
<Input
name="phone"
type="tel"
autoComplete="tel"
inputMode="tel"
required
placeholder="+34 600 123 456"
value={phone}
onChange={(e) => setPhone(e.target.value.replace(/\s/g, ''))}
/>
</Field>
<Button type="submit" size="lg" block busy={busy}>
Send code
</Button>
</form>
) : (
<form onSubmit={verifyCode} className="flex flex-col gap-3">
<label htmlFor="code" className="text-sm font-medium">
Enter the code we sent to {phone}
</label>
<input
id="code"
name="code"
type="text"
autoComplete="one-time-code"
inputMode="numeric"
pattern="[0-9]{6}"
maxLength={6}
required
autoFocus
placeholder="123456"
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
className="rounded-xl border border-[var(--border)] bg-[var(--card)] px-4 py-3 text-center text-2xl tracking-[0.4em] outline-none focus:border-[var(--color-brand-500)]"
/>
<SubmitButton busy={busy}>Sign in</SubmitButton>
<button
<form onSubmit={verifyCode} className="flex flex-col gap-6">
<Field label={`Enter the code we sent to ${phone}`}>
<Input
name="code"
type="text"
autoComplete="one-time-code"
inputMode="numeric"
pattern="[0-9]{6}"
maxLength={6}
required
autoFocus
placeholder="123456"
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
className="text-center font-display text-h2 tracking-[0.4em] tabular-nums"
/>
</Field>
<Button type="submit" size="lg" block busy={busy}>
Sign in
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setStep('phone');
setCode('');
setError(null);
}}
className="text-sm text-[var(--muted)] underline underline-offset-4"
>
Use a different number
</button>
</Button>
</form>
)}
{error && (
<p role="alert" className="text-sm text-[var(--color-stop-500)]">
{error}
</p>
)}
{error && <FormError>{error}</FormError>}
<div className="flex items-center gap-3 text-xs text-[var(--muted)]">
<span className="h-px flex-1 bg-[var(--border)]" />
<div className="flex items-center gap-3 text-meta text-muted">
<span className="h-px flex-1 bg-hairline" />
or
<span className="h-px flex-1 bg-[var(--border)]" />
<span className="h-px flex-1 bg-hairline" />
</div>
<button
<Button
type="button"
variant="outline"
size="lg"
block
onClick={() => authClient.signIn.social({ provider: 'google', callbackURL: next })}
className="rounded-xl border border-[var(--border)] bg-[var(--card)] px-4 py-3 text-sm font-medium transition hover:border-[var(--color-brand-500)]"
>
Continue with Google
</button>
</Button>
<p className="text-xs text-[var(--muted)]">
Signing in with Google creates a separate account from a phone sign-in. If you have used both,
contact us and we will link them.
<p className="text-meta text-muted">
Signing in with Google creates a separate account from a phone sign-in. If you have used
both, contact us and we will link them.
</p>
</div>
);
}
function SubmitButton({ busy, children }: { busy: boolean; children: React.ReactNode }) {
return (
<button
type="submit"
disabled={busy}
className="flex items-center justify-center gap-2 rounded-xl bg-[var(--color-brand-500)] px-4 py-3 font-medium text-white transition hover:bg-[var(--color-brand-600)] disabled:opacity-60"
>
{busy && <Loader2 className="h-4 w-4 animate-spin" aria-hidden />}
{children}
</button>
);
}
@@ -0,0 +1,57 @@
import { cn } from '@/lib/utils';
/**
* DESIGN.md §4. The one screen wrapper.
*
* Screens do not set their own gutters or safe-area padding — the whole point is
* that they cannot drift.
*
* There is no fixed app bar and no bottom tab bar. The screen title is rendered
* in flow as the page's `h1`: the bar used to own the only `h1` on every screen,
* so removing it without this would leave every page with no heading at all.
*/
export function AppShell({
title,
className,
children,
}: {
title: string;
className?: string;
children: React.ReactNode;
}) {
return (
<main
className={cn(
'mx-auto min-h-dvh max-w-app bg-page',
'px-5 pt-8 pb-[calc(2rem+env(safe-area-inset-bottom))]',
className,
)}
>
<h1 className="mb-6 text-h1">{title}</h1>
{children}
</main>
);
}
/**
* A screen with no chrome and no heading — sign in, role choice, the entry
* screen. Vertically centred, because these are single-decision screens with
* little on them.
*/
export function BareShell({ children }: { children: React.ReactNode }) {
return <main className="mx-auto flex min-h-dvh max-w-app flex-col justify-center bg-page px-5 py-10">
{children}
</main>;
}
/**
* The deck. Full-bleed and full-height — swiping is a focused mode, and the
* card needs the whole viewport rather than a box inside a padded screen.
*/
export function DeckShell({ children }: { children: React.ReactNode }) {
return (
<main className="mx-auto flex min-h-dvh max-w-app flex-col bg-page px-5 pt-6 pb-[calc(1.5rem+env(safe-area-inset-bottom))]">
{children}
</main>
);
}
@@ -0,0 +1,55 @@
import { cn } from '@/lib/utils';
/**
* A hardware illustration with the real app running inside it.
*
* This is the entry screen on a desktop browser: rather than describing the
* product in prose, it shows the actual running app — the same <Deck>, the same
* drag gesture, the same data — inside a phone. Everything inside `children` is
* live and interactive, not a screenshot.
*
* On a phone there is no frame. Drawing a picture of a phone on a phone is
* absurd, and it would waste the ~24px of width the deck actually needs, so
* below `sm` the bezel collapses and the app simply fills the viewport.
*/
export function PhoneFrame({
className,
children,
}: {
className?: string;
children: React.ReactNode;
}) {
return (
<div
className={cn(
// Phone: no frame at all, app fills the screen.
'h-dvh w-full',
// Tablet and up: a 390x844 device, the iPhone 14 logical viewport.
'sm:h-[844px] sm:w-[390px] sm:shrink-0',
// The bezel. ink-950 rather than pure black, per DESIGN.md §2.2.
'sm:rounded-[3.25rem] sm:bg-ink-950 sm:p-[0.7rem] sm:shadow-lg',
// A hairline of light along the top edge reads as a chamfer and stops
// the bezel looking like a flat black rectangle.
'sm:ring-1 sm:ring-inset sm:ring-white/12',
className,
)}
>
{/* 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]">
{/* Dynamic Island. Pointer-events-none so it never eats a drag that
starts near the top of the card. */}
<div
aria-hidden
className="pointer-events-none absolute left-1/2 top-2 z-30 hidden h-[1.85rem] w-[6.2rem] -translate-x-1/2 rounded-pill bg-ink-950 sm:block"
/>
{children}
{/* Home indicator. */}
<div
aria-hidden
className="pointer-events-none absolute bottom-2 left-1/2 z-30 hidden h-[0.3rem] w-[8.5rem] -translate-x-1/2 rounded-pill bg-ink-950/25 sm:block"
/>
</div>
</div>
);
}
+4 -7
View File
@@ -34,8 +34,8 @@ export function Deck({ cards, onDecide }: DeckProps) {
const visible = remaining.slice(0, 3);
return (
<div className="flex flex-col items-center gap-6">
<div className="relative h-[560px] w-full max-w-sm">
<div className="flex h-full min-h-0 flex-col items-center gap-5">
<div className="relative w-full min-h-0 flex-1">
<AnimatePresence initial={false}>
{visible
.map((card, i) => (
@@ -50,15 +50,12 @@ export function Deck({ cards, onDecide }: DeckProps) {
</AnimatePresence>
</div>
<div className="flex items-center gap-5">
<div className="flex shrink-0 items-center gap-12 pb-2">
<ActionButton
label="Not this one"
variant="pass"
onClick={() => visible[0] && decide(visible[0].proId, 'left')}
/>
<p className="w-28 text-center text-sm text-[var(--muted)] tabular-nums">
{remaining.length} left
</p>
<ActionButton
label="Send this job"
variant="hire"
@@ -204,7 +201,7 @@ function ActionButton({
function EmptyDeck() {
return (
<div className="mx-auto flex h-[560px] max-w-sm flex-col items-center justify-center gap-3 rounded-3xl border border-dashed border-[var(--border)] p-8 text-center">
<div className="mx-auto flex h-full flex-col items-center justify-center gap-3 rounded-deck border border-dashed border-hairline p-8 text-center">
<h2 className="text-lg font-semibold">That&rsquo;s everyone nearby</h2>
<p className="text-sm text-[var(--muted)]">
You&rsquo;ve seen every verified pro who covers your area for this trade. We&rsquo;ll notify
+71
View File
@@ -0,0 +1,71 @@
import { cva, type VariantProps } from 'class-variance-authority';
import { AlertTriangle, CheckCircle2, Info, XCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* DESIGN.md §6.5.
*
* The tinted surfaces stay light in dark mode and pin their text to ink, rather
* than inverting. Inverting would mean a second set of tints for every tone, and
* a pale-on-pale failure the first time one was missed.
*/
const bannerClasses = cva(
'flex gap-3 rounded-card border p-5 text-ink-950',
{
variants: {
tone: {
info: 'border-brand-200 bg-brand-100',
success: 'border-go-100 bg-go-50',
warning: 'border-sun-100 bg-sun-50',
error: 'border-stop-100 bg-stop-50',
},
},
defaultVariants: { tone: 'info' },
},
);
const TONE_ICON = {
info: Info,
success: CheckCircle2,
warning: AlertTriangle,
error: XCircle,
} as const;
const TONE_ICON_COLOR = {
info: 'text-brand-500',
success: 'text-go-600',
warning: 'text-sun-500',
error: 'text-stop-500',
} as const;
type BannerProps = VariantProps<typeof bannerClasses> & {
title?: string;
children: React.ReactNode;
className?: string;
/** `status` for outcomes the user caused, `alert` for errors. */
role?: 'status' | 'alert';
};
export function Banner({ tone = 'info', title, children, className, role }: BannerProps) {
const key = tone ?? 'info';
const Icon = TONE_ICON[key];
return (
<div role={role} className={cn(bannerClasses({ tone }), className)}>
<Icon className={cn('mt-0.5 h-5 w-5 shrink-0', TONE_ICON_COLOR[key])} aria-hidden />
<div className="min-w-0">
{title && <p className="font-display text-h4 text-ink-950">{title}</p>}
<div className={cn('text-body-sm text-ink-800', title && 'mt-1')}>{children}</div>
</div>
</div>
);
}
/** Inline form error. §6.2 — always announced. */
export function FormError({ children }: { children: React.ReactNode }) {
return (
<p role="alert" className="text-body-sm text-stop-500">
{children}
</p>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { cn } from '@/lib/utils';
/**
* DESIGN.md §6.3. Flat by default — a hairline, not a shadow. Interactive cards
* raise one step and shift their border to the accent; they do not lift or scale.
*/
export function Card({
interactive = false,
className,
children,
...props
}: React.HTMLAttributes<HTMLDivElement> & { interactive?: boolean }) {
return (
<div
{...props}
className={cn(
'rounded-card border border-hairline bg-raised p-5 sm:p-6',
interactive &&
'transition-[border-color,box-shadow] duration-[120ms] ease-standard hover:border-brand-500 hover:shadow-sm',
className,
)}
>
{children}
</div>
);
}
/** Empty-state panel — dashed hairline, centred, no shadow. */
export function EmptyState({
title,
body,
action,
className,
}: {
title: string;
body: string;
action?: React.ReactNode;
className?: string;
}) {
return (
<div
className={cn(
'flex flex-col items-center justify-center gap-3 rounded-card border border-dashed',
'border-hairline px-6 py-14 text-center',
className,
)}
>
<h2 className="text-h4">{title}</h2>
<p className="max-w-[46ch] text-body-sm text-muted">{body}</p>
{action}
</div>
);
}
/** A labelled figure in a stats grid. §3.3 — figures are tabular. */
export function Stat({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg border border-hairline p-4">
<dt className="text-meta text-muted">{label}</dt>
<dd className="mt-1 font-display text-h4 tabular-nums">{value}</dd>
</div>
);
}
+90
View File
@@ -0,0 +1,90 @@
'use client';
import { Check } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* DESIGN.md §6.4. Selection is carried by border + weight + a check icon, never
* by fill alone — §8 forbids colour as the only signal.
*/
export function Chip({
selected = false,
className,
children,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { selected?: boolean }) {
return (
<button
type="button"
aria-pressed={selected}
{...props}
className={cn(
'inline-flex items-center gap-1.5 rounded-pill border-[1.5px] px-4 py-3 text-body-sm',
'transition-[color,background-color,border-color] duration-[120ms] ease-standard',
'disabled:pointer-events-none disabled:opacity-45',
selected
? 'border-brand-500 bg-brand-100 font-semibold text-ink-950'
: 'border-hairline text-strong hover:border-brand-500',
className,
)}
>
{selected && <Check className="h-4 w-4" aria-hidden />}
{children}
</button>
);
}
/** Non-interactive pill — trade names, statuses, counts. */
export function Tag({ className, children }: { className?: string; children: React.ReactNode }) {
return (
<span
className={cn(
'inline-flex items-center rounded-pill border border-hairline px-3 py-1.5 text-body-sm text-strong',
className,
)}
>
{children}
</span>
);
}
/**
* A full-width option row — used where choices need a title and a description
* and so cannot compress into a chip.
*/
export function OptionCard({
selected = false,
title,
body,
icon,
className,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & {
selected?: boolean;
title: string;
body?: string;
icon?: React.ReactNode;
}) {
return (
<button
type="button"
aria-pressed={selected}
{...props}
className={cn(
'flex w-full items-start gap-4 rounded-card border-[1.5px] bg-raised p-5 text-left',
'transition-[border-color,background-color,box-shadow] duration-[120ms] ease-standard',
'disabled:pointer-events-none disabled:opacity-45',
selected
? 'border-brand-500 bg-brand-50 dark:bg-accent-soft'
: 'border-hairline hover:border-brand-500 hover:shadow-sm',
className,
)}
>
{icon && <span className="mt-0.5 shrink-0 text-accent">{icon}</span>}
<span className="min-w-0">
<span className="block font-display text-h4">{title}</span>
{body && <span className="mt-1 block text-body-sm text-muted">{body}</span>}
</span>
</button>
);
}
+101
View File
@@ -0,0 +1,101 @@
import { cn } from '@/lib/utils';
/**
* DESIGN.md §6.2. 16px text is not negotiable — iOS zooms the viewport on focus
* for anything smaller, which fights the deck's fixed scale.
*/
const controlClasses = [
'w-full rounded-lg border-[1.5px] border-hairline bg-raised px-4 text-body text-strong',
'outline-none transition-[border-color,box-shadow] duration-[120ms] ease-standard',
'focus:border-brand-500 focus:shadow-[0_0_0_3px_var(--color-brand-200)]',
'disabled:opacity-45',
].join(' ');
export function Input({
className,
invalid,
...props
}: React.ComponentPropsWithRef<'input'> & { invalid?: boolean }) {
return (
<input
{...props}
aria-invalid={invalid || undefined}
className={cn(controlClasses, 'h-12', invalid && 'border-stop-500', className)}
/>
);
}
export function Textarea({
className,
invalid,
...props
}: React.ComponentPropsWithRef<'textarea'> & { invalid?: boolean }) {
return (
<textarea
{...props}
aria-invalid={invalid || undefined}
className={cn(controlClasses, 'resize-y py-3', invalid && 'border-stop-500', className)}
/>
);
}
/**
* A single labelled control. The <label> wraps the input, so association needs
* no ids and the whole thing works unchanged in a server component.
*/
export function Field({
label,
hint,
error,
className,
children,
}: {
label: string;
hint?: string;
error?: string | null;
className?: string;
children: React.ReactNode;
}) {
return (
<label className={cn('flex flex-col gap-2', className)}>
<span className="text-body-sm font-semibold text-strong">{label}</span>
{hint && <span className="text-meta text-muted">{hint}</span>}
{children}
{error && (
<span role="alert" className="text-body-sm text-stop-500">
{error}
</span>
)}
</label>
);
}
/**
* A labelled group of controls — chips, radio-style buttons, upload slots.
* Uses fieldset/legend rather than a wrapping label, which would swallow clicks
* meant for the buttons inside.
*/
export function FieldSet({
label,
hint,
className,
children,
}: {
label: string;
hint?: string;
className?: string;
children: React.ReactNode;
}) {
return (
<fieldset className={cn('flex flex-col gap-2', className)}>
<legend className="text-body-sm font-semibold text-strong">{label}</legend>
{hint && <p className="mb-1 text-meta text-muted">{hint}</p>}
{children}
</fieldset>
);
}
/** Character counter / helper text under a control. */
export function FieldNote({ children }: { children: React.ReactNode }) {
return <span className="text-meta text-muted tabular-nums">{children}</span>;
}
+6
View File
@@ -0,0 +1,6 @@
export { Button, IconButton, buttonClasses } from './button';
export { Banner, FormError } from './banner';
export { Card, EmptyState, Stat } from './card';
export { Chip, OptionCard, Tag } from './chip';
export { Field, FieldNote, FieldSet, Input, Textarea } from './field';
export { ScreenIntro, Section, StickyAction } from './page';
+54
View File
@@ -0,0 +1,54 @@
import { cn } from '@/lib/utils';
/**
* DESIGN.md §4. Layout lives in AppShell; these are the in-screen blocks.
*
* There is deliberately no `<h1>` here — the app bar owns it (§6.6, §8).
*/
/** Lead paragraph under the app bar. Sets up the screen in one sentence. */
export function ScreenIntro({ children, className }: { children: React.ReactNode; className?: string }) {
return <p className={cn('mb-8 text-body text-muted', className)}>{children}</p>;
}
/** A titled block within a screen. Its heading is an h2 — the app bar has the h1. */
export function Section({
title,
hint,
className,
children,
}: {
title?: string;
hint?: string;
className?: string;
children: React.ReactNode;
}) {
return (
<section className={cn('flex flex-col gap-4', className)}>
{title && (
<div>
<h2 className="text-h3">{title}</h2>
{hint && <p className="mt-2 text-body-sm text-muted">{hint}</p>}
</div>
)}
{children}
</section>
);
}
/**
* Sticks the primary action to the bottom of the viewport on long forms, so it
* stays in thumb reach instead of sitting below the fold. §9.
*/
export function StickyAction({ children }: { children: React.ReactNode }) {
return (
<div
className={cn(
'sticky bottom-0 -mx-5 mt-8 border-t border-hairline bg-page/95 px-5 pt-4 backdrop-blur-[12px]',
'pb-[calc(1rem+env(safe-area-inset-bottom))]',
)}
>
{children}
</div>
);
}
+2 -4
View File
@@ -114,10 +114,8 @@
--shadow-md: 0 4px 12px rgb(0 6 36 / 0.08), 0 2px 4px rgb(0 6 36 / 0.04);
--shadow-lg: 0 12px 32px rgb(0 6 36 / 0.12), 0 4px 8px rgb(0 6 36 / 0.06);
/* ── Containers. §4 ─────────────────────────────────────────────────── */
--container-prose: 680px;
--container-app: 1080px;
--container-wide: 1280px;
/* ── Container. §4 — mobile only; there is exactly one. ─────────────── */
--container-app: 480px;
/* ── Motion. §7 ─────────────────────────────────────────────────────── */
--ease-standard: cubic-bezier(0.4, 0, 0.2, 1);