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:
@@ -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 360–430px 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.
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)]';
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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’s ID, licence and insurance before any customer sees them. It
|
We check every pro’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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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’s everyone nearby</h2>
|
<h2 className="text-lg font-semibold">That’s everyone nearby</h2>
|
||||||
<p className="text-sm text-[var(--muted)]">
|
<p className="text-sm text-[var(--muted)]">
|
||||||
You’ve seen every verified pro who covers your area for this trade. We’ll notify
|
You’ve seen every verified pro who covers your area for this trade. We’ll notify
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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() }))
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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 pro’s 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user