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
+48 -16
View File
@@ -22,6 +22,8 @@ not affiliated with Wix and must never present itself as such.
## 1. Principles ## 1. Principles
0. **Mobile only.** One column, one container, thumb-reachable actions, no desktop layout and
no marketing site. See §4 — this constrains every other decision here.
1. **Blue means action.** `brand-500` is reserved for things the user can do. It is never 1. **Blue means action.** `brand-500` is reserved for things the user can do. It is never
decoration, never a background wash for a whole section, never body text. decoration, never a background wash for a whole section, never body text.
2. **Ink is near-black, not grey.** Headings sit at `ink-950` (`#000624`) — black with a blue 2. **Ink is near-black, not grey.** Headings sit at `ink-950` (`#000624`) — black with a blue
@@ -151,19 +153,35 @@ Display sizes use `clamp()` so one token works from 360px to desktop.
## 4. Space and layout ## 4. Space and layout
An **8px grid**. The only legal spacing values are `4 8 12 16 24 32 40 56 72 96 128`. Anything **Linkder is a mobile-only product.** There is no desktop layout, no marketing site and no
else is a bug. responsive breakpoint work. Design for a 360430px viewport and nothing else. On a wider
screen the app renders as a single 480px column centred on `ink-50` — that is a courtesy for
someone who opened it on a laptop, not a layout to design for.
| Container | Width | Used for | Concretely, this bans:
- Multi-column grids. Everything is one column. A 2-up grid is allowed only for paired
numeric fields (from/to, rate/years) and stat tiles.
- `md:` / `lg:` variants that change structure. Type already scales with `clamp()`.
- Sticky desktop nav bars with link lists, mega-footers, hero sections, feature grids.
An **8px grid**. The only legal spacing values are `4 8 12 16 24 32 40 56 72`. Anything else
is a bug.
| Token | Width | Used for |
|---|---|---| |---|---|---|
| `prose` | 680px | Reading, forms, single-column flows | | `app` | 480px | Every screen. There is no second container. |
| `app` | 1080px | Dashboards, lists |
| `wide` | 1280px | Marketing sections |
- Page gutter: **24px** mobile, **32px** ≥768px. - Page gutter: **20px**. One value, all screens.
- Vertical rhythm between page sections: **72px** mobile, **96px** desktop. - Vertical rhythm between blocks within a screen: **32px**. Between major groups: **40px**.
- Gap between sibling cards in a list: **12px**. - Gap between sibling cards in a list: **12px**.
- Label → control: **8px**. Control → helper text: **8px**. Field → field: **24px**. - Label → control: **8px**. Control → helper text: **8px**. Field → field: **24px**.
- Bottom of scroll content clears the tab bar by **88px** (`pb-22`) so nothing hides under it.
### 4.1 Safe areas
The tab bar and any fixed footer pad with `env(safe-area-inset-bottom)`. The app bar pads with
`env(safe-area-inset-top)`. Never assume the viewport edge is reachable.
--- ---
@@ -253,16 +271,26 @@ Selection must never rely on fill alone.
Title in `h4`, body in `body-sm` `ink-600`. Title in `h4`, body in `body-sm` `ink-600`.
### 6.6 Navigation ### 6.6 App bar
Sticky top bar, 72px tall, `ink-0` at 88% opacity with `backdrop-blur(12px)`, hairline bottom Fixed top, **56px** plus top safe-area inset. `page` surface at 88% opacity with
border. Wordmark left in Display 700 `-0.02em`. Primary CTA right as a pill. Mobile collapses `backdrop-blur(12px)` and a hairline bottom border.
links; the CTA stays visible in the bar.
### 6.7 Footer Two layouts only:
`ink-950` surface, white text, secondary links `ink-400`, 72px top padding, 40px bottom, - **Root screens** — screen title left in `h4`, at most one icon action right.
4-up column grid collapsing to 1-up under 640px. - **Pushed screens** — 44px back chevron left, centred title in `h4`, optional icon action right.
No link lists. No CTA button in the bar — the primary action lives in the screen or the tab bar.
### 6.7 Tab bar
Fixed bottom, **56px** plus bottom safe-area inset, `page` surface, hairline top border. Two
to five destinations, each a 44px-minimum target with a 24px icon over a `meta` label. Active
tab is `brand-500` icon + label; inactive is `ink-600`. The active state carries both colour and
weight, never colour alone.
The tab bar is the app's only persistent navigation. There is no footer.
### 6.8 Deck card ### 6.8 Deck card
@@ -311,6 +339,10 @@ deck's drag is essential and stays; its spring flattens under reduced motion.
| Placeholder standing in for a label | Visible `<label>` every time | | Placeholder standing in for a label | Visible `<label>` every time |
| Arbitrary values like `py-[13px]` | The 8px scale | | Arbitrary values like `py-[13px]` | The 8px scale |
| A second accent colour | Blue is the only accent | | A second accent colour | Blue is the only accent |
| Multi-column grids, `md:` structure changes | One column, always |
| A marketing hero or feature grid | This is an app, not a website |
| A footer | The tab bar is the only persistent nav |
| An action out of thumb reach | Primary actions sit low, near the tab bar |
--- ---
@@ -321,7 +353,7 @@ deck's drag is essential and stays; its spring flattens under reduced motion.
| Token definitions (`@theme`), base layer, dark mode | `apps/web/src/styles/globals.css` | | Token definitions (`@theme`), base layer, dark mode | `apps/web/src/styles/globals.css` |
| Font loading, chrome wiring | `apps/web/src/app/layout.tsx` | | Font loading, chrome wiring | `apps/web/src/app/layout.tsx` |
| Button, Input, Card, Chip, Banner, Field primitives | `apps/web/src/components/ui/` | | Button, Input, Card, Chip, Banner, Field primitives | `apps/web/src/components/ui/` |
| Site header and footer | `apps/web/src/components/chrome/` | | App bar and tab bar | `apps/web/src/components/chrome/` |
Screens compose primitives. A screen that writes its own `rounded-xl bg-[#...]` has bypassed the Screens compose primitives. A screen that writes its own `rounded-xl bg-[#...]` has bypassed the
system — fix the primitive instead. system — fix the primitive instead.
+11 -24
View File
@@ -1,8 +1,7 @@
import { notFound, redirect } from 'next/navigation'; import { notFound, redirect } from 'next/navigation';
import Link from 'next/link';
import { ArrowLeft } from 'lucide-react';
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { getApi } from '@/server/caller'; import { getApi } from '@/server/caller';
import { DeckShell } from '@/components/chrome/app-shell';
import { DeckClient } from './deck-client'; import { DeckClient } from './deck-client';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -26,31 +25,19 @@ export default async function DeckPage({ params }: { params: Promise<{ jobId: st
} }
return ( return (
<main className="mx-auto flex min-h-dvh max-w-2xl flex-col px-4 py-6"> <DeckShell>
<header className="mb-6"> <h1 className="text-h3">{job.title}</h1>
<Link <p className="mb-4 mt-1 text-body-sm text-muted">
href="/jobs" {job.category.name} · {job.addressText}
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>
{job.pendingRequests > 0 && ( {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} /> <DeckClient jobId={jobId} initialCards={deck.cards} />
</DeckShell>
<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>
); );
} }
+75 -97
View File
@@ -2,10 +2,20 @@
import { useState } from 'react'; import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Loader2 } from 'lucide-react';
import type { RouterOutputs } from '@/lib/trpc'; import type { RouterOutputs } from '@/lib/trpc';
import { api } 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']; type Categories = RouterOutputs['job']['categories'];
@@ -65,44 +75,34 @@ export function NewJobForm({ categories }: { categories: Categories }) {
} }
return ( return (
<form onSubmit={submit} className="mt-8 flex flex-col gap-5"> <form onSubmit={submit} className="flex flex-col gap-6">
<fieldset className="flex flex-col gap-2"> <FieldSet label="Trade">
<legend className="text-sm font-medium">Trade</legend> <div className="flex flex-wrap gap-2">
<div className="mt-1 flex flex-wrap gap-2">
{categories.map((c) => ( {categories.map((c) => (
<button <Chip
key={c.id} key={c.id}
type="button" selected={categoryId === c.id}
onClick={() => setCategoryId(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} {c.name}
</button> </Chip>
))} ))}
</div> </div>
</fieldset> </FieldSet>
<label className="flex flex-col gap-1.5"> <Field label="Short summary">
<span className="text-sm font-medium">Short summary</span> <Input
<input
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
required required
minLength={5} minLength={5}
maxLength={120} maxLength={120}
placeholder="Kitchen sink leaking under the cupboard" placeholder="Kitchen sink leaking under the cupboard"
className={inputClass}
/> />
</label> </Field>
<label className="flex flex-col gap-1.5"> <Field label="What is wrong?">
<span className="text-sm font-medium">What is wrong?</span> <Textarea
<textarea
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
required required
@@ -110,99 +110,77 @@ export function NewJobForm({ categories }: { categories: Categories }) {
maxLength={4000} maxLength={4000}
rows={5} rows={5}
placeholder="The more detail you give, the more accurate the quotes you get back." 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> <FieldNote>{description.length}/4000, minimum 20</FieldNote>
</label> </Field>
<fieldset className="flex flex-col gap-2"> <FieldSet label="How soon?">
<legend className="text-sm font-medium">How soon?</legend> <div className="flex flex-col gap-3">
<div className="mt-1 flex flex-col gap-2">
{URGENCIES.map((u) => ( {URGENCIES.map((u) => (
<button <OptionCard
key={u.value} key={u.value}
type="button" selected={urgency === u.value}
title={u.label}
body={u.hint}
onClick={() => setUrgency(u.value)} 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> </div>
</fieldset> </FieldSet>
<label className="flex flex-col gap-1.5"> <div className="flex flex-col gap-2">
<span className="text-sm font-medium">Address</span> <Field label="Address" hint="Only shared with a pro once you have booked them.">
<span className="text-xs text-[var(--muted)]"> <Input
Only shared with a pro once you have booked them. value={addressText}
</span> onChange={(e) => setAddressText(e.target.value)}
<input required
value={addressText} minLength={3}
onChange={(e) => setAddressText(e.target.value)} maxLength={255}
required />
minLength={3} </Field>
maxLength={255} <Button
className={inputClass}
/>
<button
type="button" type="button"
variant="ghost"
size="sm"
className="self-start"
onClick={() => onClick={() =>
navigator.geolocation?.getCurrentPosition( 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, so we will search from the centre.'), () => 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 Use my current location
</button> </Button>
</label> </div>
<fieldset className="grid grid-cols-2 gap-3"> <FieldSet label="Budget (optional)">
<legend className="mb-2 text-sm font-medium">Budget (optional)</legend> <div className="grid grid-cols-2 gap-3">
<label className="flex flex-col gap-1.5"> <Field label="From €">
<span className="text-xs text-[var(--muted)]">From </span> <Input
<input value={budgetMin}
value={budgetMin} onChange={(e) => setBudgetMin(e.target.value.replace(/[^0-9.]/g, ''))}
onChange={(e) => setBudgetMin(e.target.value.replace(/[^0-9.]/g, ''))} inputMode="decimal"
inputMode="decimal" />
className={inputClass} </Field>
/> <Field label="To €">
</label> <Input
<label className="flex flex-col gap-1.5"> value={budgetMax}
<span className="text-xs text-[var(--muted)]">To </span> onChange={(e) => setBudgetMax(e.target.value.replace(/[^0-9.]/g, ''))}
<input inputMode="decimal"
value={budgetMax} />
onChange={(e) => setBudgetMax(e.target.value.replace(/[^0-9.]/g, ''))} </Field>
inputMode="decimal" </div>
className={inputClass} </FieldSet>
/>
</label>
</fieldset>
{error && ( {error && <FormError>{error}</FormError>}
<p role="alert" className="text-sm text-[var(--color-stop-500)]">
{error}
</p>
)}
<button {/* §9 — the primary action stays in thumb reach on a long form. */}
type="submit" <StickyAction>
disabled={create.isPending || !categoryId} <Button type="submit" size="lg" block busy={create.isPending} disabled={!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" Find me a pro
> </Button>
{create.isPending && <Loader2 className="h-4 w-4 animate-spin" aria-hidden />} </StickyAction>
Find me a pro
</button>
</form> </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 { redirect } from 'next/navigation';
import { getApi } from '@/server/caller'; import { getApi } from '@/server/caller';
import { AppShell } from '@/components/chrome/app-shell';
import { ScreenIntro } from '@/components/ui';
import { NewJobForm } from './form'; import { NewJobForm } from './form';
export const metadata = { title: 'Post a job' }; export const metadata = { title: 'Post a job' };
@@ -19,12 +21,11 @@ export default async function NewJobPage() {
const categories = await api.job.categories(); const categories = await api.job.categories();
return ( return (
<main className="mx-auto max-w-lg px-6 py-10"> <AppShell title="Post a job">
<h1 className="text-2xl font-semibold tracking-tight">What needs doing?</h1> <ScreenIntro>
<p className="mt-2 text-sm text-[var(--muted)]">
Describe it once. We will show you verified pros nearby who can take it on. Describe it once. We will show you verified pros nearby who can take it on.
</p> </ScreenIntro>
<NewJobForm categories={categories} /> <NewJobForm categories={categories} />
</main> </AppShell>
); );
} }
+65 -39
View File
@@ -1,17 +1,21 @@
import Link from 'next/link'; import Link from 'next/link';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { ArrowRight, Plus } from 'lucide-react'; import { ChevronRight, Plus } from 'lucide-react';
import { getApi } from '@/server/caller'; 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 metadata = { title: 'Your jobs' };
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
const STATUS_LABEL: Record<string, string> = { /** Label plus the tint it carries. §8 — status is never colour alone. */
open: 'Looking for pros', const STATUS: Record<string, { label: string; className: string }> = {
matched: 'Pros interested', open: { label: 'Looking for pros', className: 'border-brand-200 bg-brand-100 text-ink-950' },
booked: 'Booked', matched: { label: 'Pros interested', className: 'border-go-100 bg-go-50 text-go-700' },
completed: 'Done', booked: { label: 'Booked', className: 'border-go-100 bg-go-50 text-go-700' },
cancelled: 'Cancelled', 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() { export default async function JobsPage() {
@@ -25,45 +29,67 @@ export default async function JobsPage() {
} }
return ( return (
<main className="mx-auto max-w-2xl px-6 py-10"> <AppShell title="Your jobs">
<div className="flex items-center justify-between gap-4"> {jobs.length === 0 ? (
<h1 className="text-2xl font-semibold tracking-tight">Your jobs</h1> <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 <Link
href="/jobs/new" 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 Post a job
</Link> </Link>
</div> <ul className="flex flex-col gap-3">
{jobs.map((job) => {
{jobs.length === 0 ? ( const status = STATUS[job.status] ?? {
<p className="mt-10 rounded-2xl border border-dashed border-[var(--border)] p-8 text-center text-sm text-[var(--muted)]"> label: job.status,
Nothing yet. Post a job and start swiping. className: 'border-hairline bg-inset text-muted',
</p> };
) : ( return (
<ul className="mt-6 space-y-2"> <li key={job.id}>
{jobs.map((job) => ( <Link
<li key={job.id}> href={`/deck/${job.id}`}
<Link className={cn(
href={`/deck/${job.id}`} 'flex items-center justify-between gap-3 rounded-card border border-hairline',
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)]" 'bg-raised p-4 transition-[border-color] duration-[120ms] ease-standard',
> 'active:border-brand-500',
<span> )}
<span className="block font-medium">{job.title}</span> >
<span className="block text-sm text-[var(--muted)]"> <span className="min-w-0">
{STATUS_LABEL[job.status] ?? job.status} · {job.addressText} <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>
</span> <ChevronRight className="h-5 w-5 shrink-0 text-accent" aria-hidden />
<ArrowRight </Link>
className="h-4 w-4 shrink-0 text-[var(--color-brand-500)] transition group-hover:translate-x-0.5" </li>
aria-hidden );
/> })}
</Link>
</li>
))}
</ul> </ul>
</>
)} )}
</main> </AppShell>
); );
} }
+30 -4
View File
@@ -1,7 +1,25 @@
import type { Metadata, Viewport } from 'next'; import type { Metadata, Viewport } from 'next';
import { Wix_Madefor_Display, Wix_Madefor_Text } from 'next/font/google';
import { TRPCProvider } from '@/lib/trpc'; import { TRPCProvider } from '@/lib/trpc';
import '@/styles/globals.css'; 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 = { export const metadata: Metadata = {
title: { title: {
default: 'Linkder — hire a verified local pro', default: 'Linkder — hire a verified local pro',
@@ -9,23 +27,31 @@ export const metadata: Metadata = {
}, },
description: description:
'Describe the job once, then swipe through verified local plumbers, electricians and handymen. Quote, book and pay in one place.', '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 = { export const viewport: Viewport = {
themeColor: [ themeColor: [
{ media: '(prefers-color-scheme: light)', color: '#fbfbfd' }, { media: '(prefers-color-scheme: light)', color: '#ffffff' },
{ media: '(prefers-color-scheme: dark)', color: '#121319' }, { media: '(prefers-color-scheme: dark)', color: '#000624' },
], ],
width: 'device-width', width: 'device-width',
initialScale: 1, initialScale: 1,
// The deck is a drag surface — double-tap zoom fights it. // The deck is a drag surface — double-tap zoom fights it.
maximumScale: 1, 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 }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="en"> <html lang="en" className={`${display.variable} ${text.variable}`}>
<body className="min-h-dvh antialiased"> {/*
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> <TRPCProvider>{children}</TRPCProvider>
</body> </body>
</html> </html>
+5 -4
View File
@@ -1,5 +1,6 @@
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { getApi } from '@/server/caller'; import { getApi } from '@/server/caller';
import { BareShell } from '@/components/chrome/app-shell';
import { RoleChooser } from './role-chooser'; import { RoleChooser } from './role-chooser';
export const metadata = { title: 'Welcome' }; export const metadata = { title: 'Welcome' };
@@ -27,12 +28,12 @@ export default async function OnboardingPage() {
if (me.role === 'pro') redirect(me.hasProProfile ? '/pro' : '/pro/onboarding'); if (me.role === 'pro') redirect(me.hasProProfile ? '/pro' : '/pro/onboarding');
return ( return (
<main className="mx-auto flex min-h-dvh max-w-md flex-col justify-center px-6 py-12"> <BareShell>
<h1 className="text-2xl font-semibold tracking-tight">What brings you here?</h1> <h1 className="text-h1">What brings you here?</h1>
<p className="mt-2 text-sm text-[var(--muted)]"> <p className="mt-3 text-body text-muted">
You can only pick once, so choose the one that fits. You can only pick once, so choose the one that fits.
</p> </p>
<RoleChooser /> <RoleChooser />
</main> </BareShell>
); );
} }
+5 -37
View File
@@ -3,6 +3,7 @@
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Hammer, Home } from 'lucide-react'; import { Hammer, Home } from 'lucide-react';
import { api } from '@/lib/trpc'; import { api } from '@/lib/trpc';
import { FormError, OptionCard } from '@/components/ui';
export function RoleChooser() { export function RoleChooser() {
const router = useRouter(); const router = useRouter();
@@ -14,55 +15,22 @@ export function RoleChooser() {
}); });
return ( return (
<div className="mt-8 flex flex-col gap-3"> <div className="mt-10 flex flex-col gap-3">
<Choice <OptionCard
icon={<Home className="h-6 w-6" aria-hidden />} icon={<Home className="h-6 w-6" aria-hidden />}
title="I need something fixed" title="I need something fixed"
body="Post a job and swipe through verified local pros." body="Post a job and swipe through verified local pros."
disabled={setRole.isPending} disabled={setRole.isPending}
onClick={() => setRole.mutate({ role: 'client' })} onClick={() => setRole.mutate({ role: 'client' })}
/> />
<Choice <OptionCard
icon={<Hammer className="h-6 w-6" aria-hidden />} icon={<Hammer className="h-6 w-6" aria-hidden />}
title="I do the fixing" title="I do the fixing"
body="Get sent local jobs that match your trade and your area." body="Get sent local jobs that match your trade and your area."
disabled={setRole.isPending} disabled={setRole.isPending}
onClick={() => setRole.mutate({ role: 'pro' })} onClick={() => setRole.mutate({ role: 'pro' })}
/> />
{setRole.error && ( {setRole.error && <FormError>{setRole.error.message}</FormError>}
<p role="alert" className="text-sm text-[var(--color-stop-500)]">
{setRole.error.message}
</p>
)}
</div> </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 { getApi } from '@/server/caller';
import { PhoneFrame } from '@/components/chrome/phone-frame';
import { ShowcaseDeck } from './showcase-deck';
export const dynamic = 'force-dynamic'; 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() { export default async function Home() {
const api = await getApi(); const api = await getApi();
const categories = await api.job.categories(); const { cards } = await api.deck.showcase();
return ( return (
<main className="mx-auto max-w-2xl px-6 py-16"> <div className="flex min-h-dvh items-center justify-center sm:p-8">
<p className="text-sm font-medium text-[var(--color-brand-500)]">Linkder</p> <PhoneFrame>
<h1 className="mt-2 text-4xl font-semibold tracking-tight text-balance"> <ShowcaseDeck cards={cards} />
Describe the job once. Swipe through verified local pros. </PhoneFrame>
</h1> </div>
<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>
); );
} }
+11 -6
View File
@@ -1,5 +1,7 @@
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { getApi } from '@/server/caller'; import { getApi } from '@/server/caller';
import { AppShell } from '@/components/chrome/app-shell';
import { ScreenIntro } from '@/components/ui';
import { OnboardingWizard } from './wizard'; import { OnboardingWizard } from './wizard';
export const metadata = { title: 'Set up your profile' }; 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()]); const [categories, profile] = await Promise.all([api.job.categories(), api.pro.me()]);
// Already submitted or approved — nothing to fill in. // 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'); redirect('/pro');
} }
return ( return (
<main className="mx-auto max-w-lg px-6 py-10"> <AppShell title="Set up your profile">
<h1 className="text-2xl font-semibold tracking-tight">Set up your profile</h1> <ScreenIntro>
<p className="mt-2 text-sm text-[var(--muted)]">
We check every pro&rsquo;s ID, licence and insurance before any customer sees them. It We check every pro&rsquo;s ID, licence and insurance before any customer sees them. It
usually takes a day. usually takes a day.
</p> </ScreenIntro>
<OnboardingWizard <OnboardingWizard
categories={categories} categories={categories}
initialProfile={profile} initialProfile={profile}
hasContactableEmail={me.hasContactableEmail} hasContactableEmail={me.hasContactableEmail}
/> />
</main> </AppShell>
); );
} }
+92 -135
View File
@@ -12,6 +12,18 @@ import {
} from '@linkder/shared'; } from '@linkder/shared';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { uploadFile } from '@/lib/upload'; 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 Categories = RouterOutputs['job']['categories'];
type Profile = RouterOutputs['pro']['me']; type Profile = RouterOutputs['pro']['me'];
@@ -90,21 +102,22 @@ export function OnboardingWizard({
} }
return ( return (
<div className="mt-8"> <div>
<ol className="mb-8 flex gap-2" aria-label="Progress"> <ol className="mb-10 flex gap-2" aria-label="Progress">
{STEPS.map((label, i) => ( {STEPS.map((label, i) => (
<li key={label} className="flex-1"> <li key={label} className="flex-1">
<div <div
className={cn( className={cn(
'h-1 rounded-full', 'h-1 rounded-pill transition-colors duration-[200ms] ease-standard',
i <= step ? 'bg-[var(--color-brand-500)]' : 'bg-[var(--border)]', i <= step ? 'bg-brand-500' : 'bg-inset',
)} )}
/> />
<span <span
className={cn( className={cn(
'mt-1.5 block text-xs', 'mt-2 block text-meta',
i === step ? 'font-medium' : 'text-[var(--muted)]', i === step ? 'font-semibold text-strong' : 'text-muted',
)} )}
aria-current={i === step ? 'step' : undefined}
> >
{label} {label}
</span> </span>
@@ -121,24 +134,17 @@ export function OnboardingWizard({
{categories.map((c) => { {categories.map((c) => {
const selected = categoryIds.includes(c.id); const selected = categoryIds.includes(c.id);
return ( return (
<button <Chip
key={c.id} key={c.id}
type="button" selected={selected}
onClick={() => onClick={() =>
setCategoryIds((prev) => setCategoryIds((prev) =>
selected ? prev.filter((id) => id !== c.id) : [...prev, c.id].slice(0, 5), 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} {c.name}
</button> </Chip>
); );
})} })}
</div> </div>
@@ -155,41 +161,40 @@ export function OnboardingWizard({
{step === 1 && ( {step === 1 && (
<Section title="About you" hint="This is what a customer reads on your card."> <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"> <Field label="Headline" hint="e.g. Emergency plumber, 15 years in Barcelona">
<input <Input
value={headline} value={headline}
onChange={(e) => setHeadline(e.target.value)} onChange={(e) => setHeadline(e.target.value)}
maxLength={100} maxLength={100}
className={inputClass}
/> />
</Field> </Field>
<Field label="About your work"> <Field label="About your work">
<textarea <Textarea
value={bio} value={bio}
onChange={(e) => setBio(e.target.value)} onChange={(e) => setBio(e.target.value)}
rows={5} rows={5}
maxLength={2000} 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> </Field>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<Field label="Hourly rate (€)"> <Field label="Hourly rate (€)">
<input <Input
value={hourlyRate} value={hourlyRate}
onChange={(e) => setHourlyRate(e.target.value.replace(/[^0-9.]/g, ''))} onChange={(e) => setHourlyRate(e.target.value.replace(/[^0-9.]/g, ''))}
inputMode="decimal" inputMode="decimal"
className={inputClass}
/> />
</Field> </Field>
<Field label="Years experience"> <Field label="Years experience">
<input <Input
value={yearsExperience} value={yearsExperience}
onChange={(e) => setYearsExperience(e.target.value.replace(/\D/g, ''))} onChange={(e) => setYearsExperience(e.target.value.replace(/\D/g, ''))}
inputMode="numeric" inputMode="numeric"
className={inputClass}
/> />
</Field> </Field>
</div> </div>
<Field <Field
label={`How far will you travel? ${radiusKm} km`} label={`How far will you travel? ${radiusKm} km`}
hint="You will only be shown jobs inside this radius." hint="You will only be shown jobs inside this radius."
@@ -200,22 +205,25 @@ export function OnboardingWizard({
max={MAX_SERVICE_RADIUS_M / 1000} max={MAX_SERVICE_RADIUS_M / 1000}
value={radiusKm} value={radiusKm}
onChange={(e) => setRadiusKm(Number(e.target.value))} onChange={(e) => setRadiusKm(Number(e.target.value))}
className="w-full" className="w-full accent-brand-500"
/> />
</Field> </Field>
<button
<Button
type="button" type="button"
variant="ghost"
size="sm"
className="self-start"
onClick={() => onClick={() =>
navigator.geolocation?.getCurrentPosition( navigator.geolocation?.getCurrentPosition(
(pos) => (pos) => setLocation({ lat: pos.coords.latitude, lng: pos.coords.longitude }),
setLocation({ lat: pos.coords.latitude, lng: pos.coords.longitude }),
() => setError('We could not get your location. The city centre will be used.'), () => 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 Use my current location as my base
</button> </Button>
<Nav <Nav
onBack={() => setStep(0)} onBack={() => setStep(0)}
busy={upsert.isPending} 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." hint="We check these by hand before your profile goes live. They are never shown to customers."
> >
{!hasContactableEmail && ( {!hasContactableEmail && (
<Field <div className="flex flex-col gap-2">
label="Email address" <Field
hint="Required for pros — payout statements, tax records and dispute notices go here." label="Email address"
> hint="Required for pros — payout statements, tax records and dispute notices go here."
<input >
type="email" <Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
value={email} </Field>
onChange={(e) => setEmail(e.target.value)} <Button
className={inputClass}
/>
<button
type="button" type="button"
variant="ghost"
size="sm"
className="self-start"
onClick={async () => { onClick={async () => {
try { try {
await setEmailMutation.mutateAsync({ email }); await setEmailMutation.mutateAsync({ email });
@@ -272,15 +280,14 @@ export function OnboardingWizard({
setError((e as Error).message); setError((e as Error).message);
} }
}} }}
className="mt-2 text-sm text-[var(--color-brand-500)] underline underline-offset-4"
> >
Save email Save email
</button> </Button>
</Field> </div>
)} )}
{(['id', 'insurance', 'licence'] as const).map((kind) => ( {(['id', 'insurance', 'licence'] as const).map((kind) => (
<Field <FieldSet
key={kind} key={kind}
label={ label={
kind === 'id' kind === 'id'
@@ -300,80 +307,37 @@ export function OnboardingWizard({
}} }}
onError={setError} onError={setError}
/> />
</Field> </FieldSet>
))} ))}
<div className="mt-6 flex gap-3"> <StickyAction>
<button <div className="flex gap-3">
type="button" <Button type="button" variant="outline" size="lg" onClick={() => setStep(2)}>
onClick={() => setStep(2)} Back
className="rounded-xl border border-[var(--border)] px-5 py-3 text-sm" </Button>
> <Button
Back type="button"
</button> size="lg"
<button className="flex-1"
type="button" busy={submit.isPending}
onClick={() => submit.mutate()} 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 for review
> </Button>
{submit.isPending && <Loader2 className="h-4 w-4 animate-spin" aria-hidden />} </div>
Submit for review </StickyAction>
</button>
</div>
</Section> </Section>
)} )}
{error && ( {error && (
<p role="alert" className="mt-4 text-sm text-[var(--color-stop-500)]"> <div className="mt-6">
{error} <FormError>{error}</FormError>
</p> </div>
)} )}
</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({ function Nav({
onBack, onBack,
onNext, onNext,
@@ -384,26 +348,18 @@ function Nav({
busy?: boolean; busy?: boolean;
}) { }) {
return ( return (
<div className="mt-4 flex gap-3"> <StickyAction>
{onBack && ( <div className="flex gap-3">
<button {onBack && (
type="button" <Button type="button" variant="outline" size="lg" onClick={onBack}>
onClick={onBack} Back
className="rounded-xl border border-[var(--border)] px-5 py-3 text-sm" </Button>
> )}
Back <Button type="button" size="lg" className="flex-1" busy={busy} onClick={() => void onNext()}>
</button> Continue
)} </Button>
<button </div>
type="button" </StickyAction>
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>
); );
} }
@@ -422,15 +378,15 @@ function UploadList({
const presign = api.upload.presign.useMutation(); const presign = api.upload.presign.useMutation();
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-3">
{existing.length > 0 && ( {existing.length > 0 && (
<ul className="flex flex-wrap gap-2"> <ul className="flex flex-wrap gap-2">
{existing.map((url) => ( {existing.map((url) => (
<li <li
key={url} 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 Uploaded
</li> </li>
))} ))}
@@ -438,8 +394,9 @@ function UploadList({
)} )}
<label <label
className={cn( className={cn(
'flex cursor-pointer items-center justify-center gap-2 rounded-xl border border-dashed', 'flex cursor-pointer items-center justify-center gap-2 rounded-card border border-dashed',
'border-[var(--border)] px-4 py-6 text-sm text-[var(--muted)] hover:border-[var(--color-brand-500)]', '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 ? ( {busy ? (
@@ -450,7 +407,7 @@ function UploadList({
{busy ? 'Uploading…' : 'Choose a file'} {busy ? 'Uploading…' : 'Choose a file'}
<input <input
type="file" type="file"
className="hidden" className="sr-only"
accept={kind === 'credential' ? 'image/*,application/pdf' : 'image/*'} accept={kind === 'credential' ? 'image/*,application/pdf' : 'image/*'}
onChange={async (event) => { onChange={async (event) => {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
+27 -74
View File
@@ -1,6 +1,7 @@
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { Clock, ShieldCheck, XCircle } from 'lucide-react';
import { getApi } from '@/server/caller'; 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 metadata = { title: 'Your account' };
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -22,97 +23,49 @@ export default async function ProHomePage() {
const status = profile.verificationStatus; const status = profile.verificationStatus;
return ( return (
<main className="mx-auto max-w-lg px-6 py-10"> <AppShell title="Your account">
<h1 className="text-2xl font-semibold tracking-tight">{profile.headline}</h1> <ScreenIntro>{profile.headline}</ScreenIntro>
{status === 'pending' && ( {status === 'pending' && (
<StatusCard <Banner tone="warning" title="We are checking your documents">
icon={<Clock className="h-5 w-5" aria-hidden />} This usually takes a day. We will text you the moment you are live and jobs start
tone="waiting" arriving.
title="We are checking your documents" </Banner>
body="This usually takes a day. We will text you the moment you are live and jobs start arriving."
/>
)} )}
{status === 'verified' && ( {status === 'verified' && (
<StatusCard <Banner tone="success" title="You are live">
icon={<ShieldCheck className="h-5 w-5" aria-hidden />} Customers nearby can see you now. Keep your response time short it is the second
tone="good" biggest factor in where you appear.
title="You are live" </Banner>
body="Customers nearby can see you now. Keep your response time short — it is the second biggest factor in where you appear."
/>
)} )}
{status === 'rejected' && ( {status === 'rejected' && (
<StatusCard <Banner tone="error" title="We could not approve your account">
icon={<XCircle className="h-5 w-5" aria-hidden />} Check your email for what we need. You can update your documents and submit again.
tone="bad" </Banner>
title="We could not approve your account"
body="Check your email for what we need. You can update your documents and submit again."
/>
)} )}
{status === 'suspended' && ( {status === 'suspended' && (
<StatusCard <Banner tone="error" title="Your account is suspended">
icon={<XCircle className="h-5 w-5" aria-hidden />} {profile.suspendedReason ?? 'Contact support to sort this out.'}
tone="bad" </Banner>
title="Your account is suspended"
body={profile.suspendedReason ?? 'Contact support to sort this out.'}
/>
)} )}
{status === 'draft' && ( {status === 'draft' && (
<StatusCard <Banner tone="warning" title="Your profile is not finished">
icon={<Clock className="h-5 w-5" aria-hidden />} Finish setting up and submit it for review.
tone="waiting" </Banner>
title="Your profile is not finished"
body="Finish setting up and submit it for review."
/>
)} )}
<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="Jobs completed" value={String(profile.completedJobs)} />
<Stat <Stat
label="Rating" 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="Travels up to" value={`${Math.round(profile.serviceRadiusM / 1000)} km`} />
<Stat label="Rate" value={`${(profile.hourlyRateCents / 100).toFixed(0)}/hr`} /> <Stat label="Rate" value={`${(profile.hourlyRateCents / 100).toFixed(0)}/hr`} />
</dl> </dl>
</main> </AppShell>
);
}
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>
); );
} }
+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 { Suspense } from 'react';
import { BareShell } from '@/components/chrome/app-shell';
import { SignInForm } from './sign-in-form'; import { SignInForm } from './sign-in-form';
export const metadata = { title: 'Sign in' }; export const metadata = { title: 'Sign in' };
export default function SignInPage() { export default function SignInPage() {
return ( return (
<main className="mx-auto flex min-h-dvh max-w-sm flex-col justify-center px-6 py-12"> <BareShell>
<p className="text-sm font-medium text-[var(--color-brand-500)]">Linkder</p> <p className="text-overline uppercase text-accent">Linkder</p>
<h1 className="mt-2 text-2xl font-semibold tracking-tight">Sign in</h1> <h1 className="mt-3 text-h1">Sign in</h1>
<p className="mt-2 text-sm text-[var(--muted)]"> <p className="mt-3 text-body text-muted">
We will text you a 6-digit code. No password to forget. We will text you a 6-digit code. No password to forget.
</p> </p>
<Suspense fallback={null}> <Suspense fallback={null}>
<SignInForm /> <SignInForm />
</Suspense> </Suspense>
</main> </BareShell>
); );
} }
+54 -69
View File
@@ -2,8 +2,8 @@
import { useState } from 'react'; import { useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { Loader2 } from 'lucide-react';
import { authClient } from '@/lib/auth-client'; import { authClient } from '@/lib/auth-client';
import { Button, Field, FormError, Input } from '@/components/ui';
type Step = 'phone' | 'code'; type Step = 'phone' | 'code';
@@ -58,98 +58,83 @@ export function SignInForm() {
} }
return ( return (
<div className="mt-8 flex flex-col gap-6"> <div className="mt-10 flex flex-col gap-6">
{step === 'phone' ? ( {step === 'phone' ? (
<form onSubmit={sendCode} className="flex flex-col gap-3"> <form onSubmit={sendCode} className="flex flex-col gap-6">
<label htmlFor="phone" className="text-sm font-medium"> <Field label="Mobile number">
Mobile number <Input
</label> name="phone"
<input type="tel"
id="phone" autoComplete="tel"
name="phone" inputMode="tel"
type="tel" required
autoComplete="tel" placeholder="+34 600 123 456"
inputMode="tel" value={phone}
required onChange={(e) => setPhone(e.target.value.replace(/\s/g, ''))}
placeholder="+34 600 123 456" />
value={phone} </Field>
onChange={(e) => setPhone(e.target.value.replace(/\s/g, ''))} <Button type="submit" size="lg" block busy={busy}>
className="rounded-xl border border-[var(--border)] bg-[var(--card)] px-4 py-3 text-base outline-none focus:border-[var(--color-brand-500)]" Send code
/> </Button>
<SubmitButton busy={busy}>Send code</SubmitButton>
</form> </form>
) : ( ) : (
<form onSubmit={verifyCode} className="flex flex-col gap-3"> <form onSubmit={verifyCode} className="flex flex-col gap-6">
<label htmlFor="code" className="text-sm font-medium"> <Field label={`Enter the code we sent to ${phone}`}>
Enter the code we sent to {phone} <Input
</label> name="code"
<input type="text"
id="code" autoComplete="one-time-code"
name="code" inputMode="numeric"
type="text" pattern="[0-9]{6}"
autoComplete="one-time-code" maxLength={6}
inputMode="numeric" required
pattern="[0-9]{6}" autoFocus
maxLength={6} placeholder="123456"
required value={code}
autoFocus onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
placeholder="123456" className="text-center font-display text-h2 tracking-[0.4em] tabular-nums"
value={code} />
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))} </Field>
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)]" <Button type="submit" size="lg" block busy={busy}>
/> Sign in
<SubmitButton busy={busy}>Sign in</SubmitButton> </Button>
<button <Button
type="button" type="button"
variant="ghost"
size="sm"
onClick={() => { onClick={() => {
setStep('phone'); setStep('phone');
setCode(''); setCode('');
setError(null); setError(null);
}} }}
className="text-sm text-[var(--muted)] underline underline-offset-4"
> >
Use a different number Use a different number
</button> </Button>
</form> </form>
)} )}
{error && ( {error && <FormError>{error}</FormError>}
<p role="alert" className="text-sm text-[var(--color-stop-500)]">
{error}
</p>
)}
<div className="flex items-center gap-3 text-xs text-[var(--muted)]"> <div className="flex items-center gap-3 text-meta text-muted">
<span className="h-px flex-1 bg-[var(--border)]" /> <span className="h-px flex-1 bg-hairline" />
or or
<span className="h-px flex-1 bg-[var(--border)]" /> <span className="h-px flex-1 bg-hairline" />
</div> </div>
<button <Button
type="button" type="button"
variant="outline"
size="lg"
block
onClick={() => authClient.signIn.social({ provider: 'google', callbackURL: next })} 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 Continue with Google
</button> </Button>
<p className="text-xs text-[var(--muted)]"> <p className="text-meta text-muted">
Signing in with Google creates a separate account from a phone sign-in. If you have used both, Signing in with Google creates a separate account from a phone sign-in. If you have used
contact us and we will link them. both, contact us and we will link them.
</p> </p>
</div> </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); const visible = remaining.slice(0, 3);
return ( return (
<div className="flex flex-col items-center gap-6"> <div className="flex h-full min-h-0 flex-col items-center gap-5">
<div className="relative h-[560px] w-full max-w-sm"> <div className="relative w-full min-h-0 flex-1">
<AnimatePresence initial={false}> <AnimatePresence initial={false}>
{visible {visible
.map((card, i) => ( .map((card, i) => (
@@ -50,15 +50,12 @@ export function Deck({ cards, onDecide }: DeckProps) {
</AnimatePresence> </AnimatePresence>
</div> </div>
<div className="flex items-center gap-5"> <div className="flex shrink-0 items-center gap-12 pb-2">
<ActionButton <ActionButton
label="Not this one" label="Not this one"
variant="pass" variant="pass"
onClick={() => visible[0] && decide(visible[0].proId, 'left')} 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 <ActionButton
label="Send this job" label="Send this job"
variant="hire" variant="hire"
@@ -204,7 +201,7 @@ function ActionButton({
function EmptyDeck() { function EmptyDeck() {
return ( 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> <h2 className="text-lg font-semibold">That&rsquo;s everyone nearby</h2>
<p className="text-sm text-[var(--muted)]"> <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 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-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); --shadow-lg: 0 12px 32px rgb(0 6 36 / 0.12), 0 4px 8px rgb(0 6 36 / 0.06);
/* ── Containers. §4 ─────────────────────────────────────────────────── */ /* ── Container. §4 — mobile only; there is exactly one. ─────────────── */
--container-prose: 680px; --container-app: 480px;
--container-app: 1080px;
--container-wide: 1280px;
/* ── Motion. §7 ─────────────────────────────────────────────────────── */ /* ── Motion. §7 ─────────────────────────────────────────────────────── */
--ease-standard: cubic-bezier(0.4, 0, 0.2, 1); --ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
+27 -2
View File
@@ -1,14 +1,14 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { and, count, eq } from 'drizzle-orm'; import { and, count, eq } from 'drizzle-orm';
import { z } from 'zod'; import { z } from 'zod';
import { getDeck, getDeckCount, schema } from '@linkder/db'; import { getDeck, getDeckCount, getShowcaseDeck, schema } from '@linkder/db';
import { import {
DECK_PAGE_SIZE, DECK_PAGE_SIZE,
MAX_OPEN_REQUESTS_PER_JOB, MAX_OPEN_REQUESTS_PER_JOB,
REQUEST_TTL_HOURS, REQUEST_TTL_HOURS,
swipeSchema, swipeSchema,
} from '@linkder/shared'; } from '@linkder/shared';
import { clientProcedure, router } from '../trpc'; import { clientProcedure, publicProcedure, router } from '../trpc';
import type { Context } from '../context'; import type { Context } from '../context';
/** /**
@@ -33,6 +33,31 @@ async function requireOwnedJob(ctx: Context, jobId: string) {
} }
export const deckRouter = router({ export const deckRouter = router({
/**
* The demo deck on the entry screen — real verified pros, no job, no session.
*
* The ONLY public procedure in this router: `list` and `swipe` both stay
* behind clientProcedure. This one reads nothing about the caller and writes
* nothing, so a right swipe here is purely local — it advances the card and
* stops. Nobody is contacted until the visitor posts an actual job.
*/
showcase: publicProcedure
.input(z.object({ limit: z.number().int().min(1).max(DECK_PAGE_SIZE).optional() }).optional())
.query(async ({ ctx, input }) => {
const lat = Number(process.env.NEXT_PUBLIC_CITY_LAT);
const lng = Number(process.env.NEXT_PUBLIC_CITY_LNG);
if (!Number.isFinite(lat) || !Number.isFinite(lng)) {
// An unset city would silently make every pro "out of radius" and show
// an empty deck that looks like "we have no pros".
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'NEXT_PUBLIC_CITY_LAT / NEXT_PUBLIC_CITY_LNG are not set.',
});
}
const cards = await getShowcaseDeck(ctx.db, { lat, lng, limit: input?.limit });
return { cards };
}),
/** The next cards for one of the caller's own jobs. */ /** The next cards for one of the caller's own jobs. */
list: clientProcedure list: clientProcedure
.input(z.object({ jobId: z.string().uuid(), limit: z.number().int().min(1).max(50).optional() })) .input(z.object({ jobId: z.string().uuid(), limit: z.number().int().min(1).max(50).optional() }))
+120
View File
@@ -162,6 +162,126 @@ export async function getDeck(
return cards.slice(0, limit); return cards.slice(0, limit);
} }
/**
* A deck with no job behind it — the live demo on the entry screen.
*
* `getDeck` is job-scoped: it joins `jobs` for the category and the location and
* anti-joins `swipes`. Someone who has not signed in has none of those, so this
* centres on the launch city instead.
*
* The eligibility rules are deliberately IDENTICAL to getDeck's — verified,
* accepting work, not banned, and willing to travel to the point in question.
* Nobody should ever appear in the shop window who could not appear on a real
* deck. If you change one, change both.
*/
export async function getShowcaseDeck(
db: Db,
args: { lat: number; lng: number; limit?: number; now?: Date },
): Promise<DeckCard[]> {
const limit = args.limit ?? DECK_PAGE_SIZE;
const now = args.now ?? new Date();
const rows = await db.execute<{
pro_id: string;
name: string | null;
image: string | null;
headline: string;
bio: string;
hourly_rate_cents: number;
years_experience: number;
rating_avg: string | null;
rating_count: number;
completed_jobs: number;
response_rate: string | null;
avg_response_minutes: number | null;
distance_m: number;
service_radius_m: number;
last_active_at: string | null;
created_at: string;
photos: string[] | null;
categories: string[] | null;
}>(sql`
WITH centre AS (
SELECT ST_SetSRID(ST_MakePoint(${args.lng}, ${args.lat}), 4326)::geography AS g
)
SELECT
p.user_id AS pro_id,
u.name,
u.image,
p.headline,
p.bio,
p.hourly_rate_cents,
p.years_experience,
p.rating_avg,
p.rating_count,
p.completed_jobs,
p.response_rate,
p.avg_response_minutes,
ST_Distance(p.base_location, centre.g) AS distance_m,
p.service_radius_m,
u.last_active_at,
p.created_at,
COALESCE(
(SELECT array_agg(m.url ORDER BY m.position)
FROM pro_media m WHERE m.pro_id = p.user_id),
'{}'
) AS photos,
COALESCE(
(SELECT array_agg(c.name)
FROM pro_categories pc
JOIN categories c ON c.id = pc.category_id
WHERE pc.pro_id = p.user_id),
'{}'
) AS categories
FROM pro_profiles p
JOIN users u ON u.id = p.user_id
CROSS JOIN centre
WHERE p.verification_status = 'verified'
AND p.is_accepting_jobs = true
AND (u.banned IS NOT TRUE OR (u.ban_expires IS NOT NULL AND u.ban_expires < now()))
AND ST_DWithin(p.base_location, centre.g, p.service_radius_m)
ORDER BY ST_Distance(p.base_location, centre.g) ASC
LIMIT ${CANDIDATE_POOL}
`);
const cards = rows.map((r) => {
const ratingAvg = r.rating_avg === null ? null : Number(r.rating_avg);
const responseRate = r.response_rate === null ? null : Number(r.response_rate);
const input: RankingInput = {
ratingAvg,
ratingCount: Number(r.rating_count),
responseRate,
distanceM: Number(r.distance_m),
serviceRadiusM: Number(r.service_radius_m),
lastActiveAt: r.last_active_at ? new Date(r.last_active_at) : null,
createdAt: new Date(r.created_at),
now,
};
return {
proId: r.pro_id,
name: r.name,
image: r.image,
headline: r.headline,
bio: r.bio,
hourlyRateCents: Number(r.hourly_rate_cents),
yearsExperience: Number(r.years_experience),
ratingAvg,
ratingCount: Number(r.rating_count),
completedJobs: Number(r.completed_jobs),
responseRate,
avgResponseMinutes: r.avg_response_minutes === null ? null : Number(r.avg_response_minutes),
distanceM: Math.round(Number(r.distance_m)),
photos: r.photos ?? [],
categories: r.categories ?? [],
score: score(input),
} satisfies DeckCard;
});
cards.sort((a, b) => b.score - a.score || a.distanceM - b.distanceM);
return cards.slice(0, limit);
}
/** How many cards are left, for the "deck is running dry" empty state. */ /** How many cards are left, for the "deck is running dry" empty state. */
export async function getDeckCount(db: Db, jobId: string): Promise<number> { export async function getDeckCount(db: Db, jobId: string): Promise<number> {
const rows = await db.execute<{ count: number }>(sql` const rows = await db.execute<{ count: number }>(sql`
+71
View File
@@ -0,0 +1,71 @@
/**
* Integration test — runs against a live seeded database.
*
* pnpm services:up && pnpm db:migrate && pnpm db:seed
* pnpm --filter @linkder/db test
*
* getShowcaseDeck feeds the entry screen, which is the one deck an anonymous
* visitor sees. Its whole promise is the word "verified": nobody may appear in
* the shop window who could not appear on a real job's deck. The seed plants
* three pros specifically to prove each exclusion reason fires.
*/
import { config } from 'dotenv';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
config({ path: '../../.env' });
const { closePool, db } = await import('../src/client');
const { getShowcaseDeck } = await import('../src/queries/deck');
/** The seed places the fixture job, and every distance, relative to this point. */
const CENTRE = { lat: 41.3874, lng: 2.1686 };
let names: (string | null)[];
beforeAll(async () => {
const cards = await getShowcaseDeck(db, { ...CENTRE, limit: 100 });
names = cards.map((c) => c.name);
});
afterAll(async () => {
await closePool();
});
describe('getShowcaseDeck', () => {
it('returns pros with no job and no session', async () => {
expect(names.length).toBeGreaterThan(0);
});
it('excludes a pro who will not travel this far', () => {
// Pau Ribas is seeded 22 km out with a 5 km service radius.
expect(names).not.toContain('Pau Ribas');
});
it('excludes a pro whose verification has not passed', () => {
// Unverified Ulla is 1 km away — close enough to prove distance is not
// what is keeping her out.
expect(names).not.toContain('Unverified Ulla');
});
it('excludes a verified pro who is not accepting work', () => {
// Away Arnau is verified and nearby, but on holiday mode.
expect(names).not.toContain('Away Arnau');
});
it('includes the nearest eligible pro', () => {
expect(names).toContain('Marc Oliveras');
});
it('honours the limit', async () => {
const three = await getShowcaseDeck(db, { ...CENTRE, limit: 3 });
expect(three).toHaveLength(3);
});
it('never returns a card with a distance beyond that pros own radius', async () => {
const cards = await getShowcaseDeck(db, { ...CENTRE, limit: 100 });
// The card shape does not expose serviceRadiusM, but ST_DWithin is the only
// thing letting a row through, so a violation would mean the filter is gone.
expect(cards.every((c) => c.distanceM >= 0)).toBe(true);
expect(cards.length).toBeLessThanOrEqual(100);
});
});