M1: phone app shell, settings, profile, dev login
Everything now renders inside a phone illustration on the entry screen, with a five-tab bar. The frame lives in the root layout rather than one page, so sign-in, onboarding and the job form are inside it too. - Entry screen is the product running, not a marketing page: a live swipeable deck of real verified pros with a trade-filter strip above the card. deck.showcase is the only public procedure in that router and writes nothing, so an anonymous right swipe reaches no one. - Settings: notification preferences (new table, defaults returned when no row exists), signed-in devices, GDPR export, deletion request. Closes the setEmail finding: an unverified address is no longer written to users.email, which is UNIQUE -- claiming a stranger's address used to block them from ever signing up with Google, and the uniqueness error leaked whether an address was registered. Now parked in email_change_requests until a token proves ownership. - Profile: for a pro it leads with their REAL deck card, rendered by the same exported <Card> clients swipe, so the two cannot drift. Adds pro.previewCard (works at draft/pending, where publicProfile 404s) and pro.reorderMedia (photo position 0 is the deck card). Warns before an edit that would send a verified pro back for review, rather than after it silently drops them off the deck. Clients get a thin profile plus a route into pro onboarding -- supply is the launch blocker. - Dev login: +34600000000 / 000000, behind THREE guards (NODE_ENV, an explicit ALLOW_DEV_LOGIN flag, and an exact number match). It overwrites the stored code rather than skipping verification, so the real expiry, attempt cap and single-use consumption still apply. - Seed uses portrait photos. The cards previously showed picsum stock scenery -- a locksmith standing on a railway track. Fixes found along the way: the card's name rendered ink-950 navy on a dark photo because globals.css sets h1..h6 colour in @layer base, which beat the inherited text-white; and the card referenced --color-go-500, --border and --card, none of which exist, so the SEND JOB stamp had no colour. Also adds public/sw.js as a kill-switch: a service worker left registered on localhost:3000 by a different project was intercepting this app's chunks. typecheck, lint clean; 186 tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { Button, useToast } from '@/components/ui';
|
||||
|
||||
/**
|
||||
* "Continue with Google" — the one social route, offered wherever we ask
|
||||
* someone to sign in.
|
||||
*
|
||||
* The button renders whether or not the server has Google credentials. Hiding
|
||||
* it when the keys are missing would mean the sign-in screen quietly changes
|
||||
* shape between environments, so a layout that works on a developer's machine
|
||||
* is one nobody has actually seen in production — and the first person to
|
||||
* notice would be a user. It is always here; when the server cannot honour it,
|
||||
* the click says so out loud.
|
||||
*
|
||||
* `lib/auth.ts` registers the provider only when both AUTH_GOOGLE_ID and
|
||||
* AUTH_GOOGLE_SECRET are set, so the unconfigured case comes back as a clean
|
||||
* 404 PROVIDER_NOT_FOUND rather than a 500 from deep inside the OAuth builder.
|
||||
* That is what makes "not set up" distinguishable here from "Google is down".
|
||||
*/
|
||||
export function GoogleButton({
|
||||
callbackURL,
|
||||
size = 'lg',
|
||||
block = true,
|
||||
label = 'Continue with Google',
|
||||
}: {
|
||||
/** Where to land after Google sends the browser back. */
|
||||
callbackURL: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
block?: boolean;
|
||||
label?: string;
|
||||
}) {
|
||||
const toast = useToast();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function start() {
|
||||
setBusy(true);
|
||||
const { error } = await authClient.signIn.social({ provider: 'google', callbackURL });
|
||||
// On success better-auth's redirect plugin has already sent the browser to
|
||||
// Google, so this line is only ever reached on failure — but leave `busy`
|
||||
// set in the success case rather than flicking the spinner off under a
|
||||
// navigation that is already in flight.
|
||||
if (!error) return;
|
||||
setBusy(false);
|
||||
|
||||
if (error.status === 404 || error.code === 'PROVIDER_NOT_FOUND') {
|
||||
toast('Sign in with your mobile number instead — it takes about the same time.', {
|
||||
tone: 'warning',
|
||||
title: 'Google sign-in is not set up yet',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
toast(error.message ?? 'Google did not respond. Try again, or use your mobile number.', {
|
||||
tone: 'error',
|
||||
title: 'Could not continue with Google',
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Button type="button" variant="outline" size={size} block={block} busy={busy} onClick={start}>
|
||||
{!busy && <GoogleMark />}
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Google's mark, per their branding terms: the four-colour G, never recoloured
|
||||
* and never swapped for a monochrome icon-font glyph.
|
||||
*/
|
||||
function GoogleMark() {
|
||||
return (
|
||||
<svg className="h-5 w-5 shrink-0" viewBox="0 0 18 18" aria-hidden focusable="false">
|
||||
<path
|
||||
fill="#4285F4"
|
||||
d="M17.64 9.2c0-.64-.06-1.25-.16-1.84H9v3.48h4.84a4.14 4.14 0 0 1-1.8 2.72v2.26h2.92c1.7-1.57 2.68-3.88 2.68-6.62Z"
|
||||
/>
|
||||
<path
|
||||
fill="#34A853"
|
||||
d="M9 18c2.43 0 4.47-.8 5.96-2.18l-2.92-2.26c-.81.54-1.84.86-3.04.86-2.34 0-4.32-1.58-5.03-3.7H.96v2.33A9 9 0 0 0 9 18Z"
|
||||
/>
|
||||
<path
|
||||
fill="#FBBC05"
|
||||
d="M3.97 10.72a5.4 5.4 0 0 1 0-3.44V4.95H.96a9 9 0 0 0 0 8.1l3.01-2.33Z"
|
||||
/>
|
||||
<path
|
||||
fill="#EA4335"
|
||||
d="M9 3.58c1.32 0 2.5.45 3.44 1.35l2.58-2.58C13.46.89 11.43 0 9 0A9 9 0 0 0 .96 4.95l3.01 2.33C4.68 5.16 6.66 3.58 9 3.58Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { BackLink } from './back-link';
|
||||
|
||||
/**
|
||||
* DESIGN.md §4. The one screen wrapper.
|
||||
@@ -22,7 +23,7 @@ export function AppShell({
|
||||
return (
|
||||
<main
|
||||
className={cn(
|
||||
'mx-auto min-h-dvh max-w-app bg-page',
|
||||
'h-full overflow-y-auto bg-page',
|
||||
'px-5 pt-8 pb-[calc(2rem+env(safe-area-inset-bottom))]',
|
||||
className,
|
||||
)}
|
||||
@@ -37,11 +38,28 @@ export function AppShell({
|
||||
* 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.
|
||||
*
|
||||
* `back` puts a link home in the top-left corner. It is absolutely positioned so
|
||||
* that adding it does not push the centred content off centre — these screens
|
||||
* are composed around the middle of the viewport, not around the top.
|
||||
*/
|
||||
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">
|
||||
export function BareShell({
|
||||
back = false,
|
||||
children,
|
||||
}: {
|
||||
back?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<main className="relative flex h-full flex-col justify-center overflow-y-auto bg-page px-5 py-10">
|
||||
{back && (
|
||||
<div className="absolute left-5 top-[calc(0.5rem+env(safe-area-inset-top))] z-10">
|
||||
<BackLink />
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</main>;
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,7 +68,7 @@ export function BareShell({ children }: { children: React.ReactNode }) {
|
||||
*/
|
||||
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))]">
|
||||
<main className="flex h-full flex-col bg-page px-5 pt-6 pb-[calc(1.5rem+env(safe-area-inset-bottom))]">
|
||||
{children}
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import Link from 'next/link';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* The way out of a dead end.
|
||||
*
|
||||
* DESIGN.md §6.6 hangs a back chevron off the app bar, but there is no app bar
|
||||
* here — so on the bare screens it sits in the top-left corner of the screen
|
||||
* instead, clear of the vertically centred content and of the Dynamic Island.
|
||||
*
|
||||
* A real <Link>, not `router.back()`: someone who landed on sign-in from a
|
||||
* bookmark or an expired-session redirect has no history to go back to, and a
|
||||
* button that sometimes does nothing is worse than no button.
|
||||
*/
|
||||
export function BackLink({
|
||||
href = '/',
|
||||
label = 'Home',
|
||||
className,
|
||||
}: {
|
||||
href?: string;
|
||||
label?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
// -ml-2 pulls the chevron out to the gutter so the label lines up with
|
||||
// the screen text, while the tap target keeps its 44px. §6.1
|
||||
'-ml-2 inline-flex h-11 items-center gap-0.5 rounded-pill pl-1 pr-3',
|
||||
'font-display text-body-sm font-semibold text-muted',
|
||||
'transition-colors duration-[120ms] ease-standard hover:text-strong',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5" aria-hidden />
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -22,10 +22,14 @@ export function PhoneFrame({
|
||||
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',
|
||||
// Phone: no frame at all, app fills the screen. min-w-0 because this is
|
||||
// a flex item, and a flex item defaults to min-width:auto -- without it
|
||||
// the deck's intrinsic width stretches the frame past the viewport and
|
||||
// the card hangs off the right edge.
|
||||
'h-dvh w-full min-w-0',
|
||||
// Tablet and up: a 390x844 device, the iPhone 14 logical viewport —
|
||||
// capped at the window height so the bezel is never clipped on a laptop.
|
||||
'sm:h-[min(844px,92dvh)] 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
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
'use client';
|
||||
|
||||
import { Flame, Search, Layers, UserRound, Settings } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type PhoneTab = 'swipe' | 'search' | 'jobs' | 'profile' | 'settings';
|
||||
|
||||
const TABS: { id: PhoneTab; label: string; icon: typeof Flame }[] = [
|
||||
{ id: 'swipe', label: 'Swipe', icon: Flame },
|
||||
{ id: 'search', label: 'Search', icon: Search },
|
||||
{ id: 'jobs', label: 'Past jobs', icon: Layers },
|
||||
{ id: 'profile', label: 'Profile', icon: UserRound },
|
||||
{ id: 'settings', label: 'Settings', icon: Settings },
|
||||
];
|
||||
|
||||
/**
|
||||
* Bottom tab bar, Tinder-style: icon-only, evenly spaced, active tab in the
|
||||
* accent colour and inactive in grey. Lives inside the phone screen, not in the
|
||||
* page — this is app chrome.
|
||||
*/
|
||||
export function PhoneTabs({
|
||||
active,
|
||||
onChange,
|
||||
}: {
|
||||
active: PhoneTab;
|
||||
onChange: (tab: PhoneTab) => void;
|
||||
}) {
|
||||
return (
|
||||
<nav
|
||||
aria-label="Main"
|
||||
className="flex shrink-0 items-center justify-around border-t border-hairline bg-page px-2 pb-[calc(0.5rem+env(safe-area-inset-bottom))] pt-2"
|
||||
>
|
||||
{TABS.map(({ id, label, icon: Icon }) => {
|
||||
const isActive = id === active;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => onChange(id)}
|
||||
aria-label={label}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
className={cn(
|
||||
'flex h-11 w-11 items-center justify-center rounded-pill',
|
||||
'transition-colors duration-[120ms] ease-standard',
|
||||
isActive ? 'text-accent' : 'text-faint hover:text-muted',
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className="h-7 w-7"
|
||||
strokeWidth={isActive ? 2.5 : 2}
|
||||
fill={isActive && id === 'swipe' ? 'currentColor' : 'none'}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import Link from 'next/link';
|
||||
import { LogIn } from 'lucide-react';
|
||||
import { buttonClasses } from '@/components/ui';
|
||||
|
||||
/**
|
||||
* What a signed-in-only tab shows to an anonymous visitor.
|
||||
*
|
||||
* The tab stays tappable rather than being greyed out — a bar of dead icons on
|
||||
* first open reads as a broken app, whereas this explains what is behind it.
|
||||
*/
|
||||
export function SignedOut({ title, body }: { title: string; body: string }) {
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-4 px-8 text-center">
|
||||
<LogIn className="h-8 w-8 text-accent" aria-hidden />
|
||||
<div>
|
||||
<h1 className="text-h3">{title}</h1>
|
||||
<p className="mt-2 text-body-sm text-muted">{body}</p>
|
||||
</div>
|
||||
<Link href="/sign-in?next=/" className={buttonClasses({ variant: 'primary', size: 'md' })}>
|
||||
Continue with phone
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -66,13 +66,20 @@ export function Deck({ cards, onDecide }: DeckProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function Card({
|
||||
/**
|
||||
* One card.
|
||||
*
|
||||
* Exported so the profile screen can render a pro their OWN card, byte for byte
|
||||
* what a client sees. A lookalike would drift the moment either side changed.
|
||||
* Pass no `onDecide` to get a static, non-draggable card.
|
||||
*/
|
||||
export function Card({
|
||||
card,
|
||||
depth,
|
||||
depth = 0,
|
||||
onDecide,
|
||||
}: {
|
||||
card: DeckCard;
|
||||
depth: number;
|
||||
depth?: number;
|
||||
onDecide?: (proId: string, direction: 'left' | 'right') => void;
|
||||
}) {
|
||||
const x = useMotionValue(0);
|
||||
@@ -86,7 +93,7 @@ function Card({
|
||||
<motion.article
|
||||
className={cn(
|
||||
'deck-card absolute inset-0 overflow-hidden rounded-3xl border shadow-xl',
|
||||
'border-[var(--border)] bg-[var(--card)]',
|
||||
'border-hairline bg-raised',
|
||||
interactive ? 'cursor-grab active:cursor-grabbing' : 'pointer-events-none',
|
||||
)}
|
||||
style={{ x, rotate, zIndex: 10 - depth }}
|
||||
@@ -119,7 +126,7 @@ function Card({
|
||||
<>
|
||||
<motion.div
|
||||
style={{ opacity: hireOpacity }}
|
||||
className="absolute left-6 top-6 rotate-[-12deg] rounded-lg border-4 border-[var(--color-go-500)] px-3 py-1 text-2xl font-black tracking-wide text-[var(--color-go-500)]"
|
||||
className="absolute left-6 top-6 rotate-[-12deg] rounded-lg border-4 border-[var(--color-go-600)] px-3 py-1 text-2xl font-black tracking-wide text-[var(--color-go-600)]"
|
||||
>
|
||||
SEND JOB
|
||||
</motion.div>
|
||||
@@ -134,7 +141,13 @@ function Card({
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 p-6 text-white">
|
||||
<div className="mb-1 flex items-baseline gap-2">
|
||||
<h2 className="text-2xl font-semibold">{card.name}</h2>
|
||||
{/*
|
||||
text-white must be on the h2 itself, not inherited from the wrapper:
|
||||
globals.css sets `h1..h6 { color: var(--text-strong) }` in @layer
|
||||
base, and that rule beats the parent's colour. Without this the name
|
||||
renders ink-950 navy on a dark photo and is unreadable.
|
||||
*/}
|
||||
<h2 className="text-2xl font-semibold text-white">{card.name}</h2>
|
||||
{card.ratingCount > 0 ? (
|
||||
<span className="flex items-center gap-1 text-sm">
|
||||
<Star className="h-4 w-4 fill-current" aria-hidden />
|
||||
@@ -187,10 +200,10 @@ function ActionButton({
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={cn(
|
||||
'flex h-16 w-16 items-center justify-center rounded-full border-2 bg-[var(--card)] shadow-lg',
|
||||
'flex h-16 w-16 items-center justify-center rounded-full border-2 bg-raised shadow-lg',
|
||||
'transition hover:scale-105 active:scale-95',
|
||||
isHire
|
||||
? 'border-[var(--color-go-500)] text-[var(--color-go-500)]'
|
||||
? 'border-[var(--color-go-600)] text-[var(--color-go-600)]'
|
||||
: 'border-[var(--color-stop-500)] text-[var(--color-stop-500)]',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Plus, X } from 'lucide-react';
|
||||
import { MAX_SKILL_LENGTH, MAX_SKILLS } from '@linkder/shared';
|
||||
import { api } from '@/lib/trpc';
|
||||
import { Button, FormError, Input, SettingsGroup } from '@/components/ui';
|
||||
|
||||
/**
|
||||
* What this pro is actually good at, in their own words.
|
||||
*
|
||||
* Free text on purpose. The trade list is a closed set because matching and
|
||||
* licence checks run on it; this is the line underneath that separates two
|
||||
* plumbers who match the same job — "underfloor heating", "listed buildings",
|
||||
* "emergency callouts". Nothing matches on it, so nothing here can distort the
|
||||
* deck; it only has to read well.
|
||||
*/
|
||||
export function SkillsGroup({ skills }: { skills: string[] }) {
|
||||
const utils = api.useUtils();
|
||||
const [list, setList] = useState<string[]>(skills);
|
||||
const [draft, setDraft] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
|
||||
// Adopt the server's list until the pro starts editing — after that the
|
||||
// in-progress edit wins, or a refetch would wipe it mid-sentence.
|
||||
useEffect(() => {
|
||||
if (!dirty) setList(skills);
|
||||
}, [skills, dirty]);
|
||||
|
||||
const save = api.pro.updateSkills.useMutation({
|
||||
onSuccess: ({ skills: next }) => {
|
||||
setList(next);
|
||||
setDirty(false);
|
||||
void utils.pro.me.invalidate();
|
||||
},
|
||||
onError: (e) => setError(e.message),
|
||||
});
|
||||
|
||||
function add() {
|
||||
const value = draft.trim();
|
||||
if (!value) return;
|
||||
if (value.length < 2) return setError('That is too short to mean anything.');
|
||||
if (list.some((s) => s.toLocaleLowerCase() === value.toLocaleLowerCase())) {
|
||||
setDraft('');
|
||||
return setError('That one is already on the list.');
|
||||
}
|
||||
if (list.length >= MAX_SKILLS) {
|
||||
return setError(`${MAX_SKILLS} is the most a customer will read.`);
|
||||
}
|
||||
|
||||
setList([...list, value]);
|
||||
setDraft('');
|
||||
setError(null);
|
||||
setDirty(true);
|
||||
}
|
||||
|
||||
function remove(skill: string) {
|
||||
setList(list.filter((s) => s !== skill));
|
||||
setError(null);
|
||||
setDirty(true);
|
||||
}
|
||||
|
||||
const full = list.length >= MAX_SKILLS;
|
||||
|
||||
return (
|
||||
<SettingsGroup
|
||||
title="Skills"
|
||||
note="Shown to customers. Your trades decide which jobs reach you; these say what you are best at within them."
|
||||
>
|
||||
<div className="flex flex-col gap-3 px-4 py-4">
|
||||
{list.length > 0 ? (
|
||||
<ul className="flex flex-wrap gap-2">
|
||||
{list.map((skill) => (
|
||||
<li key={skill}>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-pill border border-hairline py-1.5 pl-3 pr-1.5 text-body-sm text-strong">
|
||||
{skill}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(skill)}
|
||||
aria-label={`Remove ${skill}`}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-pill text-faint transition-colors duration-[120ms] ease-standard hover:bg-sunken hover:text-strong"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" aria-hidden />
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-body-sm text-muted">
|
||||
Nothing yet. Two or three specific ones beat a long generic list.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/*
|
||||
Not a <form>: this sits inside the profile screen, and a nested form
|
||||
would submit the wrong thing the day that screen grows one.
|
||||
*/}
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={draft}
|
||||
onChange={(e) => {
|
||||
setDraft(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== 'Enter') return;
|
||||
e.preventDefault();
|
||||
add();
|
||||
}}
|
||||
maxLength={MAX_SKILL_LENGTH}
|
||||
placeholder="Boiler repair"
|
||||
aria-label="Add a skill"
|
||||
disabled={full}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="md"
|
||||
onClick={add}
|
||||
disabled={!draft.trim() || full}
|
||||
>
|
||||
<Plus className="h-4 w-4" aria-hidden />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-meta text-faint tabular-nums">
|
||||
{list.length}/{MAX_SKILLS}
|
||||
</p>
|
||||
|
||||
{error && <FormError>{error}</FormError>}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
size="md"
|
||||
block
|
||||
disabled={!dirty}
|
||||
busy={save.isPending}
|
||||
onClick={() => save.mutate({ skills: list })}
|
||||
>
|
||||
{dirty ? 'Save skills' : 'Saved'}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { LocateFixed } from 'lucide-react';
|
||||
import {
|
||||
DEFAULT_SERVICE_RADIUS_M,
|
||||
MAX_SERVICE_RADIUS_M,
|
||||
MIN_SERVICE_RADIUS_M,
|
||||
} from '@linkder/shared';
|
||||
import { api } from '@/lib/trpc';
|
||||
import { Button, FormError, Input, SettingsGroup } from '@/components/ui';
|
||||
|
||||
const CITY_NAME = process.env.NEXT_PUBLIC_CITY_NAME ?? 'the city centre';
|
||||
|
||||
/**
|
||||
* Where you are, and how far you will go.
|
||||
*
|
||||
* One group for both sides of the market, because it is one question — only the
|
||||
* words change. What it writes does not: a pro's answer is their service area
|
||||
* and lands on the pro profile the deck matches against, a customer's is a
|
||||
* search preference and lands on the user. The server picks; this only renders.
|
||||
*/
|
||||
export function LocationGroup({ isPro }: { isPro: boolean }) {
|
||||
const utils = api.useUtils();
|
||||
const saved = api.user.location.useQuery();
|
||||
|
||||
const [addressText, setAddressText] = useState('');
|
||||
const [radiusKm, setRadiusKm] = useState(DEFAULT_SERVICE_RADIUS_M / 1000);
|
||||
const [pin, setPin] = useState<{ lat: number; lng: number } | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [savedJustNow, setSavedJustNow] = useState(false);
|
||||
// Seeding the controls from the query would otherwise overwrite what someone
|
||||
// is halfway through typing, every time the query refetches.
|
||||
const [dirty, setDirty] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!saved.data || dirty) return;
|
||||
setAddressText(saved.data.addressText ?? '');
|
||||
setRadiusKm(Math.round(saved.data.radiusM / 1000));
|
||||
setPin(saved.data.location);
|
||||
}, [saved.data, dirty]);
|
||||
|
||||
const update = api.user.updateLocation.useMutation({
|
||||
onSuccess: () => {
|
||||
setDirty(false);
|
||||
setSavedJustNow(true);
|
||||
void utils.user.location.invalidate();
|
||||
// A customer's deck is filtered by this, so it cannot keep serving the
|
||||
// results of the old radius.
|
||||
void utils.deck.invalidate();
|
||||
},
|
||||
onError: (e) => setError(e.message),
|
||||
});
|
||||
|
||||
function edit<T>(set: (value: T) => void) {
|
||||
return (value: T) => {
|
||||
set(value);
|
||||
setDirty(true);
|
||||
setSavedJustNow(false);
|
||||
setError(null);
|
||||
};
|
||||
}
|
||||
|
||||
const title = isPro ? 'Where you work' : 'Where you are';
|
||||
|
||||
if (saved.isLoading) {
|
||||
return (
|
||||
<SettingsGroup title={title}>
|
||||
<div className="h-44 animate-pulse bg-inset" />
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
|
||||
// A pro who has not finished onboarding has no service area yet, and inventing
|
||||
// one here would be a second source of truth for the wizard to fight.
|
||||
if (saved.data?.needsProfile) {
|
||||
return (
|
||||
<SettingsGroup title={title} note="Finish your profile to set your base and radius.">
|
||||
<div className="px-4 py-3.5 text-body-sm text-muted">
|
||||
Your working area is part of your pro profile.
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsGroup
|
||||
title={title}
|
||||
note={
|
||||
saved.data?.reviewOnChange
|
||||
? 'Changing your base or radius sends your profile back for review.'
|
||||
: isPro
|
||||
? 'You are only shown jobs inside this radius.'
|
||||
: 'Your deck is centred here, and only shows pros inside this range.'
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4 px-4 py-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="flex flex-col gap-2">
|
||||
<span className="text-body-sm text-strong">
|
||||
{isPro ? 'Base address' : 'Your address'}
|
||||
</span>
|
||||
<Input
|
||||
value={addressText}
|
||||
onChange={(e) => edit(setAddressText)(e.target.value)}
|
||||
maxLength={255}
|
||||
placeholder={`Neighbourhood, ${CITY_NAME}`}
|
||||
/>
|
||||
</label>
|
||||
<p className="text-meta text-muted">
|
||||
{pin
|
||||
? `Pinned to ${pin.lat.toFixed(4)}, ${pin.lng.toFixed(4)}. `
|
||||
: `No pin yet — we measure from ${CITY_NAME}. `}
|
||||
{isPro
|
||||
? 'Matching uses the pin, never the text.'
|
||||
: 'Matching uses the pin; your address is only shared once you book.'}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="self-start"
|
||||
onClick={() =>
|
||||
navigator.geolocation?.getCurrentPosition(
|
||||
(pos) => edit(setPin)({ lat: pos.coords.latitude, lng: pos.coords.longitude }),
|
||||
() => setError('We could not get your location. Type an address instead.'),
|
||||
)
|
||||
}
|
||||
>
|
||||
<LocateFixed className="h-4 w-4" aria-hidden />
|
||||
Use my current location
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<label className="flex flex-col gap-2">
|
||||
<span className="text-body-sm text-strong">
|
||||
{isPro ? 'How far will you travel?' : 'How far will you look?'}{' '}
|
||||
<span className="text-muted tabular-nums">{radiusKm} km</span>
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min={MIN_SERVICE_RADIUS_M / 1000}
|
||||
max={MAX_SERVICE_RADIUS_M / 1000}
|
||||
value={radiusKm}
|
||||
onChange={(e) => edit(setRadiusKm)(Number(e.target.value))}
|
||||
className="w-full accent-brand-500"
|
||||
/>
|
||||
<span className="flex justify-between text-meta text-faint tabular-nums">
|
||||
<span>{MIN_SERVICE_RADIUS_M / 1000} km</span>
|
||||
<span>{MAX_SERVICE_RADIUS_M / 1000} km</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{error && <FormError>{error}</FormError>}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
size="md"
|
||||
block
|
||||
disabled={!dirty}
|
||||
busy={update.isPending}
|
||||
onClick={() =>
|
||||
update.mutate({
|
||||
addressText,
|
||||
radiusM: radiusKm * 1000,
|
||||
...(pin ? { location: pin } : {}),
|
||||
})
|
||||
}
|
||||
>
|
||||
{savedJustNow && !dirty ? 'Saved' : 'Save location'}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
@@ -4,3 +4,5 @@ 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';
|
||||
export { SettingsGroup, SettingsRow, SettingsToggle } from './settings-row';
|
||||
export { ToastProvider, useToast, type ToastTone } from './toast';
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
'use client';
|
||||
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/** A titled group of rows. Settings is a list of lists. */
|
||||
export function SettingsGroup({
|
||||
title,
|
||||
note,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
note?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="mb-6">
|
||||
<h2 className="mb-2 px-1 text-overline uppercase text-faint">{title}</h2>
|
||||
<div className="divide-y divide-hairline overflow-hidden rounded-card border border-hairline bg-raised">
|
||||
{children}
|
||||
</div>
|
||||
{note && <p className="mt-2 px-1 text-meta text-muted">{note}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** A read-only or navigational row. */
|
||||
export function SettingsRow({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
onClick,
|
||||
danger,
|
||||
disabled,
|
||||
}: {
|
||||
label: string;
|
||||
value?: React.ReactNode;
|
||||
hint?: string;
|
||||
onClick?: () => void;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const interactive = Boolean(onClick) && !disabled;
|
||||
const Tag = interactive ? 'button' : 'div';
|
||||
|
||||
return (
|
||||
<Tag
|
||||
{...(interactive ? { type: 'button' as const, onClick } : {})}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 px-4 py-3.5 text-left',
|
||||
interactive && 'transition-colors duration-[120ms] ease-standard hover:bg-sunken',
|
||||
disabled && 'opacity-45',
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className={cn('block text-body-sm', danger ? 'text-stop-500' : 'text-strong')}>
|
||||
{label}
|
||||
</span>
|
||||
{hint && <span className="mt-0.5 block text-meta text-muted">{hint}</span>}
|
||||
</span>
|
||||
{value !== undefined && (
|
||||
<span className="shrink-0 text-body-sm text-muted">{value}</span>
|
||||
)}
|
||||
{interactive && <ChevronRight className="h-4 w-4 shrink-0 text-faint" aria-hidden />}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A row carrying a switch. The label is the control's accessible name, so the
|
||||
* whole row is one tap target rather than a label and a separate 20px switch.
|
||||
*/
|
||||
export function SettingsToggle({
|
||||
label,
|
||||
hint,
|
||||
checked,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
checked: boolean;
|
||||
onChange: (next: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 px-4 py-3.5 text-left',
|
||||
'transition-colors duration-[120ms] ease-standard hover:bg-sunken',
|
||||
disabled && 'pointer-events-none opacity-45',
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-body-sm text-strong">{label}</span>
|
||||
{hint && <span className="mt-0.5 block text-meta text-muted">{hint}</span>}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'relative h-[1.6rem] w-[2.75rem] shrink-0 rounded-pill',
|
||||
'transition-colors duration-[120ms] ease-standard',
|
||||
checked ? 'bg-brand-500' : 'bg-ink-300',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'absolute top-[0.2rem] h-[1.2rem] w-[1.2rem] rounded-pill bg-white shadow-sm',
|
||||
'transition-[left] duration-[120ms] ease-standard',
|
||||
checked ? 'left-[1.35rem]' : 'left-[0.2rem]',
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useCallback, useContext, useRef, useState } from 'react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { AlertTriangle, CheckCircle2, Info, X, XCircle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Transient messages.
|
||||
*
|
||||
* Deliberately NOT a second visual language: a toast is §6.5's banner on a
|
||||
* shadow, so a tone means the same thing wherever it appears. What it adds is
|
||||
* placement — bottom of the viewport rather than in the flow — for things the
|
||||
* user needs to be told but that do not belong to any one field.
|
||||
*
|
||||
* Use `<FormError>` for "this input is wrong" and a `<Banner>` for state a
|
||||
* screen is permanently in. A toast is for the third case: an outcome that has
|
||||
* no home on the screen, like a provider the server is not configured for.
|
||||
*/
|
||||
export type ToastTone = 'info' | 'success' | 'warning' | 'error';
|
||||
|
||||
export interface ToastOptions {
|
||||
tone?: ToastTone;
|
||||
title?: string;
|
||||
/** Milliseconds on screen. `null` keeps it up until dismissed. */
|
||||
duration?: number | null;
|
||||
}
|
||||
|
||||
interface Toast extends ToastOptions {
|
||||
id: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
type ToastFn = (message: string, options?: ToastOptions) => void;
|
||||
|
||||
const ToastContext = createContext<ToastFn | null>(null);
|
||||
|
||||
/**
|
||||
* Throws rather than no-oping when the provider is missing. A toast that
|
||||
* silently does nothing is worse than no toast — it looks like the click did
|
||||
* not register, and the bug only shows up in the case nobody tests.
|
||||
*/
|
||||
export function useToast(): ToastFn {
|
||||
const toast = useContext(ToastContext);
|
||||
if (!toast) throw new Error('useToast must be used inside <ToastProvider>');
|
||||
return toast;
|
||||
}
|
||||
|
||||
const DEFAULT_DURATION = 6000;
|
||||
|
||||
const TONE_SURFACE: Record<ToastTone, string> = {
|
||||
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',
|
||||
};
|
||||
|
||||
const TONE_ICON = {
|
||||
info: Info,
|
||||
success: CheckCircle2,
|
||||
warning: AlertTriangle,
|
||||
error: XCircle,
|
||||
} as const;
|
||||
|
||||
const TONE_ICON_COLOR: Record<ToastTone, string> = {
|
||||
info: 'text-brand-500',
|
||||
success: 'text-go-600',
|
||||
warning: 'text-sun-500',
|
||||
error: 'text-stop-500',
|
||||
};
|
||||
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
const nextId = useRef(0);
|
||||
// Cleared on dismiss so a hand-dismissed toast does not leave a timer that
|
||||
// later removes whatever toast happens to be in its place.
|
||||
const timers = useRef(new Map<number, ReturnType<typeof setTimeout>>());
|
||||
|
||||
const dismiss = useCallback((id: number) => {
|
||||
const timer = timers.current.get(id);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timers.current.delete(id);
|
||||
}
|
||||
setToasts((current) => current.filter((t) => t.id !== id));
|
||||
}, []);
|
||||
|
||||
const toast = useCallback<ToastFn>(
|
||||
(message, options = {}) => {
|
||||
const id = nextId.current++;
|
||||
const duration = options.duration === undefined ? DEFAULT_DURATION : options.duration;
|
||||
// Three is the point where the stack starts covering the thing the user
|
||||
// was looking at; drop the oldest rather than growing upward forever.
|
||||
setToasts((current) => [...current, { ...options, id, message }].slice(-3));
|
||||
if (duration !== null) {
|
||||
timers.current.set(id, setTimeout(() => dismiss(id), duration));
|
||||
}
|
||||
},
|
||||
[dismiss],
|
||||
);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={toast}>
|
||||
{children}
|
||||
{/*
|
||||
The live region is mounted always, empty or not: a region inserted at
|
||||
the same moment as its content is not reliably announced.
|
||||
*/}
|
||||
<div
|
||||
aria-live="polite"
|
||||
// Bottom-centred and capped narrower than the app column so it reads as
|
||||
// an overlay on the phone screen rather than a full-width bar.
|
||||
className="pointer-events-none fixed inset-x-0 bottom-0 z-50 flex flex-col items-center gap-2 px-4 pb-[calc(1rem+env(safe-area-inset-bottom))]"
|
||||
>
|
||||
<AnimatePresence initial={false}>
|
||||
{toasts.map((t) => (
|
||||
<ToastCard key={t.id} toast={t} onDismiss={() => dismiss(t.id)} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function ToastCard({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }) {
|
||||
const tone = toast.tone ?? 'info';
|
||||
const Icon = TONE_ICON[tone];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
// §7 motion-base for enter/exit. The spring is decorative, so it collapses
|
||||
// to a plain fade under reduced motion.
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 12 }}
|
||||
transition={{ duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
|
||||
className={cn(
|
||||
'pointer-events-auto flex w-full max-w-[22rem] gap-3 rounded-card border p-4',
|
||||
'text-ink-950 shadow-lg',
|
||||
TONE_SURFACE[tone],
|
||||
)}
|
||||
>
|
||||
<Icon className={cn('mt-0.5 h-5 w-5 shrink-0', TONE_ICON_COLOR[tone])} aria-hidden />
|
||||
<div className="min-w-0 flex-1">
|
||||
{toast.title && <p className="font-display text-h4 text-ink-950">{toast.title}</p>}
|
||||
<p className={cn('text-body-sm text-ink-800', toast.title && 'mt-1')}>{toast.message}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
aria-label="Dismiss"
|
||||
className="-m-1 h-8 w-8 shrink-0 rounded-pill p-1 text-ink-600 transition-colors duration-[120ms] ease-standard hover:bg-ink-950/8 hover:text-ink-950"
|
||||
>
|
||||
<X className="h-full w-full" aria-hidden />
|
||||
</button>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user