Entry screen is the app: phone frame with a live deck inside
The homepage was a marketing brochure -- a hero paragraph, a "three steps" explainer and a tag list. It described the gesture in prose while the actual Tinder deck sat behind /deck/[jobId], reachable only after signing in AND posting a job. Nobody opening the app ever saw the product. Now / renders a phone illustration with the real app running inside it: the same <Deck>, the same drag physics, real verified pros. On a phone the bezel collapses and the deck simply fills the viewport -- drawing a picture of a phone on a phone is absurd, and it would eat the width the cards need. - Removed the fixed app bar and bottom tab bar. AppShell now renders the screen title as an in-flow h1; the bar owned the only h1 on every screen, so dropping it silently would have left every page headingless. The /jobs "post" action moved from bar chrome into the content, since the tab bar was its only other route there. - getShowcaseDeck(): a deck with no job behind it. getDeck is job-scoped (joins jobs for category and location, anti-joins swipes), which an anonymous visitor has none of, so this centres on the launch city. Eligibility rules are copied verbatim -- nobody may appear in the shop window who could not appear on a real deck. - deck.showcase: the only public procedure in the router. list and swipe stay behind clientProcedure. It reads nothing about the caller and writes nothing, so a right swipe on the entry screen is purely local. No real tradesperson is contacted until a job is posted. - <Deck> filled a hardcoded 560px desktop box; it now fills its container. The card counter moved out from between the two action buttons so the thumb zone holds nothing but the two controls. Verified against the seeded database: 22 eligible pros returned, and all three seeded traps excluded for the right reason -- Pau Ribas (22km out, 5km radius), Unverified Ulla (pending), Away Arnau (not accepting). 7 new integration tests cover exactly that. Also corrects a label I had written as "Plumbers near you" -- the showcase deck is not category-filtered and shows every trade. Includes concurrent edits to the mobile shell, ui/ primitives and DESIGN.md made outside this session. typecheck, lint, build clean; 141 tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,7 @@
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { getApi } from '@/server/caller';
|
||||
import { DeckShell } from '@/components/chrome/app-shell';
|
||||
import { DeckClient } from './deck-client';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
@@ -26,31 +25,19 @@ export default async function DeckPage({ params }: { params: Promise<{ jobId: st
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-dvh max-w-2xl flex-col px-4 py-6">
|
||||
<header className="mb-6">
|
||||
<Link
|
||||
href="/jobs"
|
||||
className="mb-4 inline-flex items-center gap-1.5 text-sm text-[var(--muted)] hover:text-[var(--fg)]"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" aria-hidden />
|
||||
My jobs
|
||||
</Link>
|
||||
<p className="text-sm text-[var(--muted)]">
|
||||
{job.category.name} · {job.addressText}
|
||||
</p>
|
||||
<h1 className="text-xl font-semibold">{job.title}</h1>
|
||||
<DeckShell>
|
||||
<h1 className="text-h3">{job.title}</h1>
|
||||
<p className="mb-4 mt-1 text-body-sm text-muted">
|
||||
{job.category.name} · {job.addressText}
|
||||
{job.pendingRequests > 0 && (
|
||||
<p className="mt-1 text-sm text-[var(--muted)]">
|
||||
{job.pendingRequests} pro{job.pendingRequests === 1 ? '' : 's'} already considering this
|
||||
</p>
|
||||
<>
|
||||
{' · '}
|
||||
{job.pendingRequests} pro{job.pendingRequests === 1 ? '' : 's'} considering this
|
||||
</>
|
||||
)}
|
||||
</header>
|
||||
</p>
|
||||
|
||||
<DeckClient jobId={jobId} initialCards={deck.cards} />
|
||||
|
||||
<p className="mt-8 text-center text-xs text-[var(--muted)]">
|
||||
Swipe right to send this job to a pro, left to pass. Drag the card or use the buttons.
|
||||
</p>
|
||||
</main>
|
||||
</DeckShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,10 +2,20 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import type { RouterOutputs } from '@/lib/trpc';
|
||||
import { api } from '@/lib/trpc';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Button,
|
||||
Chip,
|
||||
Field,
|
||||
FieldNote,
|
||||
FieldSet,
|
||||
FormError,
|
||||
Input,
|
||||
OptionCard,
|
||||
StickyAction,
|
||||
Textarea,
|
||||
} from '@/components/ui';
|
||||
|
||||
type Categories = RouterOutputs['job']['categories'];
|
||||
|
||||
@@ -65,44 +75,34 @@ export function NewJobForm({ categories }: { categories: Categories }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="mt-8 flex flex-col gap-5">
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<legend className="text-sm font-medium">Trade</legend>
|
||||
<div className="mt-1 flex flex-wrap gap-2">
|
||||
<form onSubmit={submit} className="flex flex-col gap-6">
|
||||
<FieldSet label="Trade">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{categories.map((c) => (
|
||||
<button
|
||||
<Chip
|
||||
key={c.id}
|
||||
type="button"
|
||||
selected={categoryId === c.id}
|
||||
onClick={() => setCategoryId(c.id)}
|
||||
className={cn(
|
||||
'rounded-full border px-4 py-2 text-sm transition',
|
||||
categoryId === c.id
|
||||
? 'border-[var(--color-brand-500)] bg-[var(--color-brand-500)]/10 font-medium'
|
||||
: 'border-[var(--border)]',
|
||||
)}
|
||||
>
|
||||
{c.name}
|
||||
</button>
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
</FieldSet>
|
||||
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-sm font-medium">Short summary</span>
|
||||
<input
|
||||
<Field label="Short summary">
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
required
|
||||
minLength={5}
|
||||
maxLength={120}
|
||||
placeholder="Kitchen sink leaking under the cupboard"
|
||||
className={inputClass}
|
||||
/>
|
||||
</label>
|
||||
</Field>
|
||||
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-sm font-medium">What is wrong?</span>
|
||||
<textarea
|
||||
<Field label="What is wrong?">
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
required
|
||||
@@ -110,99 +110,77 @@ export function NewJobForm({ categories }: { categories: Categories }) {
|
||||
maxLength={4000}
|
||||
rows={5}
|
||||
placeholder="The more detail you give, the more accurate the quotes you get back."
|
||||
className={inputClass}
|
||||
/>
|
||||
<span className="text-xs text-[var(--muted)]">{description.length}/4000, minimum 20</span>
|
||||
</label>
|
||||
<FieldNote>{description.length}/4000, minimum 20</FieldNote>
|
||||
</Field>
|
||||
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<legend className="text-sm font-medium">How soon?</legend>
|
||||
<div className="mt-1 flex flex-col gap-2">
|
||||
<FieldSet label="How soon?">
|
||||
<div className="flex flex-col gap-3">
|
||||
{URGENCIES.map((u) => (
|
||||
<button
|
||||
<OptionCard
|
||||
key={u.value}
|
||||
type="button"
|
||||
selected={urgency === u.value}
|
||||
title={u.label}
|
||||
body={u.hint}
|
||||
onClick={() => setUrgency(u.value)}
|
||||
className={cn(
|
||||
'rounded-xl border px-4 py-3 text-left text-sm transition',
|
||||
urgency === u.value
|
||||
? 'border-[var(--color-brand-500)] bg-[var(--color-brand-500)]/10'
|
||||
: 'border-[var(--border)]',
|
||||
)}
|
||||
>
|
||||
<span className="block font-medium">{u.label}</span>
|
||||
<span className="text-xs text-[var(--muted)]">{u.hint}</span>
|
||||
</button>
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
</FieldSet>
|
||||
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-sm font-medium">Address</span>
|
||||
<span className="text-xs text-[var(--muted)]">
|
||||
Only shared with a pro once you have booked them.
|
||||
</span>
|
||||
<input
|
||||
value={addressText}
|
||||
onChange={(e) => setAddressText(e.target.value)}
|
||||
required
|
||||
minLength={3}
|
||||
maxLength={255}
|
||||
className={inputClass}
|
||||
/>
|
||||
<button
|
||||
<div className="flex flex-col gap-2">
|
||||
<Field label="Address" hint="Only shared with a pro once you have booked them.">
|
||||
<Input
|
||||
value={addressText}
|
||||
onChange={(e) => setAddressText(e.target.value)}
|
||||
required
|
||||
minLength={3}
|
||||
maxLength={255}
|
||||
/>
|
||||
</Field>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="self-start"
|
||||
onClick={() =>
|
||||
navigator.geolocation?.getCurrentPosition(
|
||||
(pos) => setLocation({ lat: pos.coords.latitude, lng: pos.coords.longitude }),
|
||||
() => setError('We could not get your location, so we will search from the centre.'),
|
||||
)
|
||||
}
|
||||
className="self-start text-sm text-[var(--color-brand-500)] underline underline-offset-4"
|
||||
>
|
||||
Use my current location
|
||||
</button>
|
||||
</label>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<fieldset className="grid grid-cols-2 gap-3">
|
||||
<legend className="mb-2 text-sm font-medium">Budget (optional)</legend>
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-xs text-[var(--muted)]">From €</span>
|
||||
<input
|
||||
value={budgetMin}
|
||||
onChange={(e) => setBudgetMin(e.target.value.replace(/[^0-9.]/g, ''))}
|
||||
inputMode="decimal"
|
||||
className={inputClass}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-xs text-[var(--muted)]">To €</span>
|
||||
<input
|
||||
value={budgetMax}
|
||||
onChange={(e) => setBudgetMax(e.target.value.replace(/[^0-9.]/g, ''))}
|
||||
inputMode="decimal"
|
||||
className={inputClass}
|
||||
/>
|
||||
</label>
|
||||
</fieldset>
|
||||
<FieldSet label="Budget (optional)">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="From €">
|
||||
<Input
|
||||
value={budgetMin}
|
||||
onChange={(e) => setBudgetMin(e.target.value.replace(/[^0-9.]/g, ''))}
|
||||
inputMode="decimal"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="To €">
|
||||
<Input
|
||||
value={budgetMax}
|
||||
onChange={(e) => setBudgetMax(e.target.value.replace(/[^0-9.]/g, ''))}
|
||||
inputMode="decimal"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</FieldSet>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-[var(--color-stop-500)]">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{error && <FormError>{error}</FormError>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={create.isPending || !categoryId}
|
||||
className="flex items-center justify-center gap-2 rounded-xl bg-[var(--color-brand-500)] px-5 py-3 font-medium text-white transition hover:bg-[var(--color-brand-600)] disabled:opacity-60"
|
||||
>
|
||||
{create.isPending && <Loader2 className="h-4 w-4 animate-spin" aria-hidden />}
|
||||
Find me a pro
|
||||
</button>
|
||||
{/* §9 — the primary action stays in thumb reach on a long form. */}
|
||||
<StickyAction>
|
||||
<Button type="submit" size="lg" block busy={create.isPending} disabled={!categoryId}>
|
||||
Find me a pro
|
||||
</Button>
|
||||
</StickyAction>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
'w-full rounded-xl border border-[var(--border)] bg-[var(--card)] px-4 py-3 text-base outline-none focus:border-[var(--color-brand-500)]';
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getApi } from '@/server/caller';
|
||||
import { AppShell } from '@/components/chrome/app-shell';
|
||||
import { ScreenIntro } from '@/components/ui';
|
||||
import { NewJobForm } from './form';
|
||||
|
||||
export const metadata = { title: 'Post a job' };
|
||||
@@ -19,12 +21,11 @@ export default async function NewJobPage() {
|
||||
const categories = await api.job.categories();
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-lg px-6 py-10">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">What needs doing?</h1>
|
||||
<p className="mt-2 text-sm text-[var(--muted)]">
|
||||
<AppShell title="Post a job">
|
||||
<ScreenIntro>
|
||||
Describe it once. We will show you verified pros nearby who can take it on.
|
||||
</p>
|
||||
</ScreenIntro>
|
||||
<NewJobForm categories={categories} />
|
||||
</main>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { ArrowRight, Plus } from 'lucide-react';
|
||||
import { ChevronRight, Plus } from 'lucide-react';
|
||||
import { getApi } from '@/server/caller';
|
||||
import { AppShell } from '@/components/chrome/app-shell';
|
||||
import { buttonClasses, EmptyState } from '@/components/ui';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export const metadata = { title: 'Your jobs' };
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
open: 'Looking for pros',
|
||||
matched: 'Pros interested',
|
||||
booked: 'Booked',
|
||||
completed: 'Done',
|
||||
cancelled: 'Cancelled',
|
||||
/** Label plus the tint it carries. §8 — status is never colour alone. */
|
||||
const STATUS: Record<string, { label: string; className: string }> = {
|
||||
open: { label: 'Looking for pros', className: 'border-brand-200 bg-brand-100 text-ink-950' },
|
||||
matched: { label: 'Pros interested', className: 'border-go-100 bg-go-50 text-go-700' },
|
||||
booked: { label: 'Booked', className: 'border-go-100 bg-go-50 text-go-700' },
|
||||
completed: { label: 'Done', className: 'border-hairline bg-inset text-muted' },
|
||||
cancelled: { label: 'Cancelled', className: 'border-stop-100 bg-stop-50 text-stop-600' },
|
||||
};
|
||||
|
||||
export default async function JobsPage() {
|
||||
@@ -25,45 +29,67 @@ export default async function JobsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-2xl px-6 py-10">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Your jobs</h1>
|
||||
<AppShell title="Your jobs">
|
||||
{jobs.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No jobs yet"
|
||||
body="Post a job and we will build you a deck of verified pros who cover your area."
|
||||
action={
|
||||
<Link
|
||||
href="/jobs/new"
|
||||
className={cn('mt-2', buttonClasses({ variant: 'primary', size: 'md' }))}
|
||||
>
|
||||
Post a job
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Link
|
||||
href="/jobs/new"
|
||||
className="inline-flex items-center gap-1.5 rounded-xl bg-[var(--color-brand-500)] px-4 py-2.5 text-sm font-medium text-white"
|
||||
className={cn('mb-4', buttonClasses({ variant: 'primary', size: 'md', block: true }))}
|
||||
>
|
||||
<Plus className="h-4 w-4" aria-hidden />
|
||||
<Plus className="h-5 w-5" aria-hidden />
|
||||
Post a job
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{jobs.length === 0 ? (
|
||||
<p className="mt-10 rounded-2xl border border-dashed border-[var(--border)] p-8 text-center text-sm text-[var(--muted)]">
|
||||
Nothing yet. Post a job and start swiping.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mt-6 space-y-2">
|
||||
{jobs.map((job) => (
|
||||
<li key={job.id}>
|
||||
<Link
|
||||
href={`/deck/${job.id}`}
|
||||
className="group flex items-center justify-between gap-4 rounded-xl border border-[var(--border)] bg-[var(--card)] px-4 py-3 transition hover:border-[var(--color-brand-500)]"
|
||||
>
|
||||
<span>
|
||||
<span className="block font-medium">{job.title}</span>
|
||||
<span className="block text-sm text-[var(--muted)]">
|
||||
{STATUS_LABEL[job.status] ?? job.status} · {job.addressText}
|
||||
<ul className="flex flex-col gap-3">
|
||||
{jobs.map((job) => {
|
||||
const status = STATUS[job.status] ?? {
|
||||
label: job.status,
|
||||
className: 'border-hairline bg-inset text-muted',
|
||||
};
|
||||
return (
|
||||
<li key={job.id}>
|
||||
<Link
|
||||
href={`/deck/${job.id}`}
|
||||
className={cn(
|
||||
'flex items-center justify-between gap-3 rounded-card border border-hairline',
|
||||
'bg-raised p-4 transition-[border-color] duration-[120ms] ease-standard',
|
||||
'active:border-brand-500',
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block font-display text-h4">{job.title}</span>
|
||||
<span className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-pill border px-3 py-1 text-meta',
|
||||
status.className,
|
||||
)}
|
||||
>
|
||||
{status.label}
|
||||
</span>
|
||||
<span className="truncate text-body-sm text-muted">{job.addressText}</span>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<ArrowRight
|
||||
className="h-4 w-4 shrink-0 text-[var(--color-brand-500)] transition group-hover:translate-x-0.5"
|
||||
aria-hidden
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
<ChevronRight className="h-5 w-5 shrink-0 text-accent" aria-hidden />
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,25 @@
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { Wix_Madefor_Display, Wix_Madefor_Text } from 'next/font/google';
|
||||
import { TRPCProvider } from '@/lib/trpc';
|
||||
import '@/styles/globals.css';
|
||||
|
||||
/**
|
||||
* DESIGN.md §3.1. Self-hosted through next/font rather than a <link> to
|
||||
* fonts.googleapis.com — that link is a render-blocking round trip on the
|
||||
* critical path, and the deck is the first thing people see.
|
||||
*/
|
||||
const display = Wix_Madefor_Display({
|
||||
subsets: ['latin'],
|
||||
display: 'swap',
|
||||
variable: '--font-madefor-display',
|
||||
});
|
||||
|
||||
const text = Wix_Madefor_Text({
|
||||
subsets: ['latin'],
|
||||
display: 'swap',
|
||||
variable: '--font-madefor-text',
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: 'Linkder — hire a verified local pro',
|
||||
@@ -9,23 +27,31 @@ export const metadata: Metadata = {
|
||||
},
|
||||
description:
|
||||
'Describe the job once, then swipe through verified local plumbers, electricians and handymen. Quote, book and pay in one place.',
|
||||
appleWebApp: { capable: true, statusBarStyle: 'default', title: 'Linkder' },
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: [
|
||||
{ media: '(prefers-color-scheme: light)', color: '#fbfbfd' },
|
||||
{ media: '(prefers-color-scheme: dark)', color: '#121319' },
|
||||
{ media: '(prefers-color-scheme: light)', color: '#ffffff' },
|
||||
{ media: '(prefers-color-scheme: dark)', color: '#000624' },
|
||||
],
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
// The deck is a drag surface — double-tap zoom fights it.
|
||||
maximumScale: 1,
|
||||
// Required for env(safe-area-inset-*) to report anything but zero. §4.1
|
||||
viewportFit: 'cover',
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className="min-h-dvh antialiased">
|
||||
<html lang="en" className={`${display.variable} ${text.variable}`}>
|
||||
{/*
|
||||
The app is a single 480px column, but the column is owned by the screen
|
||||
shells rather than by this layout — the entry screen renders a phone
|
||||
illustration that has to go full-bleed past it. §4
|
||||
*/}
|
||||
<body className="bg-sunken text-strong antialiased">
|
||||
<TRPCProvider>{children}</TRPCProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getApi } from '@/server/caller';
|
||||
import { BareShell } from '@/components/chrome/app-shell';
|
||||
import { RoleChooser } from './role-chooser';
|
||||
|
||||
export const metadata = { title: 'Welcome' };
|
||||
@@ -27,12 +28,12 @@ export default async function OnboardingPage() {
|
||||
if (me.role === 'pro') redirect(me.hasProProfile ? '/pro' : '/pro/onboarding');
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-dvh max-w-md flex-col justify-center px-6 py-12">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">What brings you here?</h1>
|
||||
<p className="mt-2 text-sm text-[var(--muted)]">
|
||||
<BareShell>
|
||||
<h1 className="text-h1">What brings you here?</h1>
|
||||
<p className="mt-3 text-body text-muted">
|
||||
You can only pick once, so choose the one that fits.
|
||||
</p>
|
||||
<RoleChooser />
|
||||
</main>
|
||||
</BareShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Hammer, Home } from 'lucide-react';
|
||||
import { api } from '@/lib/trpc';
|
||||
import { FormError, OptionCard } from '@/components/ui';
|
||||
|
||||
export function RoleChooser() {
|
||||
const router = useRouter();
|
||||
@@ -14,55 +15,22 @@ export function RoleChooser() {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mt-8 flex flex-col gap-3">
|
||||
<Choice
|
||||
<div className="mt-10 flex flex-col gap-3">
|
||||
<OptionCard
|
||||
icon={<Home className="h-6 w-6" aria-hidden />}
|
||||
title="I need something fixed"
|
||||
body="Post a job and swipe through verified local pros."
|
||||
disabled={setRole.isPending}
|
||||
onClick={() => setRole.mutate({ role: 'client' })}
|
||||
/>
|
||||
<Choice
|
||||
<OptionCard
|
||||
icon={<Hammer className="h-6 w-6" aria-hidden />}
|
||||
title="I do the fixing"
|
||||
body="Get sent local jobs that match your trade and your area."
|
||||
disabled={setRole.isPending}
|
||||
onClick={() => setRole.mutate({ role: 'pro' })}
|
||||
/>
|
||||
{setRole.error && (
|
||||
<p role="alert" className="text-sm text-[var(--color-stop-500)]">
|
||||
{setRole.error.message}
|
||||
</p>
|
||||
)}
|
||||
{setRole.error && <FormError>{setRole.error.message}</FormError>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Choice({
|
||||
icon,
|
||||
title,
|
||||
body,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
body: string;
|
||||
disabled: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className="flex items-start gap-4 rounded-2xl border border-[var(--border)] bg-[var(--card)] p-5 text-left transition hover:border-[var(--color-brand-500)] disabled:opacity-60"
|
||||
>
|
||||
<span className="mt-0.5 text-[var(--color-brand-500)]">{icon}</span>
|
||||
<span>
|
||||
<span className="block font-medium">{title}</span>
|
||||
<span className="mt-0.5 block text-sm text-[var(--muted)]">{body}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
+20
-43
@@ -1,53 +1,30 @@
|
||||
import Link from 'next/link';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { getApi } from '@/server/caller';
|
||||
import { PhoneFrame } from '@/components/chrome/phone-frame';
|
||||
import { ShowcaseDeck } from './showcase-deck';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* The entry screen.
|
||||
*
|
||||
* Not a marketing page. This is the product itself, running: a phone with a
|
||||
* live, draggable deck of real verified pros inside it. Linkder's whole promise
|
||||
* is a gesture, and a paragraph describing a gesture is worth nothing next to
|
||||
* being able to do it.
|
||||
*
|
||||
* On a phone the frame collapses and the deck simply is the screen (see
|
||||
* PhoneFrame) — this is a mobile product, and the illustration exists only so
|
||||
* the desktop visitor understands that.
|
||||
*/
|
||||
export default async function Home() {
|
||||
const api = await getApi();
|
||||
const categories = await api.job.categories();
|
||||
const { cards } = await api.deck.showcase();
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-2xl px-6 py-16">
|
||||
<p className="text-sm font-medium text-[var(--color-brand-500)]">Linkder</p>
|
||||
<h1 className="mt-2 text-4xl font-semibold tracking-tight text-balance">
|
||||
Describe the job once. Swipe through verified local pros.
|
||||
</h1>
|
||||
<p className="mt-4 text-lg text-[var(--muted)] text-pretty">
|
||||
Every pro on the deck has had their ID, trade licence and insurance checked. Quote, book and
|
||||
pay in one place — your money is held until the work is done.
|
||||
</p>
|
||||
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
<Link
|
||||
href="/sign-in"
|
||||
className="rounded-xl bg-[var(--color-brand-500)] px-5 py-3 font-medium text-white transition hover:bg-[var(--color-brand-600)]"
|
||||
>
|
||||
Post a job
|
||||
</Link>
|
||||
<Link
|
||||
href="/sign-in?next=/pro/onboarding"
|
||||
className="inline-flex items-center gap-1.5 rounded-xl border border-[var(--border)] px-5 py-3 font-medium transition hover:border-[var(--color-brand-500)]"
|
||||
>
|
||||
Work with us
|
||||
<ArrowRight className="h-4 w-4" aria-hidden />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<section className="mt-12">
|
||||
<h2 className="text-sm font-medium text-[var(--muted)]">Trades we cover</h2>
|
||||
<ul className="mt-3 flex flex-wrap gap-2">
|
||||
{categories.map((c) => (
|
||||
<li
|
||||
key={c.id}
|
||||
className="rounded-full border border-[var(--border)] px-3 py-1.5 text-sm"
|
||||
>
|
||||
{c.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
<div className="flex min-h-dvh items-center justify-center sm:p-8">
|
||||
<PhoneFrame>
|
||||
<ShowcaseDeck cards={cards} />
|
||||
</PhoneFrame>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getApi } from '@/server/caller';
|
||||
import { AppShell } from '@/components/chrome/app-shell';
|
||||
import { ScreenIntro } from '@/components/ui';
|
||||
import { OnboardingWizard } from './wizard';
|
||||
|
||||
export const metadata = { title: 'Set up your profile' };
|
||||
@@ -22,22 +24,25 @@ export default async function ProOnboardingPage() {
|
||||
const [categories, profile] = await Promise.all([api.job.categories(), api.pro.me()]);
|
||||
|
||||
// Already submitted or approved — nothing to fill in.
|
||||
if (profile && profile.verificationStatus !== 'draft' && profile.verificationStatus !== 'rejected') {
|
||||
if (
|
||||
profile &&
|
||||
profile.verificationStatus !== 'draft' &&
|
||||
profile.verificationStatus !== 'rejected'
|
||||
) {
|
||||
redirect('/pro');
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-lg px-6 py-10">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Set up your profile</h1>
|
||||
<p className="mt-2 text-sm text-[var(--muted)]">
|
||||
<AppShell title="Set up your profile">
|
||||
<ScreenIntro>
|
||||
We check every pro’s ID, licence and insurance before any customer sees them. It
|
||||
usually takes a day.
|
||||
</p>
|
||||
</ScreenIntro>
|
||||
<OnboardingWizard
|
||||
categories={categories}
|
||||
initialProfile={profile}
|
||||
hasContactableEmail={me.hasContactableEmail}
|
||||
/>
|
||||
</main>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,18 @@ import {
|
||||
} from '@linkder/shared';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { uploadFile } from '@/lib/upload';
|
||||
import {
|
||||
Button,
|
||||
Chip,
|
||||
Field,
|
||||
FieldNote,
|
||||
FieldSet,
|
||||
FormError,
|
||||
Input,
|
||||
Section,
|
||||
StickyAction,
|
||||
Textarea,
|
||||
} from '@/components/ui';
|
||||
|
||||
type Categories = RouterOutputs['job']['categories'];
|
||||
type Profile = RouterOutputs['pro']['me'];
|
||||
@@ -90,21 +102,22 @@ export function OnboardingWizard({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-8">
|
||||
<ol className="mb-8 flex gap-2" aria-label="Progress">
|
||||
<div>
|
||||
<ol className="mb-10 flex gap-2" aria-label="Progress">
|
||||
{STEPS.map((label, i) => (
|
||||
<li key={label} className="flex-1">
|
||||
<div
|
||||
className={cn(
|
||||
'h-1 rounded-full',
|
||||
i <= step ? 'bg-[var(--color-brand-500)]' : 'bg-[var(--border)]',
|
||||
'h-1 rounded-pill transition-colors duration-[200ms] ease-standard',
|
||||
i <= step ? 'bg-brand-500' : 'bg-inset',
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'mt-1.5 block text-xs',
|
||||
i === step ? 'font-medium' : 'text-[var(--muted)]',
|
||||
'mt-2 block text-meta',
|
||||
i === step ? 'font-semibold text-strong' : 'text-muted',
|
||||
)}
|
||||
aria-current={i === step ? 'step' : undefined}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
@@ -121,24 +134,17 @@ export function OnboardingWizard({
|
||||
{categories.map((c) => {
|
||||
const selected = categoryIds.includes(c.id);
|
||||
return (
|
||||
<button
|
||||
<Chip
|
||||
key={c.id}
|
||||
type="button"
|
||||
selected={selected}
|
||||
onClick={() =>
|
||||
setCategoryIds((prev) =>
|
||||
selected ? prev.filter((id) => id !== c.id) : [...prev, c.id].slice(0, 5),
|
||||
)
|
||||
}
|
||||
className={cn(
|
||||
'rounded-full border px-4 py-2 text-sm transition',
|
||||
selected
|
||||
? 'border-[var(--color-brand-500)] bg-[var(--color-brand-500)]/10 font-medium'
|
||||
: 'border-[var(--border)]',
|
||||
)}
|
||||
>
|
||||
{selected && <Check className="mr-1 inline h-3.5 w-3.5" aria-hidden />}
|
||||
{c.name}
|
||||
</button>
|
||||
</Chip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -155,41 +161,40 @@ export function OnboardingWizard({
|
||||
{step === 1 && (
|
||||
<Section title="About you" hint="This is what a customer reads on your card.">
|
||||
<Field label="Headline" hint="e.g. Emergency plumber, 15 years in Barcelona">
|
||||
<input
|
||||
<Input
|
||||
value={headline}
|
||||
onChange={(e) => setHeadline(e.target.value)}
|
||||
maxLength={100}
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="About your work">
|
||||
<textarea
|
||||
<Textarea
|
||||
value={bio}
|
||||
onChange={(e) => setBio(e.target.value)}
|
||||
rows={5}
|
||||
maxLength={2000}
|
||||
className={inputClass}
|
||||
/>
|
||||
<span className="text-xs text-[var(--muted)]">{bio.length}/2000, minimum 30</span>
|
||||
<FieldNote>{bio.length}/2000, minimum 30</FieldNote>
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Hourly rate (€)">
|
||||
<input
|
||||
<Input
|
||||
value={hourlyRate}
|
||||
onChange={(e) => setHourlyRate(e.target.value.replace(/[^0-9.]/g, ''))}
|
||||
inputMode="decimal"
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Years experience">
|
||||
<input
|
||||
<Input
|
||||
value={yearsExperience}
|
||||
onChange={(e) => setYearsExperience(e.target.value.replace(/\D/g, ''))}
|
||||
inputMode="numeric"
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field
|
||||
label={`How far will you travel? ${radiusKm} km`}
|
||||
hint="You will only be shown jobs inside this radius."
|
||||
@@ -200,22 +205,25 @@ export function OnboardingWizard({
|
||||
max={MAX_SERVICE_RADIUS_M / 1000}
|
||||
value={radiusKm}
|
||||
onChange={(e) => setRadiusKm(Number(e.target.value))}
|
||||
className="w-full"
|
||||
className="w-full accent-brand-500"
|
||||
/>
|
||||
</Field>
|
||||
<button
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="self-start"
|
||||
onClick={() =>
|
||||
navigator.geolocation?.getCurrentPosition(
|
||||
(pos) =>
|
||||
setLocation({ lat: pos.coords.latitude, lng: pos.coords.longitude }),
|
||||
(pos) => setLocation({ lat: pos.coords.latitude, lng: pos.coords.longitude }),
|
||||
() => setError('We could not get your location. The city centre will be used.'),
|
||||
)
|
||||
}
|
||||
className="text-sm text-[var(--color-brand-500)] underline underline-offset-4"
|
||||
>
|
||||
Use my current location as my base
|
||||
</button>
|
||||
</Button>
|
||||
|
||||
<Nav
|
||||
onBack={() => setStep(0)}
|
||||
busy={upsert.isPending}
|
||||
@@ -251,18 +259,18 @@ export function OnboardingWizard({
|
||||
hint="We check these by hand before your profile goes live. They are never shown to customers."
|
||||
>
|
||||
{!hasContactableEmail && (
|
||||
<Field
|
||||
label="Email address"
|
||||
hint="Required for pros — payout statements, tax records and dispute notices go here."
|
||||
>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
<button
|
||||
<div className="flex flex-col gap-2">
|
||||
<Field
|
||||
label="Email address"
|
||||
hint="Required for pros — payout statements, tax records and dispute notices go here."
|
||||
>
|
||||
<Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
</Field>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="self-start"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await setEmailMutation.mutateAsync({ email });
|
||||
@@ -272,15 +280,14 @@ export function OnboardingWizard({
|
||||
setError((e as Error).message);
|
||||
}
|
||||
}}
|
||||
className="mt-2 text-sm text-[var(--color-brand-500)] underline underline-offset-4"
|
||||
>
|
||||
Save email
|
||||
</button>
|
||||
</Field>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(['id', 'insurance', 'licence'] as const).map((kind) => (
|
||||
<Field
|
||||
<FieldSet
|
||||
key={kind}
|
||||
label={
|
||||
kind === 'id'
|
||||
@@ -300,80 +307,37 @@ export function OnboardingWizard({
|
||||
}}
|
||||
onError={setError}
|
||||
/>
|
||||
</Field>
|
||||
</FieldSet>
|
||||
))}
|
||||
|
||||
<div className="mt-6 flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep(2)}
|
||||
className="rounded-xl border border-[var(--border)] px-5 py-3 text-sm"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit.mutate()}
|
||||
disabled={submit.isPending}
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-xl bg-[var(--color-brand-500)] px-5 py-3 font-medium text-white disabled:opacity-60"
|
||||
>
|
||||
{submit.isPending && <Loader2 className="h-4 w-4 animate-spin" aria-hidden />}
|
||||
Submit for review
|
||||
</button>
|
||||
</div>
|
||||
<StickyAction>
|
||||
<div className="flex gap-3">
|
||||
<Button type="button" variant="outline" size="lg" onClick={() => setStep(2)}>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
className="flex-1"
|
||||
busy={submit.isPending}
|
||||
onClick={() => submit.mutate()}
|
||||
>
|
||||
Submit for review
|
||||
</Button>
|
||||
</div>
|
||||
</StickyAction>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="mt-4 text-sm text-[var(--color-stop-500)]">
|
||||
{error}
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<FormError>{error}</FormError>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
'w-full rounded-xl border border-[var(--border)] bg-[var(--card)] px-4 py-3 text-base outline-none focus:border-[var(--color-brand-500)]';
|
||||
|
||||
function Section({
|
||||
title,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
hint?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-medium">{title}</h2>
|
||||
{hint && <p className="mt-1 text-sm text-[var(--muted)]">{hint}</p>}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
{hint && <span className="text-xs text-[var(--muted)]">{hint}</span>}
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function Nav({
|
||||
onBack,
|
||||
onNext,
|
||||
@@ -384,26 +348,18 @@ function Nav({
|
||||
busy?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-4 flex gap-3">
|
||||
{onBack && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="rounded-xl border border-[var(--border)] px-5 py-3 text-sm"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onNext()}
|
||||
disabled={busy}
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-xl bg-[var(--color-brand-500)] px-5 py-3 font-medium text-white disabled:opacity-60"
|
||||
>
|
||||
{busy && <Loader2 className="h-4 w-4 animate-spin" aria-hidden />}
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
<StickyAction>
|
||||
<div className="flex gap-3">
|
||||
{onBack && (
|
||||
<Button type="button" variant="outline" size="lg" onClick={onBack}>
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" size="lg" className="flex-1" busy={busy} onClick={() => void onNext()}>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</StickyAction>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -422,15 +378,15 @@ function UploadList({
|
||||
const presign = api.upload.presign.useMutation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-3">
|
||||
{existing.length > 0 && (
|
||||
<ul className="flex flex-wrap gap-2">
|
||||
{existing.map((url) => (
|
||||
<li
|
||||
key={url}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-[var(--border)] px-3 py-1.5 text-xs"
|
||||
className="flex items-center gap-1.5 rounded-pill border border-go-100 bg-go-50 px-3 py-1.5 text-meta text-go-700"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5 text-[var(--color-go-500)]" aria-hidden />
|
||||
<Check className="h-4 w-4" aria-hidden />
|
||||
Uploaded
|
||||
</li>
|
||||
))}
|
||||
@@ -438,8 +394,9 @@ function UploadList({
|
||||
)}
|
||||
<label
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center justify-center gap-2 rounded-xl border border-dashed',
|
||||
'border-[var(--border)] px-4 py-6 text-sm text-[var(--muted)] hover:border-[var(--color-brand-500)]',
|
||||
'flex cursor-pointer items-center justify-center gap-2 rounded-card border border-dashed',
|
||||
'border-hairline px-4 py-8 text-body-sm text-muted',
|
||||
'transition-colors duration-[120ms] ease-standard hover:border-brand-500 hover:text-strong',
|
||||
)}
|
||||
>
|
||||
{busy ? (
|
||||
@@ -450,7 +407,7 @@ function UploadList({
|
||||
{busy ? 'Uploading…' : 'Choose a file'}
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
className="sr-only"
|
||||
accept={kind === 'credential' ? 'image/*,application/pdf' : 'image/*'}
|
||||
onChange={async (event) => {
|
||||
const file = event.target.files?.[0];
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { Clock, ShieldCheck, XCircle } from 'lucide-react';
|
||||
import { getApi } from '@/server/caller';
|
||||
import { AppShell } from '@/components/chrome/app-shell';
|
||||
import { Banner, ScreenIntro, Stat } from '@/components/ui';
|
||||
|
||||
export const metadata = { title: 'Your account' };
|
||||
export const dynamic = 'force-dynamic';
|
||||
@@ -22,97 +23,49 @@ export default async function ProHomePage() {
|
||||
const status = profile.verificationStatus;
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-lg px-6 py-10">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{profile.headline}</h1>
|
||||
<AppShell title="Your account">
|
||||
<ScreenIntro>{profile.headline}</ScreenIntro>
|
||||
|
||||
{status === 'pending' && (
|
||||
<StatusCard
|
||||
icon={<Clock className="h-5 w-5" aria-hidden />}
|
||||
tone="waiting"
|
||||
title="We are checking your documents"
|
||||
body="This usually takes a day. We will text you the moment you are live and jobs start arriving."
|
||||
/>
|
||||
<Banner tone="warning" title="We are checking your documents">
|
||||
This usually takes a day. We will text you the moment you are live and jobs start
|
||||
arriving.
|
||||
</Banner>
|
||||
)}
|
||||
{status === 'verified' && (
|
||||
<StatusCard
|
||||
icon={<ShieldCheck className="h-5 w-5" aria-hidden />}
|
||||
tone="good"
|
||||
title="You are live"
|
||||
body="Customers nearby can see you now. Keep your response time short — it is the second biggest factor in where you appear."
|
||||
/>
|
||||
<Banner tone="success" title="You are live">
|
||||
Customers nearby can see you now. Keep your response time short — it is the second
|
||||
biggest factor in where you appear.
|
||||
</Banner>
|
||||
)}
|
||||
{status === 'rejected' && (
|
||||
<StatusCard
|
||||
icon={<XCircle className="h-5 w-5" aria-hidden />}
|
||||
tone="bad"
|
||||
title="We could not approve your account"
|
||||
body="Check your email for what we need. You can update your documents and submit again."
|
||||
/>
|
||||
<Banner tone="error" title="We could not approve your account">
|
||||
Check your email for what we need. You can update your documents and submit again.
|
||||
</Banner>
|
||||
)}
|
||||
{status === 'suspended' && (
|
||||
<StatusCard
|
||||
icon={<XCircle className="h-5 w-5" aria-hidden />}
|
||||
tone="bad"
|
||||
title="Your account is suspended"
|
||||
body={profile.suspendedReason ?? 'Contact support to sort this out.'}
|
||||
/>
|
||||
<Banner tone="error" title="Your account is suspended">
|
||||
{profile.suspendedReason ?? 'Contact support to sort this out.'}
|
||||
</Banner>
|
||||
)}
|
||||
{status === 'draft' && (
|
||||
<StatusCard
|
||||
icon={<Clock className="h-5 w-5" aria-hidden />}
|
||||
tone="waiting"
|
||||
title="Your profile is not finished"
|
||||
body="Finish setting up and submit it for review."
|
||||
/>
|
||||
<Banner tone="warning" title="Your profile is not finished">
|
||||
Finish setting up and submit it for review.
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
<dl className="mt-8 grid grid-cols-2 gap-4 text-sm">
|
||||
{/* §4 — a 2-up grid is allowed for stat tiles and nothing else. */}
|
||||
<dl className="mt-8 grid grid-cols-2 gap-3">
|
||||
<Stat label="Jobs completed" value={String(profile.completedJobs)} />
|
||||
<Stat
|
||||
label="Rating"
|
||||
value={profile.ratingAvg ? `${Number(profile.ratingAvg).toFixed(1)} / 5` : 'No reviews yet'}
|
||||
value={
|
||||
profile.ratingAvg ? `${Number(profile.ratingAvg).toFixed(1)} / 5` : 'No reviews yet'
|
||||
}
|
||||
/>
|
||||
<Stat label="Travels up to" value={`${Math.round(profile.serviceRadiusM / 1000)} km`} />
|
||||
<Stat label="Rate" value={`€${(profile.hourlyRateCents / 100).toFixed(0)}/hr`} />
|
||||
</dl>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusCard({
|
||||
icon,
|
||||
tone,
|
||||
title,
|
||||
body,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
tone: 'good' | 'waiting' | 'bad';
|
||||
title: string;
|
||||
body: string;
|
||||
}) {
|
||||
const toneClass =
|
||||
tone === 'good'
|
||||
? 'border-[var(--color-go-500)]/30 bg-[var(--color-go-500)]/10'
|
||||
: tone === 'bad'
|
||||
? 'border-[var(--color-stop-500)]/30 bg-[var(--color-stop-500)]/10'
|
||||
: 'border-[var(--border)] bg-[var(--card)]';
|
||||
|
||||
return (
|
||||
<div className={`mt-6 flex gap-3 rounded-2xl border p-5 ${toneClass}`}>
|
||||
<span className="mt-0.5 shrink-0">{icon}</span>
|
||||
<span>
|
||||
<span className="block font-medium">{title}</span>
|
||||
<span className="mt-1 block text-sm text-[var(--muted)]">{body}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-[var(--border)] p-4">
|
||||
<dt className="text-xs text-[var(--muted)]">{label}</dt>
|
||||
<dd className="mt-1 font-medium">{value}</dd>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 { BareShell } from '@/components/chrome/app-shell';
|
||||
import { SignInForm } from './sign-in-form';
|
||||
|
||||
export const metadata = { title: 'Sign in' };
|
||||
|
||||
export default function SignInPage() {
|
||||
return (
|
||||
<main className="mx-auto flex min-h-dvh max-w-sm flex-col justify-center px-6 py-12">
|
||||
<p className="text-sm font-medium text-[var(--color-brand-500)]">Linkder</p>
|
||||
<h1 className="mt-2 text-2xl font-semibold tracking-tight">Sign in</h1>
|
||||
<p className="mt-2 text-sm text-[var(--muted)]">
|
||||
<BareShell>
|
||||
<p className="text-overline uppercase text-accent">Linkder</p>
|
||||
<h1 className="mt-3 text-h1">Sign in</h1>
|
||||
<p className="mt-3 text-body text-muted">
|
||||
We will text you a 6-digit code. No password to forget.
|
||||
</p>
|
||||
<Suspense fallback={null}>
|
||||
<SignInForm />
|
||||
</Suspense>
|
||||
</main>
|
||||
</BareShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { Button, Field, FormError, Input } from '@/components/ui';
|
||||
|
||||
type Step = 'phone' | 'code';
|
||||
|
||||
@@ -58,98 +58,83 @@ export function SignInForm() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-8 flex flex-col gap-6">
|
||||
<div className="mt-10 flex flex-col gap-6">
|
||||
{step === 'phone' ? (
|
||||
<form onSubmit={sendCode} className="flex flex-col gap-3">
|
||||
<label htmlFor="phone" className="text-sm font-medium">
|
||||
Mobile number
|
||||
</label>
|
||||
<input
|
||||
id="phone"
|
||||
name="phone"
|
||||
type="tel"
|
||||
autoComplete="tel"
|
||||
inputMode="tel"
|
||||
required
|
||||
placeholder="+34 600 123 456"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value.replace(/\s/g, ''))}
|
||||
className="rounded-xl border border-[var(--border)] bg-[var(--card)] px-4 py-3 text-base outline-none focus:border-[var(--color-brand-500)]"
|
||||
/>
|
||||
<SubmitButton busy={busy}>Send code</SubmitButton>
|
||||
<form onSubmit={sendCode} className="flex flex-col gap-6">
|
||||
<Field label="Mobile number">
|
||||
<Input
|
||||
name="phone"
|
||||
type="tel"
|
||||
autoComplete="tel"
|
||||
inputMode="tel"
|
||||
required
|
||||
placeholder="+34 600 123 456"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value.replace(/\s/g, ''))}
|
||||
/>
|
||||
</Field>
|
||||
<Button type="submit" size="lg" block busy={busy}>
|
||||
Send code
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={verifyCode} className="flex flex-col gap-3">
|
||||
<label htmlFor="code" className="text-sm font-medium">
|
||||
Enter the code we sent to {phone}
|
||||
</label>
|
||||
<input
|
||||
id="code"
|
||||
name="code"
|
||||
type="text"
|
||||
autoComplete="one-time-code"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]{6}"
|
||||
maxLength={6}
|
||||
required
|
||||
autoFocus
|
||||
placeholder="123456"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
|
||||
className="rounded-xl border border-[var(--border)] bg-[var(--card)] px-4 py-3 text-center text-2xl tracking-[0.4em] outline-none focus:border-[var(--color-brand-500)]"
|
||||
/>
|
||||
<SubmitButton busy={busy}>Sign in</SubmitButton>
|
||||
<button
|
||||
<form onSubmit={verifyCode} className="flex flex-col gap-6">
|
||||
<Field label={`Enter the code we sent to ${phone}`}>
|
||||
<Input
|
||||
name="code"
|
||||
type="text"
|
||||
autoComplete="one-time-code"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]{6}"
|
||||
maxLength={6}
|
||||
required
|
||||
autoFocus
|
||||
placeholder="123456"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
|
||||
className="text-center font-display text-h2 tracking-[0.4em] tabular-nums"
|
||||
/>
|
||||
</Field>
|
||||
<Button type="submit" size="lg" block busy={busy}>
|
||||
Sign in
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setStep('phone');
|
||||
setCode('');
|
||||
setError(null);
|
||||
}}
|
||||
className="text-sm text-[var(--muted)] underline underline-offset-4"
|
||||
>
|
||||
Use a different number
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-[var(--color-stop-500)]">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{error && <FormError>{error}</FormError>}
|
||||
|
||||
<div className="flex items-center gap-3 text-xs text-[var(--muted)]">
|
||||
<span className="h-px flex-1 bg-[var(--border)]" />
|
||||
<div className="flex items-center gap-3 text-meta text-muted">
|
||||
<span className="h-px flex-1 bg-hairline" />
|
||||
or
|
||||
<span className="h-px flex-1 bg-[var(--border)]" />
|
||||
<span className="h-px flex-1 bg-hairline" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
block
|
||||
onClick={() => authClient.signIn.social({ provider: 'google', callbackURL: next })}
|
||||
className="rounded-xl border border-[var(--border)] bg-[var(--card)] px-4 py-3 text-sm font-medium transition hover:border-[var(--color-brand-500)]"
|
||||
>
|
||||
Continue with Google
|
||||
</button>
|
||||
</Button>
|
||||
|
||||
<p className="text-xs text-[var(--muted)]">
|
||||
Signing in with Google creates a separate account from a phone sign-in. If you have used both,
|
||||
contact us and we will link them.
|
||||
<p className="text-meta text-muted">
|
||||
Signing in with Google creates a separate account from a phone sign-in. If you have used
|
||||
both, contact us and we will link them.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SubmitButton({ busy, children }: { busy: boolean; children: React.ReactNode }) {
|
||||
return (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
className="flex items-center justify-center gap-2 rounded-xl bg-[var(--color-brand-500)] px-4 py-3 font-medium text-white transition hover:bg-[var(--color-brand-600)] disabled:opacity-60"
|
||||
>
|
||||
{busy && <Loader2 className="h-4 w-4 animate-spin" aria-hidden />}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* DESIGN.md §4. The one screen wrapper.
|
||||
*
|
||||
* Screens do not set their own gutters or safe-area padding — the whole point is
|
||||
* that they cannot drift.
|
||||
*
|
||||
* There is no fixed app bar and no bottom tab bar. The screen title is rendered
|
||||
* in flow as the page's `h1`: the bar used to own the only `h1` on every screen,
|
||||
* so removing it without this would leave every page with no heading at all.
|
||||
*/
|
||||
export function AppShell({
|
||||
title,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<main
|
||||
className={cn(
|
||||
'mx-auto min-h-dvh max-w-app bg-page',
|
||||
'px-5 pt-8 pb-[calc(2rem+env(safe-area-inset-bottom))]',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<h1 className="mb-6 text-h1">{title}</h1>
|
||||
{children}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A screen with no chrome and no heading — sign in, role choice, the entry
|
||||
* screen. Vertically centred, because these are single-decision screens with
|
||||
* little on them.
|
||||
*/
|
||||
export function BareShell({ children }: { children: React.ReactNode }) {
|
||||
return <main className="mx-auto flex min-h-dvh max-w-app flex-col justify-center bg-page px-5 py-10">
|
||||
{children}
|
||||
</main>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The deck. Full-bleed and full-height — swiping is a focused mode, and the
|
||||
* card needs the whole viewport rather than a box inside a padded screen.
|
||||
*/
|
||||
export function DeckShell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<main className="mx-auto flex min-h-dvh max-w-app flex-col bg-page px-5 pt-6 pb-[calc(1.5rem+env(safe-area-inset-bottom))]">
|
||||
{children}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* A hardware illustration with the real app running inside it.
|
||||
*
|
||||
* This is the entry screen on a desktop browser: rather than describing the
|
||||
* product in prose, it shows the actual running app — the same <Deck>, the same
|
||||
* drag gesture, the same data — inside a phone. Everything inside `children` is
|
||||
* live and interactive, not a screenshot.
|
||||
*
|
||||
* On a phone there is no frame. Drawing a picture of a phone on a phone is
|
||||
* absurd, and it would waste the ~24px of width the deck actually needs, so
|
||||
* below `sm` the bezel collapses and the app simply fills the viewport.
|
||||
*/
|
||||
export function PhoneFrame({
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
// Phone: no frame at all, app fills the screen.
|
||||
'h-dvh w-full',
|
||||
// Tablet and up: a 390x844 device, the iPhone 14 logical viewport.
|
||||
'sm:h-[844px] sm:w-[390px] sm:shrink-0',
|
||||
// The bezel. ink-950 rather than pure black, per DESIGN.md §2.2.
|
||||
'sm:rounded-[3.25rem] sm:bg-ink-950 sm:p-[0.7rem] sm:shadow-lg',
|
||||
// A hairline of light along the top edge reads as a chamfer and stops
|
||||
// the bezel looking like a flat black rectangle.
|
||||
'sm:ring-1 sm:ring-inset sm:ring-white/12',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* The screen. overflow-hidden is what makes a swiped card disappear at
|
||||
the bezel instead of flying across the page. */}
|
||||
<div className="relative h-full w-full overflow-hidden bg-page sm:rounded-[2.6rem]">
|
||||
{/* Dynamic Island. Pointer-events-none so it never eats a drag that
|
||||
starts near the top of the card. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute left-1/2 top-2 z-30 hidden h-[1.85rem] w-[6.2rem] -translate-x-1/2 rounded-pill bg-ink-950 sm:block"
|
||||
/>
|
||||
{children}
|
||||
{/* Home indicator. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute bottom-2 left-1/2 z-30 hidden h-[0.3rem] w-[8.5rem] -translate-x-1/2 rounded-pill bg-ink-950/25 sm:block"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -34,8 +34,8 @@ export function Deck({ cards, onDecide }: DeckProps) {
|
||||
const visible = remaining.slice(0, 3);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<div className="relative h-[560px] w-full max-w-sm">
|
||||
<div className="flex h-full min-h-0 flex-col items-center gap-5">
|
||||
<div className="relative w-full min-h-0 flex-1">
|
||||
<AnimatePresence initial={false}>
|
||||
{visible
|
||||
.map((card, i) => (
|
||||
@@ -50,15 +50,12 @@ export function Deck({ cards, onDecide }: DeckProps) {
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="flex shrink-0 items-center gap-12 pb-2">
|
||||
<ActionButton
|
||||
label="Not this one"
|
||||
variant="pass"
|
||||
onClick={() => visible[0] && decide(visible[0].proId, 'left')}
|
||||
/>
|
||||
<p className="w-28 text-center text-sm text-[var(--muted)] tabular-nums">
|
||||
{remaining.length} left
|
||||
</p>
|
||||
<ActionButton
|
||||
label="Send this job"
|
||||
variant="hire"
|
||||
@@ -204,7 +201,7 @@ function ActionButton({
|
||||
|
||||
function EmptyDeck() {
|
||||
return (
|
||||
<div className="mx-auto flex h-[560px] max-w-sm flex-col items-center justify-center gap-3 rounded-3xl border border-dashed border-[var(--border)] p-8 text-center">
|
||||
<div className="mx-auto flex h-full flex-col items-center justify-center gap-3 rounded-deck border border-dashed border-hairline p-8 text-center">
|
||||
<h2 className="text-lg font-semibold">That’s everyone nearby</h2>
|
||||
<p className="text-sm text-[var(--muted)]">
|
||||
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-lg: 0 12px 32px rgb(0 6 36 / 0.12), 0 4px 8px rgb(0 6 36 / 0.06);
|
||||
|
||||
/* ── Containers. §4 ─────────────────────────────────────────────────── */
|
||||
--container-prose: 680px;
|
||||
--container-app: 1080px;
|
||||
--container-wide: 1280px;
|
||||
/* ── Container. §4 — mobile only; there is exactly one. ─────────────── */
|
||||
--container-app: 480px;
|
||||
|
||||
/* ── Motion. §7 ─────────────────────────────────────────────────────── */
|
||||
--ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
Reference in New Issue
Block a user