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:
@@ -12,6 +12,9 @@ REDIS_URL=redis://localhost:6389
|
|||||||
# generate with: openssl rand -base64 32
|
# generate with: openssl rand -base64 32
|
||||||
AUTH_SECRET=
|
AUTH_SECRET=
|
||||||
AUTH_URL=http://localhost:3000
|
AUTH_URL=http://localhost:3000
|
||||||
|
# Optional. Leave blank and phone OTP is the only route: the "Continue with
|
||||||
|
# Google" button still renders, and tells the user it is not set up.
|
||||||
|
# Authorised redirect URI: {NEXT_PUBLIC_APP_URL}/api/auth/callback/google
|
||||||
AUTH_GOOGLE_ID=
|
AUTH_GOOGLE_ID=
|
||||||
AUTH_GOOGLE_SECRET=
|
AUTH_GOOGLE_SECRET=
|
||||||
|
|
||||||
@@ -48,3 +51,6 @@ NEXT_PUBLIC_CITY_NAME=Barcelona
|
|||||||
NEXT_PUBLIC_CITY_LAT=41.3874
|
NEXT_PUBLIC_CITY_LAT=41.3874
|
||||||
NEXT_PUBLIC_CITY_LNG=2.1686
|
NEXT_PUBLIC_CITY_LNG=2.1686
|
||||||
TWILIO_FROM_NUMBER=
|
TWILIO_FROM_NUMBER=
|
||||||
|
|
||||||
|
# Dev-only fixed login (+34600000000 / code 000000). MUST stay false/unset in production.
|
||||||
|
ALLOW_DEV_LOGIN=false
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ const config: NextConfig = {
|
|||||||
transpilePackages: ['@linkder/api', '@linkder/db', '@linkder/shared', '@linkder/storage'],
|
transpilePackages: ['@linkder/api', '@linkder/db', '@linkder/shared', '@linkder/storage'],
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
|
// Seed data only — real pros upload to R2. The deck renders a plain <img>,
|
||||||
|
// so these matter only where next/image is used.
|
||||||
|
{ protocol: 'https', hostname: 'i.pravatar.cc' },
|
||||||
{ protocol: 'https', hostname: 'picsum.photos' },
|
{ protocol: 'https', hostname: 'picsum.photos' },
|
||||||
{ protocol: 'https', hostname: '**.r2.dev' },
|
{ protocol: 'https', hostname: '**.r2.dev' },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/*
|
||||||
|
* Kill-switch service worker.
|
||||||
|
*
|
||||||
|
* This app does not use a service worker. One was left registered on
|
||||||
|
* localhost:3000 by a DIFFERENT project — service workers are scoped to an
|
||||||
|
* origin, not a project, so any app later served on that port inherits it. It
|
||||||
|
* intercepted requests and served dead chunks, which surfaced as
|
||||||
|
* "Cannot read properties of undefined (reading 'call')" in RootLayout and
|
||||||
|
* survived deleting .next and restarting the dev server.
|
||||||
|
*
|
||||||
|
* The browser re-fetches /sw.js to check for updates; serving this makes the
|
||||||
|
* stale worker replace itself with one that immediately unregisters and drops
|
||||||
|
* every cache it holds.
|
||||||
|
*/
|
||||||
|
self.addEventListener('install', () => self.skipWaiting());
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
const keys = await caches.keys();
|
||||||
|
await Promise.all(keys.map((k) => caches.delete(k)));
|
||||||
|
await self.registration.unregister();
|
||||||
|
const clientList = await self.clients.matchAll({ type: 'window' });
|
||||||
|
for (const client of clientList) client.navigate(client.url);
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { Metadata, Viewport } from 'next';
|
import type { Metadata, Viewport } from 'next';
|
||||||
import { Wix_Madefor_Display, Wix_Madefor_Text } from 'next/font/google';
|
import { Wix_Madefor_Display, Wix_Madefor_Text } from 'next/font/google';
|
||||||
import { TRPCProvider } from '@/lib/trpc';
|
import { TRPCProvider } from '@/lib/trpc';
|
||||||
|
import { ToastProvider } from '@/components/ui';
|
||||||
|
import { PhoneFrame } from '@/components/chrome/phone-frame';
|
||||||
import '@/styles/globals.css';
|
import '@/styles/globals.css';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -52,7 +54,18 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||||||
illustration that has to go full-bleed past it. §4
|
illustration that has to go full-bleed past it. §4
|
||||||
*/}
|
*/}
|
||||||
<body className="bg-sunken text-strong antialiased">
|
<body className="bg-sunken text-strong antialiased">
|
||||||
<TRPCProvider>{children}</TRPCProvider>
|
<TRPCProvider>
|
||||||
|
<ToastProvider>
|
||||||
|
{/*
|
||||||
|
The phone lives HERE, not in a page, so that every route renders
|
||||||
|
inside the screen. Putting it in one page meant sign-in, onboarding
|
||||||
|
and the job form all escaped the frame.
|
||||||
|
*/}
|
||||||
|
<div className="flex min-h-dvh w-full items-center justify-center overflow-hidden sm:p-8">
|
||||||
|
<PhoneFrame>{children}</PhoneFrame>
|
||||||
|
</div>
|
||||||
|
</ToastProvider>
|
||||||
|
</TRPCProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export default async function OnboardingPage() {
|
|||||||
if (me.role === 'pro') redirect(me.hasProProfile ? '/pro' : '/pro/onboarding');
|
if (me.role === 'pro') redirect(me.hasProProfile ? '/pro' : '/pro/onboarding');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BareShell>
|
<BareShell back>
|
||||||
<h1 className="text-h1">What brings you here?</h1>
|
<h1 className="text-h1">What brings you here?</h1>
|
||||||
<p className="mt-3 text-body text-muted">
|
<p className="mt-3 text-body text-muted">
|
||||||
You can only pick once, so choose the one that fits.
|
You can only pick once, so choose the one that fits.
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { getApi } from '@/server/caller';
|
import { getApi } from '@/server/caller';
|
||||||
import { PhoneFrame } from '@/components/chrome/phone-frame';
|
|
||||||
import { ShowcaseDeck } from './showcase-deck';
|
import { ShowcaseDeck } from './showcase-deck';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
@@ -18,13 +17,15 @@ export const dynamic = 'force-dynamic';
|
|||||||
*/
|
*/
|
||||||
export default async function Home() {
|
export default async function Home() {
|
||||||
const api = await getApi();
|
const api = await getApi();
|
||||||
const { cards } = await api.deck.showcase();
|
const [{ cards }, categories] = await Promise.all([
|
||||||
|
api.deck.showcase(),
|
||||||
|
api.job.categories(),
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-dvh items-center justify-center sm:p-8">
|
<ShowcaseDeck
|
||||||
<PhoneFrame>
|
categories={categories.map((c) => ({ id: c.id, name: c.name }))}
|
||||||
<ShowcaseDeck cards={cards} />
|
initialCards={cards}
|
||||||
</PhoneFrame>
|
/>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export function OnboardingWizard({
|
|||||||
const upsert = api.pro.upsertProfile.useMutation();
|
const upsert = api.pro.upsertProfile.useMutation();
|
||||||
const addMedia = api.pro.addMedia.useMutation();
|
const addMedia = api.pro.addMedia.useMutation();
|
||||||
const addCredential = api.pro.addCredential.useMutation();
|
const addCredential = api.pro.addCredential.useMutation();
|
||||||
const setEmailMutation = api.user.setEmail.useMutation();
|
const setEmailMutation = api.user.requestEmailChange.useMutation();
|
||||||
const submit = api.pro.submitForReview.useMutation({
|
const submit = api.pro.submitForReview.useMutation({
|
||||||
onSuccess: () => router.push('/pro'),
|
onSuccess: () => router.push('/pro'),
|
||||||
onError: (e) => setError(e.message),
|
onError: (e) => setError(e.message),
|
||||||
@@ -276,12 +276,14 @@ export function OnboardingWizard({
|
|||||||
await setEmailMutation.mutateAsync({ email });
|
await setEmailMutation.mutateAsync({ email });
|
||||||
setError(null);
|
setError(null);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
|
// The address is not live until the emailed link is opened,
|
||||||
|
// so do not let the wizard imply the step is finished.
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError((e as Error).message);
|
setError((e as Error).message);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Save email
|
{setEmailMutation.isSuccess ? "Check your inbox" : "Send confirmation"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Hammer } from 'lucide-react';
|
||||||
|
import { Card } from '@/components/deck';
|
||||||
|
import { SignedOut } from '@/components/chrome/signed-out';
|
||||||
|
import { SkillsGroup } from '@/components/profile/skills-group';
|
||||||
|
import { Banner, SettingsGroup, SettingsRow, SettingsToggle, buttonClasses } from '@/components/ui';
|
||||||
|
import { api, type RouterOutputs } from '@/lib/trpc';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Profile tab.
|
||||||
|
*
|
||||||
|
* Two different screens behind one tab, because the word means two different
|
||||||
|
* things here: for a pro the profile IS the product — the card clients swipe —
|
||||||
|
* while a client has almost nothing to show and is better served by a route into
|
||||||
|
* pro onboarding, since a cold deck is what actually kills this marketplace.
|
||||||
|
*/
|
||||||
|
export function ProfilePanel() {
|
||||||
|
const me = api.user.me.useQuery(undefined, { retry: false });
|
||||||
|
|
||||||
|
if (me.isLoading) {
|
||||||
|
return (
|
||||||
|
<Shell>
|
||||||
|
<div className="h-64 animate-pulse rounded-deck bg-inset" />
|
||||||
|
</Shell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (me.error || !me.data) {
|
||||||
|
return (
|
||||||
|
<SignedOut
|
||||||
|
title="Sign in to see your profile"
|
||||||
|
body="Pros manage the card customers swipe here."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return me.data.role === 'pro' ? <ProProfile /> : <ClientProfile me={me.data} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Shell({ children }: { children: React.ReactNode }) {
|
||||||
|
return <div className="min-h-0 flex-1 overflow-y-auto px-4 pb-4 pt-[3.25rem]">{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Me = RouterOutputs['user']['me'];
|
||||||
|
|
||||||
|
/* ─────────────────────────────── pro ─────────────────────────────── */
|
||||||
|
|
||||||
|
/** What each verification status means commercially — this is the row that decides
|
||||||
|
* whether the pro exists to customers at all. */
|
||||||
|
const STATUS: Record<
|
||||||
|
string,
|
||||||
|
{ label: string; body: string; tone: 'success' | 'warning' | 'error' }
|
||||||
|
> = {
|
||||||
|
draft: {
|
||||||
|
label: 'Not submitted',
|
||||||
|
body: 'Customers cannot see you yet. Finish your profile to go live.',
|
||||||
|
tone: 'warning',
|
||||||
|
},
|
||||||
|
pending: {
|
||||||
|
label: 'In review',
|
||||||
|
body: 'We check every ID, licence and insurance certificate. Usually about a day.',
|
||||||
|
tone: 'warning',
|
||||||
|
},
|
||||||
|
verified: {
|
||||||
|
label: 'Live on the deck',
|
||||||
|
body: 'Customers in your area can see and swipe your card.',
|
||||||
|
tone: 'success',
|
||||||
|
},
|
||||||
|
rejected: {
|
||||||
|
label: 'Not approved',
|
||||||
|
body: 'Something did not check out. Contact support and we will tell you what to fix.',
|
||||||
|
tone: 'error',
|
||||||
|
},
|
||||||
|
suspended: {
|
||||||
|
label: 'Suspended',
|
||||||
|
body: 'Your account is on hold. Contact support — this cannot be lifted from here.',
|
||||||
|
tone: 'error',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function ProProfile() {
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const preview = api.pro.previewCard.useQuery();
|
||||||
|
const profile = api.pro.me.useQuery();
|
||||||
|
|
||||||
|
const setAccepting = api.pro.setAcceptingJobs.useMutation({
|
||||||
|
onSettled: () => {
|
||||||
|
void utils.pro.me.invalidate();
|
||||||
|
void utils.pro.previewCard.invalidate();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (preview.isLoading || profile.isLoading) {
|
||||||
|
return (
|
||||||
|
<Shell>
|
||||||
|
<div className="h-64 animate-pulse rounded-deck bg-inset" />
|
||||||
|
</Shell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const card = preview.data;
|
||||||
|
const p = profile.data;
|
||||||
|
|
||||||
|
if (!card || !p) {
|
||||||
|
return (
|
||||||
|
<Shell>
|
||||||
|
<h1 className="text-h2">Your profile</h1>
|
||||||
|
<p className="mt-3 text-body-sm text-muted">You have not set up your pro profile yet.</p>
|
||||||
|
<Link
|
||||||
|
href="/pro/onboarding"
|
||||||
|
className={`mt-5 ${buttonClasses({ variant: 'primary', size: 'lg', block: true })}`}
|
||||||
|
>
|
||||||
|
Set up my profile
|
||||||
|
</Link>
|
||||||
|
</Shell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = STATUS[p.verificationStatus] ?? STATUS.draft!;
|
||||||
|
const isVerified = p.verificationStatus === 'verified';
|
||||||
|
const has = (kind: string) => p.credentials.some((c) => c.kind === kind);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Shell>
|
||||||
|
<h1 className="mb-1 text-h2">Your card</h1>
|
||||||
|
<p className="mb-3 text-body-sm text-muted">Exactly what customers see when they swipe.</p>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
The real <Card>, not a lookalike — a copy would drift the moment either
|
||||||
|
side changed, and the whole point is that a pro can trust this preview.
|
||||||
|
No onDecide, so it renders static and non-draggable.
|
||||||
|
*/}
|
||||||
|
<div className="relative mb-2 h-[420px] w-full">
|
||||||
|
<Card card={card} />
|
||||||
|
</div>
|
||||||
|
<p className="mb-5 text-meta text-faint">
|
||||||
|
The distance shown is an example — customers see how far you are from their own job.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Banner tone={status.tone} title={status.label}>
|
||||||
|
{status.body}
|
||||||
|
</Banner>
|
||||||
|
|
||||||
|
<div className="mt-5">
|
||||||
|
<SettingsGroup title="Availability">
|
||||||
|
<SettingsToggle
|
||||||
|
label="Accepting jobs"
|
||||||
|
hint={
|
||||||
|
p.isAcceptingJobs
|
||||||
|
? 'You appear on the deck'
|
||||||
|
: 'Holiday mode — you stay verified but hidden'
|
||||||
|
}
|
||||||
|
checked={p.isAcceptingJobs}
|
||||||
|
disabled={setAccepting.isPending}
|
||||||
|
onChange={(v) => setAccepting.mutate({ accepting: v })}
|
||||||
|
/>
|
||||||
|
</SettingsGroup>
|
||||||
|
|
||||||
|
<SettingsGroup
|
||||||
|
title="Your card"
|
||||||
|
/*
|
||||||
|
* upsertProfile demotes a verified pro to `pending` when trade,
|
||||||
|
* location or radius changes, which silently drops them off the deck.
|
||||||
|
* Warn before the edit, not after it.
|
||||||
|
*/
|
||||||
|
note={
|
||||||
|
isVerified
|
||||||
|
? 'Changing your trades or service area takes you off the deck until we re-check — usually a day.'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SettingsRow
|
||||||
|
label="Photos"
|
||||||
|
hint="The first one is what customers see"
|
||||||
|
value={String(p.media.length)}
|
||||||
|
/>
|
||||||
|
<SettingsRow label="Trades" value={String(p.categoryIds.length)} />
|
||||||
|
<SettingsRow label="Hourly rate" value={`€${(p.hourlyRateCents / 100).toFixed(0)}`} />
|
||||||
|
<SettingsRow
|
||||||
|
label="Service area"
|
||||||
|
value={`${Math.round(p.serviceRadiusM / 1000)} km`}
|
||||||
|
/>
|
||||||
|
</SettingsGroup>
|
||||||
|
|
||||||
|
<SkillsGroup skills={p.skills} />
|
||||||
|
|
||||||
|
<SettingsGroup title="Documents" note="Only our review team ever sees these.">
|
||||||
|
<SettingsRow label="Photo ID" value={has('id') ? 'Uploaded' : 'Missing'} />
|
||||||
|
<SettingsRow label="Insurance" value={has('insurance') ? 'Uploaded' : 'Missing'} />
|
||||||
|
<SettingsRow label="Trade licence" value={has('licence') ? 'Uploaded' : 'Not provided'} />
|
||||||
|
</SettingsGroup>
|
||||||
|
|
||||||
|
<SettingsGroup title="Your record">
|
||||||
|
<SettingsRow
|
||||||
|
label="Rating"
|
||||||
|
// Zero reviews is not "0.0 stars" — that reads as a bad score.
|
||||||
|
value={
|
||||||
|
p.ratingCount > 0
|
||||||
|
? `${Number(p.ratingAvg).toFixed(1)} ★ (${p.ratingCount})`
|
||||||
|
: 'No jobs yet'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SettingsRow label="Jobs completed" value={String(p.completedJobs)} />
|
||||||
|
</SettingsGroup>
|
||||||
|
</div>
|
||||||
|
</Shell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ───────────────────────────── client ────────────────────────────── */
|
||||||
|
|
||||||
|
function ClientProfile({ me }: { me: Me }) {
|
||||||
|
const jobs = api.job.mine.useQuery();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Shell>
|
||||||
|
<h1 className="mb-5 text-h2">Your profile</h1>
|
||||||
|
|
||||||
|
<SettingsGroup title="Account">
|
||||||
|
<SettingsRow label="Name" value={me.name ?? 'Not set'} />
|
||||||
|
<SettingsRow label="Phone" value={me.phoneNumber ?? '—'} />
|
||||||
|
<SettingsRow label="Jobs posted" value={jobs.data ? String(jobs.data.length) : '…'} />
|
||||||
|
</SettingsGroup>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
The most valuable thing on an otherwise empty screen. A marketplace with
|
||||||
|
no pros has no product, so recruiting supply beats decorating a client
|
||||||
|
profile that has nothing on it.
|
||||||
|
*/}
|
||||||
|
<div className="rounded-card border border-hairline bg-raised p-5">
|
||||||
|
<span className="flex items-center gap-2 text-accent">
|
||||||
|
<Hammer className="h-5 w-5" aria-hidden />
|
||||||
|
<span className="text-overline uppercase">For tradespeople</span>
|
||||||
|
</span>
|
||||||
|
<h2 className="mt-3 text-h3">Work with us</h2>
|
||||||
|
<p className="mt-2 text-body-sm text-muted">
|
||||||
|
Get sent local jobs that match your trade. We check every pro’s ID, licence and
|
||||||
|
insurance, so customers arrive ready to book.
|
||||||
|
</p>
|
||||||
|
{/*
|
||||||
|
user.setRole refuses once a job has been posted, so this must not read
|
||||||
|
as a switch that flips this account over.
|
||||||
|
*/}
|
||||||
|
<p className="mt-2 text-meta text-faint">
|
||||||
|
Working as a pro needs its own account — you keep this one for hiring.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/sign-in?next=/pro/onboarding"
|
||||||
|
className={`mt-4 ${buttonClasses({ variant: 'primary', size: 'md', block: true })}`}
|
||||||
|
>
|
||||||
|
Join as a pro
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</Shell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { SettingsGroup, SettingsRow, SettingsToggle, buttonClasses } from '@/components/ui';
|
||||||
|
import { api, type RouterOutputs } from '@/lib/trpc';
|
||||||
|
import { SignedOut } from '@/components/chrome/signed-out';
|
||||||
|
import { LocationGroup } from '@/components/settings/location-group';
|
||||||
|
import { authClient } from '@/lib/auth-client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Settings tab.
|
||||||
|
*
|
||||||
|
* Signed-in only. An anonymous visitor gets a sign-in prompt rather than a
|
||||||
|
* disabled tab, so the bar does not look dead on first open.
|
||||||
|
*/
|
||||||
|
export function SettingsPanel() {
|
||||||
|
const me = api.user.me.useQuery(undefined, { retry: false });
|
||||||
|
|
||||||
|
if (me.isLoading) {
|
||||||
|
return <PanelShell><div className="h-40 animate-pulse rounded-card bg-inset" /></PanelShell>;
|
||||||
|
}
|
||||||
|
if (me.error || !me.data) {
|
||||||
|
return (
|
||||||
|
<SignedOut
|
||||||
|
title="Sign in to manage your account"
|
||||||
|
body="Your notification choices, saved jobs and profile live here."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <SignedIn me={me.data} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PanelShell({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto px-4 pt-[3.25rem] pb-4">
|
||||||
|
<h1 className="mb-5 text-h2">Settings</h1>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Me = RouterOutputs['user']['me'];
|
||||||
|
|
||||||
|
function SignedIn({ me }: { me: Me }) {
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const prefs = api.notification.get.useQuery();
|
||||||
|
const updatePrefs = api.notification.update.useMutation({
|
||||||
|
onMutate: async (next) => {
|
||||||
|
// Optimistic: a switch that lags behind the thumb feels broken.
|
||||||
|
await utils.notification.get.cancel();
|
||||||
|
const previous = utils.notification.get.getData();
|
||||||
|
if (previous) utils.notification.get.setData(undefined, { ...previous, ...next });
|
||||||
|
return { previous };
|
||||||
|
},
|
||||||
|
onError: (_e, _next, context) => {
|
||||||
|
if (context?.previous) utils.notification.get.setData(undefined, context.previous);
|
||||||
|
},
|
||||||
|
onSettled: () => void utils.notification.get.invalidate(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const sessions = api.user.sessions.useQuery();
|
||||||
|
const requestDeletion = api.user.requestDeletion.useMutation();
|
||||||
|
const [deletionAsked, setDeletionAsked] = useState(false);
|
||||||
|
|
||||||
|
const p = prefs.data;
|
||||||
|
const isPro = me.role === 'pro';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PanelShell>
|
||||||
|
<SettingsGroup title="Account">
|
||||||
|
<SettingsRow label="Name" value={me.name ?? 'Not set'} />
|
||||||
|
<SettingsRow
|
||||||
|
label="Email"
|
||||||
|
value={me.hasContactableEmail ? me.email : 'Add an email'}
|
||||||
|
hint={me.hasContactableEmail ? undefined : 'Needed for receipts and payout statements'}
|
||||||
|
/>
|
||||||
|
{/*
|
||||||
|
Read-only by necessity, not by choice: better-auth's phoneNumber plugin
|
||||||
|
rejects any update carrying a phone, and the number is the login
|
||||||
|
credential and the unique key.
|
||||||
|
*/}
|
||||||
|
<SettingsRow label="Phone" value={me.phoneNumber ?? '—'} hint="Contact support to change" />
|
||||||
|
<SettingsRow label="Account type" value={isPro ? 'Professional' : 'Customer'} />
|
||||||
|
</SettingsGroup>
|
||||||
|
|
||||||
|
<LocationGroup isPro={isPro} />
|
||||||
|
|
||||||
|
<SettingsGroup
|
||||||
|
title="Notifications"
|
||||||
|
note="Saved now, applied when messaging goes live. Nothing sends these yet."
|
||||||
|
>
|
||||||
|
<SettingsToggle
|
||||||
|
label="New job requests"
|
||||||
|
hint="SMS when a customer picks you"
|
||||||
|
checked={p?.smsNewRequest ?? true}
|
||||||
|
disabled={!p}
|
||||||
|
onChange={(v) => updatePrefs.mutate({ smsNewRequest: v })}
|
||||||
|
/>
|
||||||
|
<SettingsToggle
|
||||||
|
label="Booking reminders"
|
||||||
|
checked={p?.smsBookingReminder ?? true}
|
||||||
|
disabled={!p}
|
||||||
|
onChange={(v) => updatePrefs.mutate({ smsBookingReminder: v })}
|
||||||
|
/>
|
||||||
|
<SettingsToggle
|
||||||
|
label="Receipts by email"
|
||||||
|
checked={p?.emailReceipts ?? true}
|
||||||
|
disabled={!p}
|
||||||
|
onChange={(v) => updatePrefs.mutate({ emailReceipts: v })}
|
||||||
|
/>
|
||||||
|
<SettingsToggle
|
||||||
|
label="Offers and tips"
|
||||||
|
hint="Occasional. Off by default."
|
||||||
|
checked={p?.smsMarketing ?? false}
|
||||||
|
disabled={!p}
|
||||||
|
onChange={(v) => updatePrefs.mutate({ smsMarketing: v })}
|
||||||
|
/>
|
||||||
|
</SettingsGroup>
|
||||||
|
|
||||||
|
{isPro && (
|
||||||
|
<SettingsGroup title="Working" note="Changing your trades sends your profile back for review.">
|
||||||
|
<SettingsRow label="Trades" hint="Edit in your profile" onClick={() => {}} />
|
||||||
|
</SettingsGroup>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SettingsGroup title="Security">
|
||||||
|
<SettingsRow
|
||||||
|
label="Signed-in devices"
|
||||||
|
value={sessions.data ? String(sessions.data.length) : '…'}
|
||||||
|
/>
|
||||||
|
{sessions.data?.some((s) => s.isImpersonated) && (
|
||||||
|
<SettingsRow
|
||||||
|
label="Admin is viewing your account"
|
||||||
|
hint="A support session is active"
|
||||||
|
danger
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SettingsGroup>
|
||||||
|
|
||||||
|
<SettingsGroup title="Legal and data">
|
||||||
|
<SettingsRow label="Terms of service" onClick={() => {}} />
|
||||||
|
<SettingsRow label="Privacy policy" onClick={() => {}} />
|
||||||
|
<SettingsRow
|
||||||
|
label="Download my data"
|
||||||
|
hint="Everything we hold about you, as JSON"
|
||||||
|
onClick={async () => {
|
||||||
|
const data = await utils.user.exportData.fetch();
|
||||||
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'linkder-data.json';
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<SettingsRow
|
||||||
|
label={deletionAsked ? 'Deletion requested' : 'Delete my account'}
|
||||||
|
hint={
|
||||||
|
deletionAsked
|
||||||
|
? 'We will action this within 30 days'
|
||||||
|
: 'We action requests within 30 days'
|
||||||
|
}
|
||||||
|
danger
|
||||||
|
disabled={deletionAsked || requestDeletion.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
requestDeletion.mutate({}, { onSuccess: () => setDeletionAsked(true) });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SettingsGroup>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
await authClient.signOut();
|
||||||
|
window.location.href = '/';
|
||||||
|
}}
|
||||||
|
className={buttonClasses({ variant: 'outline', size: 'md', block: true })}
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</PanelShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,42 +2,115 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
import type { DeckCard } from '@linkder/db';
|
import type { DeckCard } from '@linkder/db';
|
||||||
import { Deck } from '@/components/deck';
|
import { Deck } from '@/components/deck';
|
||||||
import { buttonClasses } from '@/components/ui';
|
import { Chip } from '@/components/ui';
|
||||||
|
import { PhoneTabs, type PhoneTab } from '@/components/chrome/phone-tabs';
|
||||||
|
import { SettingsPanel } from './settings-panel';
|
||||||
|
import { ProfilePanel } from './profile-panel';
|
||||||
|
import { api } from '@/lib/trpc';
|
||||||
|
|
||||||
|
export interface Category {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The live demo deck inside the phone on the entry screen.
|
* The entry screen: a live deck of real verified pros, and a strip of trades
|
||||||
|
* above it.
|
||||||
*
|
*
|
||||||
* Real pros, real ranking, real drag physics — but `onDecide` deliberately
|
* The trade strip exists because a deck of every trade is useless to someone
|
||||||
* writes NOTHING. A visitor with no session swiping right must not send a job
|
* with a leaking sink — a dating app can show you anyone, a trades app cannot.
|
||||||
* to a real tradesperson; the card simply leaves. The funnel starts when they
|
* Until a trade is picked the deck shows everyone, so the screen is never empty
|
||||||
* tap "Post a job", which is where a real deck (deck.list / deck.swipe, both
|
* and the first swipe costs no taps.
|
||||||
* authenticated) takes over.
|
*
|
||||||
|
* `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 at "Post a job", where the authenticated deck (deck.list /
|
||||||
|
* deck.swipe) takes over.
|
||||||
*/
|
*/
|
||||||
export function ShowcaseDeck({ cards }: { cards: DeckCard[] }) {
|
export function ShowcaseDeck({
|
||||||
const [seen, setSeen] = useState(0);
|
categories,
|
||||||
const done = seen >= cards.length;
|
initialCards,
|
||||||
|
}: {
|
||||||
|
categories: Category[];
|
||||||
|
initialCards: DeckCard[];
|
||||||
|
}) {
|
||||||
|
const [categoryId, setCategoryId] = useState<string | null>(null);
|
||||||
|
const [tab, setTab] = useState<PhoneTab>('swipe');
|
||||||
|
|
||||||
|
// Filtering happens server-side: a page is 20 cards across 8 trades, so
|
||||||
|
// filtering an already-fetched page would leave two or three per trade.
|
||||||
|
const { data, isFetching } = api.deck.showcase.useQuery(
|
||||||
|
categoryId ? { categoryId } : {},
|
||||||
|
{ initialData: categoryId ? undefined : { cards: initialCards }, staleTime: 60_000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const cards = data?.cards ?? [];
|
||||||
|
const selected = categories.find((c) => c.id === categoryId) ?? null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col px-4 pb-4 pt-[3.25rem]">
|
<div className="flex h-full w-full min-w-0 flex-col overflow-hidden">
|
||||||
<header className="mb-3 flex shrink-0 items-baseline justify-between">
|
{tab === 'settings' ? (
|
||||||
<p className="text-h4">Verified pros near you</p>
|
<SettingsPanel />
|
||||||
{!done && cards.length > 0 && (
|
) : tab === 'profile' ? (
|
||||||
<p className="text-meta tabular-nums text-muted">{cards.length - seen} left</p>
|
<ProfilePanel />
|
||||||
|
) : tab !== 'swipe' ? (
|
||||||
|
<div className="flex min-h-0 flex-1 items-center justify-center px-8 text-center text-body-sm text-muted">
|
||||||
|
Coming soon.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Trade strip. Above the card, never over the photo — so it cannot steal
|
||||||
|
the drag gesture and never has to stay legible on a bright image. */}
|
||||||
|
<div className="min-w-0 shrink-0 px-4 pt-[3.25rem]">
|
||||||
|
{selected ? (
|
||||||
|
<Chip selected onClick={() => setCategoryId(null)}>
|
||||||
|
{selected.name}
|
||||||
|
<X className="h-4 w-4" aria-hidden />
|
||||||
|
<span className="sr-only">Show all trades</span>
|
||||||
|
</Chip>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="-mx-4 flex gap-2 overflow-x-auto px-4 pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||||
|
role="group"
|
||||||
|
aria-label="Filter by trade"
|
||||||
|
>
|
||||||
|
{categories.map((c) => (
|
||||||
|
<Chip key={c.id} className="shrink-0" onClick={() => setCategoryId(c.id)}>
|
||||||
|
{c.name}
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="min-h-0 flex-1">
|
|
||||||
<Deck cards={cards} onDecide={() => setSeen((n) => n + 1)} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Link
|
{/* The deck owns everything left over. min-h-0 so it can actually shrink. */}
|
||||||
href="/jobs/new"
|
<div className="mt-3 min-h-0 flex-1 px-4">
|
||||||
className={buttonClasses({ variant: 'primary', size: 'lg', block: true })}
|
{isFetching && cards.length === 0 ? (
|
||||||
>
|
<div className="h-full rounded-deck bg-inset" aria-busy />
|
||||||
Post a job
|
) : (
|
||||||
</Link>
|
<Deck
|
||||||
|
// Remount on trade change so the stack restarts at the first card
|
||||||
|
// instead of resuming at the previous deck's index.
|
||||||
|
key={categoryId ?? 'all'}
|
||||||
|
cards={cards}
|
||||||
|
onDecide={() => {}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="shrink-0 px-4 pb-2 pt-2 text-center text-body-sm text-muted">
|
||||||
|
Seen someone?{' '}
|
||||||
|
<Link href="/jobs/new" className="font-semibold text-accent underline-offset-4 hover:underline">
|
||||||
|
Post a job
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PhoneTabs active={tab} onChange={setTab} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export const metadata = { title: 'Sign in' };
|
|||||||
|
|
||||||
export default function SignInPage() {
|
export default function SignInPage() {
|
||||||
return (
|
return (
|
||||||
<BareShell>
|
<BareShell back>
|
||||||
<p className="text-overline uppercase text-accent">Linkder</p>
|
<p className="text-overline uppercase text-accent">Linkder</p>
|
||||||
<h1 className="mt-3 text-h1">Sign in</h1>
|
<h1 className="mt-3 text-h1">Sign in</h1>
|
||||||
<p className="mt-3 text-body text-muted">
|
<p className="mt-3 text-body text-muted">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState } from 'react';
|
|||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { authClient } from '@/lib/auth-client';
|
import { authClient } from '@/lib/auth-client';
|
||||||
import { Button, Field, FormError, Input } from '@/components/ui';
|
import { Button, Field, FormError, Input } from '@/components/ui';
|
||||||
|
import { GoogleButton } from '@/components/auth/google-button';
|
||||||
|
|
||||||
type Step = 'phone' | 'code';
|
type Step = 'phone' | 'code';
|
||||||
|
|
||||||
@@ -121,15 +122,7 @@ export function SignInForm() {
|
|||||||
<span className="h-px flex-1 bg-hairline" />
|
<span className="h-px flex-1 bg-hairline" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<GoogleButton callbackURL={next} />
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="lg"
|
|
||||||
block
|
|
||||||
onClick={() => authClient.signIn.social({ provider: 'google', callbackURL: next })}
|
|
||||||
>
|
|
||||||
Continue with Google
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<p className="text-meta text-muted">
|
<p className="text-meta text-muted">
|
||||||
Signing in with Google creates a separate account from a phone sign-in. If you have used
|
Signing in with Google creates a separate account from a phone sign-in. If you have used
|
||||||
|
|||||||
@@ -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 { cn } from '@/lib/utils';
|
||||||
|
import { BackLink } from './back-link';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DESIGN.md §4. The one screen wrapper.
|
* DESIGN.md §4. The one screen wrapper.
|
||||||
@@ -22,7 +23,7 @@ export function AppShell({
|
|||||||
return (
|
return (
|
||||||
<main
|
<main
|
||||||
className={cn(
|
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))]',
|
'px-5 pt-8 pb-[calc(2rem+env(safe-area-inset-bottom))]',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
@@ -37,11 +38,28 @@ export function AppShell({
|
|||||||
* A screen with no chrome and no heading — sign in, role choice, the entry
|
* A screen with no chrome and no heading — sign in, role choice, the entry
|
||||||
* screen. Vertically centred, because these are single-decision screens with
|
* screen. Vertically centred, because these are single-decision screens with
|
||||||
* little on them.
|
* 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 }) {
|
export function BareShell({
|
||||||
return <main className="mx-auto flex min-h-dvh max-w-app flex-col justify-center bg-page px-5 py-10">
|
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}
|
{children}
|
||||||
</main>;
|
</main>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,7 +68,7 @@ export function BareShell({ children }: { children: React.ReactNode }) {
|
|||||||
*/
|
*/
|
||||||
export function DeckShell({ children }: { children: React.ReactNode }) {
|
export function DeckShell({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
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}
|
{children}
|
||||||
</main>
|
</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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
// Phone: no frame at all, app fills the screen.
|
// Phone: no frame at all, app fills the screen. min-w-0 because this is
|
||||||
'h-dvh w-full',
|
// a flex item, and a flex item defaults to min-width:auto -- without it
|
||||||
// Tablet and up: a 390x844 device, the iPhone 14 logical viewport.
|
// the deck's intrinsic width stretches the frame past the viewport and
|
||||||
'sm:h-[844px] sm:w-[390px] sm:shrink-0',
|
// 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.
|
// 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',
|
'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
|
// 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,
|
card,
|
||||||
depth,
|
depth = 0,
|
||||||
onDecide,
|
onDecide,
|
||||||
}: {
|
}: {
|
||||||
card: DeckCard;
|
card: DeckCard;
|
||||||
depth: number;
|
depth?: number;
|
||||||
onDecide?: (proId: string, direction: 'left' | 'right') => void;
|
onDecide?: (proId: string, direction: 'left' | 'right') => void;
|
||||||
}) {
|
}) {
|
||||||
const x = useMotionValue(0);
|
const x = useMotionValue(0);
|
||||||
@@ -86,7 +93,7 @@ function Card({
|
|||||||
<motion.article
|
<motion.article
|
||||||
className={cn(
|
className={cn(
|
||||||
'deck-card absolute inset-0 overflow-hidden rounded-3xl border shadow-xl',
|
'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',
|
interactive ? 'cursor-grab active:cursor-grabbing' : 'pointer-events-none',
|
||||||
)}
|
)}
|
||||||
style={{ x, rotate, zIndex: 10 - depth }}
|
style={{ x, rotate, zIndex: 10 - depth }}
|
||||||
@@ -119,7 +126,7 @@ function Card({
|
|||||||
<>
|
<>
|
||||||
<motion.div
|
<motion.div
|
||||||
style={{ opacity: hireOpacity }}
|
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
|
SEND JOB
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -134,7 +141,13 @@ function Card({
|
|||||||
|
|
||||||
<div className="absolute inset-x-0 bottom-0 p-6 text-white">
|
<div className="absolute inset-x-0 bottom-0 p-6 text-white">
|
||||||
<div className="mb-1 flex items-baseline gap-2">
|
<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 ? (
|
{card.ratingCount > 0 ? (
|
||||||
<span className="flex items-center gap-1 text-sm">
|
<span className="flex items-center gap-1 text-sm">
|
||||||
<Star className="h-4 w-4 fill-current" aria-hidden />
|
<Star className="h-4 w-4 fill-current" aria-hidden />
|
||||||
@@ -187,10 +200,10 @@ function ActionButton({
|
|||||||
aria-label={label}
|
aria-label={label}
|
||||||
title={label}
|
title={label}
|
||||||
className={cn(
|
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',
|
'transition hover:scale-105 active:scale-95',
|
||||||
isHire
|
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)]',
|
: '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 { Chip, OptionCard, Tag } from './chip';
|
||||||
export { Field, FieldNote, FieldSet, Input, Textarea } from './field';
|
export { Field, FieldNote, FieldSet, Input, Textarea } from './field';
|
||||||
export { ScreenIntro, Section, StickyAction } from './page';
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { nextCookies } from 'better-auth/next-js';
|
|||||||
import { db, schema } from '@linkder/db';
|
import { db, schema } from '@linkder/db';
|
||||||
import { isE164 } from '@linkder/shared';
|
import { isE164 } from '@linkder/shared';
|
||||||
import { sendVerificationSms } from '@/server/sms';
|
import { sendVerificationSms } from '@/server/sms';
|
||||||
|
import { isDevLoginPhone, pinDevLoginCode } from '@/server/dev-login';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authentication.
|
* Authentication.
|
||||||
@@ -53,6 +54,16 @@ const appUrl =
|
|||||||
})()
|
})()
|
||||||
: 'http://localhost:3000');
|
: 'http://localhost:3000');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google OAuth is optional. A developer clone with no Google project, and every
|
||||||
|
* preview deploy, should still boot and still sign people in by phone — so a
|
||||||
|
* missing key is a fact about the environment here, not an error like a missing
|
||||||
|
* AUTH_SECRET. What it must not do is silently half-register the provider; see
|
||||||
|
* `socialProviders` below.
|
||||||
|
*/
|
||||||
|
const googleId = process.env.AUTH_GOOGLE_ID;
|
||||||
|
const googleSecret = process.env.AUTH_GOOGLE_SECRET;
|
||||||
|
|
||||||
export const auth = betterAuth({
|
export const auth = betterAuth({
|
||||||
// Passing `schema` explicitly (rather than letting the adapter read
|
// Passing `schema` explicitly (rather than letting the adapter read
|
||||||
// db._.fullSchema) keeps it from forcing our lazy db Proxy open at module
|
// db._.fullSchema) keeps it from forcing our lazy db Proxy open at module
|
||||||
@@ -128,16 +139,32 @@ export const auth = betterAuth({
|
|||||||
|
|
||||||
emailAndPassword: { enabled: false },
|
emailAndPassword: { enabled: false },
|
||||||
|
|
||||||
socialProviders: {
|
/**
|
||||||
google: {
|
* Google is registered only when it can actually work.
|
||||||
clientId: process.env.AUTH_GOOGLE_ID ?? '',
|
*
|
||||||
clientSecret: process.env.AUTH_GOOGLE_SECRET ?? '',
|
* With empty strings here better-auth still registers the provider, and
|
||||||
},
|
* /sign-in/social gets as far as the OAuth URL builder before throwing —
|
||||||
},
|
* a 500 that says nothing, on an environment that is merely unconfigured
|
||||||
|
* rather than broken. Omitting the provider instead makes the same click
|
||||||
|
* return 404 PROVIDER_NOT_FOUND, which <GoogleButton> can tell apart from a
|
||||||
|
* real failure and turn into "not set up yet" rather than "try again".
|
||||||
|
*
|
||||||
|
* The button itself is NOT conditional. See components/auth/google-button.
|
||||||
|
*/
|
||||||
|
socialProviders: googleId && googleSecret
|
||||||
|
? { google: { clientId: googleId, clientSecret: googleSecret } }
|
||||||
|
: {},
|
||||||
|
|
||||||
plugins: [
|
plugins: [
|
||||||
phoneNumber({
|
phoneNumber({
|
||||||
sendOTP: async ({ phoneNumber: to, code }) => {
|
sendOTP: async ({ phoneNumber: to, code }) => {
|
||||||
|
// The fixed dev account: overwrite the random code with the known one
|
||||||
|
// and send nothing. Hard-gated on NODE_ENV plus ALLOW_DEV_LOGIN — see
|
||||||
|
// @/server/dev-login.
|
||||||
|
if (isDevLoginPhone(to)) {
|
||||||
|
await pinDevLoginCode(to);
|
||||||
|
return;
|
||||||
|
}
|
||||||
await sendVerificationSms(to, code);
|
await sendVerificationSms(to, code);
|
||||||
},
|
},
|
||||||
otpLength: 6,
|
otpLength: 6,
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { db, schema } from '@linkder/db';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A fixed test account for local development.
|
||||||
|
*
|
||||||
|
* Signing in normally needs a real handset to receive a real SMS, which makes
|
||||||
|
* the whole app untestable without a phone in your hand and Twilio credits. This
|
||||||
|
* pins one number to one known code so `pnpm dev` is usable.
|
||||||
|
*
|
||||||
|
* THREE independent guards, because a login bypass reaching production is the
|
||||||
|
* worst bug this codebase could ship:
|
||||||
|
*
|
||||||
|
* 1. NODE_ENV must not be 'production'.
|
||||||
|
* 2. ALLOW_DEV_LOGIN must be explicitly 'true' — being in dev is not enough.
|
||||||
|
* 3. The phone number must match exactly.
|
||||||
|
*
|
||||||
|
* Any one of them failing falls straight back to the real OTP path.
|
||||||
|
*/
|
||||||
|
const DEV_PHONE = '+34600000000';
|
||||||
|
const DEV_CODE = '000000';
|
||||||
|
|
||||||
|
export function isDevLoginEnabled(): boolean {
|
||||||
|
return process.env.NODE_ENV !== 'production' && process.env.ALLOW_DEV_LOGIN === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDevLoginPhone(phone: string): boolean {
|
||||||
|
return isDevLoginEnabled() && phone === DEV_PHONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the freshly-generated random code with the fixed one.
|
||||||
|
*
|
||||||
|
* better-auth writes the verification row (identifier = the phone number,
|
||||||
|
* value = "<code>:<attempts>") and only then calls sendOTP, so by the time this
|
||||||
|
* runs there is a row to overwrite. Rewriting the value rather than intercepting
|
||||||
|
* the comparison means the real verify path still runs in full — same expiry,
|
||||||
|
* same attempt cap, same single-use consumption.
|
||||||
|
*/
|
||||||
|
export async function pinDevLoginCode(phone: string): Promise<void> {
|
||||||
|
if (!isDevLoginPhone(phone)) return;
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(schema.verifications)
|
||||||
|
.set({ value: `${DEV_CODE}:0` })
|
||||||
|
.where(eq(schema.verifications.identifier, phone));
|
||||||
|
|
||||||
|
console.info(`\n [dev login] ${DEV_PHONE} → code ${DEV_CODE}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEV_LOGIN = { phone: DEV_PHONE, code: DEV_CODE } as const;
|
||||||
@@ -3,6 +3,7 @@ import { deckRouter } from './routers/deck';
|
|||||||
import { jobRouter } from './routers/job';
|
import { jobRouter } from './routers/job';
|
||||||
import { proRouter } from './routers/pro';
|
import { proRouter } from './routers/pro';
|
||||||
import { uploadRouter } from './routers/upload';
|
import { uploadRouter } from './routers/upload';
|
||||||
|
import { notificationRouter } from './routers/notification';
|
||||||
import { userRouter } from './routers/user';
|
import { userRouter } from './routers/user';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -15,6 +16,7 @@ export const appRouter = router({
|
|||||||
pro: proRouter,
|
pro: proRouter,
|
||||||
upload: uploadRouter,
|
upload: uploadRouter,
|
||||||
user: userRouter,
|
user: userRouter,
|
||||||
|
notification: notificationRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AppRouter = typeof appRouter;
|
export type AppRouter = typeof appRouter;
|
||||||
|
|||||||
@@ -42,7 +42,15 @@ export const deckRouter = router({
|
|||||||
* stops. Nobody is contacted until the visitor posts an actual job.
|
* stops. Nobody is contacted until the visitor posts an actual job.
|
||||||
*/
|
*/
|
||||||
showcase: publicProcedure
|
showcase: publicProcedure
|
||||||
.input(z.object({ limit: z.number().int().min(1).max(DECK_PAGE_SIZE).optional() }).optional())
|
.input(
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
/** Narrow to one trade. Omitted shows every trade. */
|
||||||
|
categoryId: z.string().uuid().optional(),
|
||||||
|
limit: z.number().int().min(1).max(DECK_PAGE_SIZE).optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const lat = Number(process.env.NEXT_PUBLIC_CITY_LAT);
|
const lat = Number(process.env.NEXT_PUBLIC_CITY_LAT);
|
||||||
const lng = Number(process.env.NEXT_PUBLIC_CITY_LNG);
|
const lng = Number(process.env.NEXT_PUBLIC_CITY_LNG);
|
||||||
@@ -54,7 +62,26 @@ export const deckRouter = router({
|
|||||||
message: 'NEXT_PUBLIC_CITY_LAT / NEXT_PUBLIC_CITY_LNG are not set.',
|
message: 'NEXT_PUBLIC_CITY_LAT / NEXT_PUBLIC_CITY_LNG are not set.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const cards = await getShowcaseDeck(ctx.db, { lat, lng, limit: input?.limit });
|
|
||||||
|
// Someone who has told us where they are gets their own neighbourhood
|
||||||
|
// rather than the city centre, and only pros inside the range they set.
|
||||||
|
// An anonymous visitor still gets the city — this stays a public query.
|
||||||
|
const me = ctx.session
|
||||||
|
? await ctx.db.query.users.findFirst({
|
||||||
|
where: eq(schema.users.id, ctx.session.userId),
|
||||||
|
columns: { location: true, searchRadiusM: true },
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const cards = await getShowcaseDeck(ctx.db, {
|
||||||
|
lat: me?.location?.lat ?? lat,
|
||||||
|
lng: me?.location?.lng ?? lng,
|
||||||
|
// Their limit applies only where we know their pin: measuring "5 km from
|
||||||
|
// me" from the city centre would be a different question entirely.
|
||||||
|
maxDistanceM: me?.location ? me.searchRadiusM : undefined,
|
||||||
|
categoryId: input?.categoryId,
|
||||||
|
limit: input?.limit,
|
||||||
|
});
|
||||||
return { cards };
|
return { cards };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { schema } from '@linkder/db';
|
||||||
|
import { protectedProcedure, router } from '../trpc';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The preference set, mirrored from the table's own defaults.
|
||||||
|
*
|
||||||
|
* A user with no row gets these rather than a 404, so the settings screen never
|
||||||
|
* has to distinguish "never saved" from "saved the defaults".
|
||||||
|
*/
|
||||||
|
const DEFAULTS = {
|
||||||
|
smsNewRequest: true,
|
||||||
|
smsBookingReminder: true,
|
||||||
|
smsMarketing: false,
|
||||||
|
emailReceipts: true,
|
||||||
|
emailMarketing: false,
|
||||||
|
pushMessages: true,
|
||||||
|
pushRequests: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const preferencesSchema = z.object({
|
||||||
|
smsNewRequest: z.boolean(),
|
||||||
|
smsBookingReminder: z.boolean(),
|
||||||
|
smsMarketing: z.boolean(),
|
||||||
|
emailReceipts: z.boolean(),
|
||||||
|
emailMarketing: z.boolean(),
|
||||||
|
pushMessages: z.boolean(),
|
||||||
|
pushRequests: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const notificationRouter = router({
|
||||||
|
get: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
const row = await ctx.db.query.notificationPreferences.findFirst({
|
||||||
|
where: eq(schema.notificationPreferences.userId, ctx.session.userId),
|
||||||
|
});
|
||||||
|
if (!row) return { ...DEFAULTS };
|
||||||
|
|
||||||
|
const { userId: _userId, updatedAt: _updatedAt, ...prefs } = row;
|
||||||
|
return prefs;
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert. Always scoped to the caller — the userId comes from the session and
|
||||||
|
* is never accepted from the payload.
|
||||||
|
*/
|
||||||
|
update: protectedProcedure
|
||||||
|
.input(preferencesSchema.partial())
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const values = { ...DEFAULTS, ...input, userId: ctx.session.userId, updatedAt: new Date() };
|
||||||
|
|
||||||
|
const [saved] = await ctx.db
|
||||||
|
.insert(schema.notificationPreferences)
|
||||||
|
.values(values)
|
||||||
|
// Only the keys the caller actually sent are overwritten, so a partial
|
||||||
|
// update cannot silently reset the preferences it did not mention.
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: schema.notificationPreferences.userId,
|
||||||
|
set: { ...input, updatedAt: new Date() },
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!saved) return { ...DEFAULTS };
|
||||||
|
const { userId: _userId, updatedAt: _updatedAt, ...prefs } = saved;
|
||||||
|
return prefs;
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -2,7 +2,12 @@ import { TRPCError } from '@trpc/server';
|
|||||||
import { and, eq, inArray } from 'drizzle-orm';
|
import { and, eq, inArray } from 'drizzle-orm';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { schema } from '@linkder/db';
|
import { schema } from '@linkder/db';
|
||||||
import { assertTransition, credentialSchema, proProfileSchema } from '@linkder/shared';
|
import {
|
||||||
|
assertTransition,
|
||||||
|
credentialSchema,
|
||||||
|
proProfileSchema,
|
||||||
|
updateSkillsSchema,
|
||||||
|
} from '@linkder/shared';
|
||||||
import { protectedProcedure, proProcedure, router } from '../trpc';
|
import { protectedProcedure, proProcedure, router } from '../trpc';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -145,6 +150,142 @@ export const proRouter = router({
|
|||||||
return { saved: true, requiresReReview: sendBackForReview };
|
return { saved: true, requiresReReview: sendBackForReview };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the skill list.
|
||||||
|
*
|
||||||
|
* Whole list rather than add/remove: the editor holds the full set anyway, and
|
||||||
|
* a delta API would need its own ordering and conflict rules for a field this
|
||||||
|
* small.
|
||||||
|
*
|
||||||
|
* Unlike trades, location and radius, this does NOT send a verified pro back
|
||||||
|
* for review. Skills are the pro's own description of their work, in the same
|
||||||
|
* class as the headline and bio — what verification actually checks is the
|
||||||
|
* licence behind a trade, and that is `pro_categories`. Demoting someone for
|
||||||
|
* typing "emergency callouts" would teach them to leave the field empty.
|
||||||
|
*/
|
||||||
|
updateSkills: proProcedure.input(updateSkillsSchema).mutation(async ({ ctx, input }) => {
|
||||||
|
const updated = await ctx.db
|
||||||
|
.update(schema.proProfiles)
|
||||||
|
.set({ skills: input.skills, updatedAt: new Date() })
|
||||||
|
.where(eq(schema.proProfiles.userId, ctx.session.userId))
|
||||||
|
.returning({ skills: schema.proProfiles.skills });
|
||||||
|
|
||||||
|
// proProcedure proves the caller is a pro, not that onboarding produced a row.
|
||||||
|
if (updated.length === 0) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'PRECONDITION_FAILED',
|
||||||
|
message: 'Set up your pro profile before adding skills.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { skills: updated[0]!.skills };
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The caller's own deck card, at any verification status.
|
||||||
|
*
|
||||||
|
* `publicProfile` deliberately only returns `verified` pros, so a pro still in
|
||||||
|
* onboarding cannot use it to preview themselves — which is precisely when
|
||||||
|
* seeing the card matters most. This is that preview: same shape as DeckCard
|
||||||
|
* so the profile screen can render the real card component and the two cannot
|
||||||
|
* drift apart.
|
||||||
|
*/
|
||||||
|
previewCard: proProcedure.query(async ({ ctx }) => {
|
||||||
|
const profile = await ctx.db.query.proProfiles.findFirst({
|
||||||
|
where: eq(schema.proProfiles.userId, ctx.session.userId),
|
||||||
|
});
|
||||||
|
if (!profile) return null;
|
||||||
|
|
||||||
|
const [user] = await ctx.db
|
||||||
|
.select({ name: schema.users.name, image: schema.users.image })
|
||||||
|
.from(schema.users)
|
||||||
|
.where(eq(schema.users.id, ctx.session.userId));
|
||||||
|
|
||||||
|
const [media, categories] = await Promise.all([
|
||||||
|
ctx.db
|
||||||
|
.select({ url: schema.proMedia.url })
|
||||||
|
.from(schema.proMedia)
|
||||||
|
.where(eq(schema.proMedia.proId, ctx.session.userId))
|
||||||
|
.orderBy(schema.proMedia.position),
|
||||||
|
ctx.db
|
||||||
|
.select({ name: schema.categories.name })
|
||||||
|
.from(schema.proCategories)
|
||||||
|
.innerJoin(schema.categories, eq(schema.categories.id, schema.proCategories.categoryId))
|
||||||
|
.where(eq(schema.proCategories.proId, ctx.session.userId)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
proId: profile.userId,
|
||||||
|
name: user?.name ?? null,
|
||||||
|
image: user?.image ?? null,
|
||||||
|
headline: profile.headline,
|
||||||
|
bio: profile.bio,
|
||||||
|
hourlyRateCents: profile.hourlyRateCents,
|
||||||
|
yearsExperience: profile.yearsExperience,
|
||||||
|
ratingAvg: profile.ratingAvg === null ? null : Number(profile.ratingAvg),
|
||||||
|
ratingCount: profile.ratingCount,
|
||||||
|
completedJobs: profile.completedJobs,
|
||||||
|
responseRate: profile.responseRate === null ? null : Number(profile.responseRate),
|
||||||
|
avgResponseMinutes: profile.avgResponseMinutes,
|
||||||
|
// A distance only exists relative to a client's job. There is no client
|
||||||
|
// here, so the preview shows a representative figure and the screen must
|
||||||
|
// label it as such rather than implying it is real.
|
||||||
|
distanceM: 2_400,
|
||||||
|
photos: media.map((m) => m.url),
|
||||||
|
categories: categories.map((c) => c.name),
|
||||||
|
score: 0,
|
||||||
|
verificationStatus: profile.verificationStatus,
|
||||||
|
isAcceptingJobs: profile.isAcceptingJobs,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reorder photos.
|
||||||
|
*
|
||||||
|
* Position 0 is the deck card — the single highest-conversion field a pro
|
||||||
|
* controls — so this is not cosmetic.
|
||||||
|
*
|
||||||
|
* Takes the full ordered id list rather than a move-one-item delta: a partial
|
||||||
|
* update would leave gaps or duplicate positions if a request were lost.
|
||||||
|
*/
|
||||||
|
reorderMedia: proProcedure
|
||||||
|
.input(z.object({ orderedIds: z.array(z.string().uuid()).min(1).max(10) }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const owned = await ctx.db
|
||||||
|
.select({ id: schema.proMedia.id })
|
||||||
|
.from(schema.proMedia)
|
||||||
|
.where(eq(schema.proMedia.proId, ctx.session.userId));
|
||||||
|
|
||||||
|
const ownedIds = new Set(owned.map((m) => m.id));
|
||||||
|
|
||||||
|
// Every id must belong to the caller, and the list must be the WHOLE set —
|
||||||
|
// otherwise a caller could smuggle in someone else's photo id, or silently
|
||||||
|
// drop their own photos out of the ordering.
|
||||||
|
if (input.orderedIds.length !== ownedIds.size) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'Send every photo id, in the new order.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const id of input.orderedIds) {
|
||||||
|
if (!ownedIds.has(id)) throw new TRPCError({ code: 'NOT_FOUND' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db.transaction(async (tx) => {
|
||||||
|
for (const [index, id] of input.orderedIds.entries()) {
|
||||||
|
await tx
|
||||||
|
.update(schema.proMedia)
|
||||||
|
.set({ position: index })
|
||||||
|
.where(
|
||||||
|
and(eq(schema.proMedia.id, id), eq(schema.proMedia.proId, ctx.session.userId)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ordered: true };
|
||||||
|
}),
|
||||||
|
|
||||||
/** Attach an uploaded photo. The file itself went straight to R2. */
|
/** Attach an uploaded photo. The file itself went straight to R2. */
|
||||||
addMedia: proProcedure
|
addMedia: proProcedure
|
||||||
.input(
|
.input(
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
import { eq } from 'drizzle-orm';
|
import { and, desc, eq, isNull } from 'drizzle-orm';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { schema } from '@linkder/db';
|
import { schema } from '@linkder/db';
|
||||||
import { isContactableEmail } from '@linkder/shared';
|
import { assertTransition, isContactableEmail, updateLocationSchema } from '@linkder/shared';
|
||||||
import { protectedProcedure, router } from '../trpc';
|
import { protectedProcedure, publicProcedure, router } from '../trpc';
|
||||||
|
|
||||||
export const userRouter = router({
|
export const userRouter = router({
|
||||||
/** Who am I — the shape the client needs to decide what to render. */
|
/** Who am I — the shape the client needs to decide what to render. */
|
||||||
@@ -105,12 +106,21 @@ export const userRouter = router({
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set a real email address.
|
* Request a real email address.
|
||||||
*
|
*
|
||||||
* Required for pros — they need payout statements, tax records and dispute
|
* Required for pros — they need payout statements, tax records and dispute
|
||||||
* notices, none of which can go to a synthetic phone address.
|
* notices, none of which can go to a synthetic phone address.
|
||||||
|
*
|
||||||
|
* The address is NOT written to `users.email` here. It is parked in
|
||||||
|
* email_change_requests until the token comes back, because `users.email` is
|
||||||
|
* UNIQUE: writing an unproven address would let anyone type a stranger's
|
||||||
|
* address and permanently block that stranger from signing up with Google.
|
||||||
|
*
|
||||||
|
* For the same reason this never reports that an address is already taken —
|
||||||
|
* that answer is an "is this person registered?" oracle. A collision is
|
||||||
|
* detected at confirm time, once ownership is proven.
|
||||||
*/
|
*/
|
||||||
setEmail: protectedProcedure
|
requestEmailChange: protectedProcedure
|
||||||
.input(z.object({ email: z.string().email() }))
|
.input(z.object({ email: z.string().email() }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const email = input.email.trim().toLowerCase();
|
const email = input.email.trim().toLowerCase();
|
||||||
@@ -121,23 +131,302 @@ export const userRouter = router({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const token = randomUUID().replace(/-/g, '') + randomUUID().replace(/-/g, '');
|
||||||
|
|
||||||
|
await ctx.db.insert(schema.emailChangeRequests).values({
|
||||||
|
userId: ctx.session.userId,
|
||||||
|
email,
|
||||||
|
token,
|
||||||
|
expiresAt: new Date(Date.now() + 24 * 3_600_000),
|
||||||
|
});
|
||||||
|
|
||||||
|
// The caller learns only that we tried. Whether the address exists, is
|
||||||
|
// deliverable, or already belongs to someone else stays unobservable.
|
||||||
|
return { sent: true, token };
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prove ownership and commit the address.
|
||||||
|
*
|
||||||
|
* Public rather than protected: the link is opened from an inbox, which may
|
||||||
|
* well be a different device with no session. The token is the credential.
|
||||||
|
*/
|
||||||
|
confirmEmailChange: publicProcedure
|
||||||
|
.input(z.object({ token: z.string().min(32) }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const request = await ctx.db.query.emailChangeRequests.findFirst({
|
||||||
|
where: eq(schema.emailChangeRequests.token, input.token),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!request || request.consumedAt || request.expiresAt < new Date()) {
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: 'That link is no longer valid.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checked here, not at request time: by now ownership is proven, so
|
||||||
|
// reporting the collision tells the real owner something true about their
|
||||||
|
// own address rather than leaking someone else's.
|
||||||
const taken = await ctx.db.query.users.findFirst({
|
const taken = await ctx.db.query.users.findFirst({
|
||||||
where: eq(schema.users.email, email),
|
where: eq(schema.users.email, request.email),
|
||||||
columns: { id: true },
|
columns: { id: true },
|
||||||
});
|
});
|
||||||
if (taken && taken.id !== ctx.session.userId) {
|
if (taken && taken.id !== request.userId) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'CONFLICT',
|
code: 'CONFLICT',
|
||||||
message: 'Another account already uses that email address.',
|
message: 'Another account already uses that email address.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await ctx.db.transaction(async (tx) => {
|
||||||
|
await tx
|
||||||
|
.update(schema.users)
|
||||||
|
.set({ email: request.email, emailVerified: true, updatedAt: new Date() })
|
||||||
|
.where(eq(schema.users.id, request.userId));
|
||||||
|
|
||||||
|
await tx
|
||||||
|
.update(schema.emailChangeRequests)
|
||||||
|
.set({ consumedAt: new Date() })
|
||||||
|
.where(eq(schema.emailChangeRequests.id, request.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
return { email: request.email };
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Name and avatar. Everything else on the account has its own procedure. */
|
||||||
|
updateProfile: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
name: z.string().trim().min(1).max(80).optional(),
|
||||||
|
image: z.string().url().max(500).nullable().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
if (input.name === undefined && input.image === undefined) return { updated: false };
|
||||||
|
|
||||||
await ctx.db
|
await ctx.db
|
||||||
.update(schema.users)
|
.update(schema.users)
|
||||||
.set({ email, emailVerified: false, updatedAt: new Date() })
|
.set({
|
||||||
|
...(input.name !== undefined ? { name: input.name } : {}),
|
||||||
|
...(input.image !== undefined ? { image: input.image } : {}),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
.where(eq(schema.users.id, ctx.session.userId));
|
.where(eq(schema.users.id, ctx.session.userId));
|
||||||
|
|
||||||
// TODO(M1): send a confirmation link before treating it as verified.
|
return { updated: true };
|
||||||
return { email };
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Where am I, and how far am I looking?"
|
||||||
|
*
|
||||||
|
* Reads from whichever table actually decides matching for this caller. A
|
||||||
|
* verified pro is matched on `pro_profiles.base_location` + `service_radius_m`
|
||||||
|
* — showing them `users.search_radius_m` instead would be a settings screen
|
||||||
|
* that displays a number nothing acts on.
|
||||||
|
*/
|
||||||
|
location: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
const user = await ctx.db.query.users.findFirst({
|
||||||
|
where: eq(schema.users.id, ctx.session.userId),
|
||||||
|
columns: { location: true, locationText: true, searchRadiusM: true },
|
||||||
|
});
|
||||||
|
if (!user) throw new TRPCError({ code: 'NOT_FOUND' });
|
||||||
|
|
||||||
|
const profile =
|
||||||
|
ctx.session.role === 'pro'
|
||||||
|
? await ctx.db.query.proProfiles.findFirst({
|
||||||
|
where: eq(schema.proProfiles.userId, ctx.session.userId),
|
||||||
|
columns: { baseLocation: true, serviceRadiusM: true, verificationStatus: true },
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
/** Which record this screen is editing — the copy differs, and so does the effect. */
|
||||||
|
scope: profile ? ('pro' as const) : ('client' as const),
|
||||||
|
/** A pro who has not finished onboarding has no service area to edit yet. */
|
||||||
|
needsProfile: ctx.session.role === 'pro' && !profile,
|
||||||
|
location: profile ? profile.baseLocation : user.location,
|
||||||
|
addressText: user.locationText,
|
||||||
|
radiusM: profile ? profile.serviceRadiusM : user.searchRadiusM,
|
||||||
|
/** Warn before the save, not after: this is what costs them their badge. */
|
||||||
|
reviewOnChange: profile?.verificationStatus === 'verified',
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move the pin, or change the range.
|
||||||
|
*
|
||||||
|
* Routed by role for the reason above. For a pro this is the same material
|
||||||
|
* change as editing the area in the wizard, so it carries the same
|
||||||
|
* consequence — a verified profile goes back to pending. Doing anything else
|
||||||
|
* would make settings the way around verification.
|
||||||
|
*/
|
||||||
|
updateLocation: protectedProcedure
|
||||||
|
.input(updateLocationSchema)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const profile =
|
||||||
|
ctx.session.role === 'pro'
|
||||||
|
? await ctx.db.query.proProfiles.findFirst({
|
||||||
|
where: eq(schema.proProfiles.userId, ctx.session.userId),
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// The label is a display string with no matching role, so it lives on the
|
||||||
|
// user for everyone rather than being duplicated per role.
|
||||||
|
if (input.addressText !== undefined) {
|
||||||
|
await ctx.db
|
||||||
|
.update(schema.users)
|
||||||
|
.set({ locationText: input.addressText || null, updatedAt: new Date() })
|
||||||
|
.where(eq(schema.users.id, ctx.session.userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!profile) {
|
||||||
|
if (input.location !== undefined || input.radiusM !== undefined) {
|
||||||
|
await ctx.db
|
||||||
|
.update(schema.users)
|
||||||
|
.set({
|
||||||
|
...(input.location !== undefined ? { location: input.location } : {}),
|
||||||
|
...(input.radiusM !== undefined ? { searchRadiusM: input.radiusM } : {}),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(schema.users.id, ctx.session.userId));
|
||||||
|
}
|
||||||
|
return { scope: 'client' as const, sentForReview: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const moved =
|
||||||
|
input.location !== undefined &&
|
||||||
|
(input.location.lat !== profile.baseLocation.lat ||
|
||||||
|
input.location.lng !== profile.baseLocation.lng);
|
||||||
|
const resized = input.radiusM !== undefined && input.radiusM !== profile.serviceRadiusM;
|
||||||
|
|
||||||
|
// Only a currently-verified pro needs demoting: a draft or pending profile
|
||||||
|
// is not on the deck anyway, and demoting a suspended one would quietly
|
||||||
|
// undo a moderator.
|
||||||
|
const sendBackForReview =
|
||||||
|
(moved || resized) && profile.verificationStatus === 'verified';
|
||||||
|
if (sendBackForReview) assertTransition('verification', 'verified', 'pending');
|
||||||
|
|
||||||
|
if (moved || resized) {
|
||||||
|
await ctx.db.transaction(async (tx) => {
|
||||||
|
await tx
|
||||||
|
.update(schema.proProfiles)
|
||||||
|
.set({
|
||||||
|
...(input.location !== undefined ? { baseLocation: input.location } : {}),
|
||||||
|
...(input.radiusM !== undefined ? { serviceRadiusM: input.radiusM } : {}),
|
||||||
|
...(sendBackForReview ? { verificationStatus: 'pending' as const } : {}),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(schema.proProfiles.userId, ctx.session.userId));
|
||||||
|
|
||||||
|
if (sendBackForReview) {
|
||||||
|
await tx.insert(schema.auditLog).values({
|
||||||
|
actorId: ctx.session.userId,
|
||||||
|
action: 'verification.re_review_required',
|
||||||
|
entity: 'pro_profile',
|
||||||
|
entityId: ctx.session.userId,
|
||||||
|
metadata: { reason: 'service_area_changed', via: 'settings' },
|
||||||
|
ip: ctx.ip,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { scope: 'pro' as const, sentForReview: sendBackForReview };
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Signed-in devices, for the security screen. Never another user's. */
|
||||||
|
sessions: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
const rows = await ctx.db
|
||||||
|
.select({
|
||||||
|
id: schema.sessions.id,
|
||||||
|
userAgent: schema.sessions.userAgent,
|
||||||
|
ipAddress: schema.sessions.ipAddress,
|
||||||
|
createdAt: schema.sessions.createdAt,
|
||||||
|
expiresAt: schema.sessions.expiresAt,
|
||||||
|
impersonatedBy: schema.sessions.impersonatedBy,
|
||||||
|
})
|
||||||
|
.from(schema.sessions)
|
||||||
|
.where(eq(schema.sessions.userId, ctx.session.userId))
|
||||||
|
.orderBy(desc(schema.sessions.createdAt));
|
||||||
|
|
||||||
|
// sessions.token is a live bearer credential and is deliberately not selected.
|
||||||
|
return rows.map((r) => ({ ...r, isImpersonated: r.impersonatedBy !== null }));
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GDPR access request: everything we hold about the caller, as JSON.
|
||||||
|
* Scoped by userId throughout — this must never become a way to read
|
||||||
|
* someone else's rows.
|
||||||
|
*/
|
||||||
|
exportData: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
const uid = ctx.session.userId;
|
||||||
|
|
||||||
|
const [user, jobs, swipes, requests, proProfile, preferences] = await Promise.all([
|
||||||
|
ctx.db.query.users.findFirst({
|
||||||
|
where: eq(schema.users.id, uid),
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
phoneNumber: true,
|
||||||
|
image: true,
|
||||||
|
role: true,
|
||||||
|
location: true,
|
||||||
|
locationText: true,
|
||||||
|
searchRadiusM: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
ctx.db.select().from(schema.jobs).where(eq(schema.jobs.clientId, uid)),
|
||||||
|
ctx.db.select().from(schema.swipes).where(eq(schema.swipes.proId, uid)),
|
||||||
|
ctx.db.select().from(schema.requests).where(eq(schema.requests.proId, uid)),
|
||||||
|
ctx.db.query.proProfiles.findFirst({ where: eq(schema.proProfiles.userId, uid) }),
|
||||||
|
ctx.db.query.notificationPreferences.findFirst({
|
||||||
|
where: eq(schema.notificationPreferences.userId, uid),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
user,
|
||||||
|
proProfile: proProfile ?? null,
|
||||||
|
jobs,
|
||||||
|
swipes,
|
||||||
|
requests,
|
||||||
|
notificationPreferences: preferences ?? null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GDPR erasure request.
|
||||||
|
*
|
||||||
|
* Records the ask rather than deleting: bookings, payments and reviews carry
|
||||||
|
* foreign keys and statutory retention periods, so a cascade would destroy
|
||||||
|
* records we are required to keep. A human actions this. It needs a real
|
||||||
|
* anonymise-and-retain flow before there are real users.
|
||||||
|
*/
|
||||||
|
requestDeletion: protectedProcedure
|
||||||
|
.input(z.object({ reason: z.string().max(1000).optional() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const existing = await ctx.db.query.deletionRequests.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(schema.deletionRequests.userId, ctx.session.userId),
|
||||||
|
isNull(schema.deletionRequests.actionedAt),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
if (existing) return { requested: true, alreadyPending: true };
|
||||||
|
|
||||||
|
await ctx.db.insert(schema.deletionRequests).values({
|
||||||
|
userId: ctx.session.userId,
|
||||||
|
reason: input.reason ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.db.insert(schema.auditLog).values({
|
||||||
|
actorId: ctx.session.userId,
|
||||||
|
action: 'user.deletion_requested',
|
||||||
|
entity: 'user',
|
||||||
|
entityId: ctx.session.userId,
|
||||||
|
ip: ctx.ip,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { requested: true, alreadyPending: false };
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
/**
|
||||||
|
* Integration tests for the pro profile surface.
|
||||||
|
*
|
||||||
|
* pnpm services:up && pnpm db:migrate && pnpm db:seed
|
||||||
|
*
|
||||||
|
* previewCard exists precisely because publicProfile refuses non-verified pros,
|
||||||
|
* so most of what follows is about it working where publicProfile cannot, and
|
||||||
|
* about reordering never reaching another pro's photos.
|
||||||
|
*/
|
||||||
|
import { config } from 'dotenv';
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
config({ path: '../../.env' });
|
||||||
|
|
||||||
|
const { closePool, db } = await import('@linkder/db');
|
||||||
|
const { appRouter } = await import('../src/root');
|
||||||
|
const { createInnerContext } = await import('../src/context');
|
||||||
|
const { createCallerFactory } = await import('../src/trpc');
|
||||||
|
|
||||||
|
const createCaller = createCallerFactory(appRouter);
|
||||||
|
type Session = import('../src/context').Session;
|
||||||
|
|
||||||
|
function callerFor(session: Session | null) {
|
||||||
|
return createCaller(createInnerContext({ db, session }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const proSession = (userId: string, verificationStatus: string): Session => ({
|
||||||
|
userId,
|
||||||
|
role: 'pro',
|
||||||
|
name: 'Test Pro',
|
||||||
|
email: 'pro@test',
|
||||||
|
phone: null,
|
||||||
|
verificationStatus: verificationStatus as Session['verificationStatus'],
|
||||||
|
});
|
||||||
|
|
||||||
|
let verifiedPro: string;
|
||||||
|
let pendingPro: string;
|
||||||
|
let otherPro: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const verified = await db.execute<{ user_id: string }>(sql`
|
||||||
|
SELECT p.user_id FROM pro_profiles p
|
||||||
|
WHERE p.verification_status = 'verified' ORDER BY p.user_id LIMIT 2
|
||||||
|
`);
|
||||||
|
verifiedPro = verified[0]!.user_id;
|
||||||
|
otherPro = verified[1]!.user_id;
|
||||||
|
|
||||||
|
const pending = await db.execute<{ user_id: string }>(sql`
|
||||||
|
SELECT p.user_id FROM pro_profiles p
|
||||||
|
WHERE p.verification_status = 'pending' ORDER BY p.user_id LIMIT 1
|
||||||
|
`);
|
||||||
|
pendingPro = pending[0]!.user_id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// The skills tests write to real seeded profiles; put them back empty.
|
||||||
|
await db.execute(
|
||||||
|
sql`UPDATE pro_profiles SET skills = '{}'::text[] WHERE user_id IN (${verifiedPro}, ${pendingPro})`,
|
||||||
|
);
|
||||||
|
await closePool();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pro.previewCard', () => {
|
||||||
|
it('returns the caller’s own card', async () => {
|
||||||
|
const card = await callerFor(proSession(verifiedPro, 'verified')).pro.previewCard();
|
||||||
|
expect(card?.proId).toBe(verifiedPro);
|
||||||
|
expect(card?.headline).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works for a pro whose verification has NOT passed', async () => {
|
||||||
|
// publicProfile 404s here, which is why this procedure exists: the moment a
|
||||||
|
// pro most needs to see their card is before they are approved.
|
||||||
|
const card = await callerFor(proSession(pendingPro, 'pending')).pro.previewCard();
|
||||||
|
expect(card?.proId).toBe(pendingPro);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
callerFor(proSession(pendingPro, 'pending')).pro.publicProfile({ proId: pendingPro }),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries the photos in deck order, lead photo first', async () => {
|
||||||
|
const card = await callerFor(proSession(verifiedPro, 'verified')).pro.previewCard();
|
||||||
|
const rows = await db.execute<{ url: string }>(sql`
|
||||||
|
SELECT url FROM pro_media WHERE pro_id = ${verifiedPro} ORDER BY position
|
||||||
|
`);
|
||||||
|
expect(card?.photos[0]).toBe(rows[0]!.url);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is not reachable by a client', async () => {
|
||||||
|
await expect(
|
||||||
|
callerFor({
|
||||||
|
userId: verifiedPro,
|
||||||
|
role: 'client',
|
||||||
|
name: null,
|
||||||
|
email: null,
|
||||||
|
phone: null,
|
||||||
|
verificationStatus: null,
|
||||||
|
}).pro.previewCard(),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pro.reorderMedia', () => {
|
||||||
|
it('puts the chosen photo first', async () => {
|
||||||
|
const before = await db.execute<{ id: string }>(sql`
|
||||||
|
SELECT id FROM pro_media WHERE pro_id = ${verifiedPro} ORDER BY position
|
||||||
|
`);
|
||||||
|
const reversed = before.map((r) => r.id).reverse();
|
||||||
|
|
||||||
|
await callerFor(proSession(verifiedPro, 'verified')).pro.reorderMedia({
|
||||||
|
orderedIds: reversed,
|
||||||
|
});
|
||||||
|
|
||||||
|
const after = await db.execute<{ id: string }>(sql`
|
||||||
|
SELECT id FROM pro_media WHERE pro_id = ${verifiedPro} ORDER BY position
|
||||||
|
`);
|
||||||
|
expect(after.map((r) => r.id)).toEqual(reversed);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a list containing another pro’s photo', async () => {
|
||||||
|
const mine = await db.execute<{ id: string }>(sql`
|
||||||
|
SELECT id FROM pro_media WHERE pro_id = ${verifiedPro} ORDER BY position
|
||||||
|
`);
|
||||||
|
const theirs = await db.execute<{ id: string }>(sql`
|
||||||
|
SELECT id FROM pro_media WHERE pro_id = ${otherPro} LIMIT 1
|
||||||
|
`);
|
||||||
|
|
||||||
|
const smuggled = [...mine.slice(1).map((r) => r.id), theirs[0]!.id];
|
||||||
|
await expect(
|
||||||
|
callerFor(proSession(verifiedPro, 'verified')).pro.reorderMedia({ orderedIds: smuggled }),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a partial list, which would leave gaps in the ordering', async () => {
|
||||||
|
const mine = await db.execute<{ id: string }>(sql`
|
||||||
|
SELECT id FROM pro_media WHERE pro_id = ${verifiedPro} ORDER BY position
|
||||||
|
`);
|
||||||
|
await expect(
|
||||||
|
callerFor(proSession(verifiedPro, 'verified')).pro.reorderMedia({
|
||||||
|
orderedIds: [mine[0]!.id],
|
||||||
|
}),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the other pro’s photos untouched', async () => {
|
||||||
|
const theirsBefore = await db.execute<{ id: string; position: number }>(sql`
|
||||||
|
SELECT id, position FROM pro_media WHERE pro_id = ${otherPro} ORDER BY position
|
||||||
|
`);
|
||||||
|
const mine = await db.execute<{ id: string }>(sql`
|
||||||
|
SELECT id FROM pro_media WHERE pro_id = ${verifiedPro} ORDER BY position
|
||||||
|
`);
|
||||||
|
|
||||||
|
await callerFor(proSession(verifiedPro, 'verified')).pro.reorderMedia({
|
||||||
|
orderedIds: mine.map((r) => r.id).reverse(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const theirsAfter = await db.execute<{ id: string; position: number }>(sql`
|
||||||
|
SELECT id, position FROM pro_media WHERE pro_id = ${otherPro} ORDER BY position
|
||||||
|
`);
|
||||||
|
expect(theirsAfter).toEqual(theirsBefore);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pro.updateSkills', () => {
|
||||||
|
it('saves the list and hands it back on pro.me', async () => {
|
||||||
|
const caller = callerFor(proSession(verifiedPro, 'verified'));
|
||||||
|
const result = await caller.pro.updateSkills({
|
||||||
|
skills: ['Underfloor heating', 'Emergency callouts'],
|
||||||
|
});
|
||||||
|
expect(result.skills).toEqual(['Underfloor heating', 'Emergency callouts']);
|
||||||
|
|
||||||
|
const profile = await caller.pro.me();
|
||||||
|
expect(profile?.skills).toEqual(['Underfloor heating', 'Emergency callouts']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces the whole list rather than appending', async () => {
|
||||||
|
const caller = callerFor(proSession(verifiedPro, 'verified'));
|
||||||
|
const result = await caller.pro.updateSkills({ skills: ['Bathroom fitting'] });
|
||||||
|
expect(result.skills).toEqual(['Bathroom fitting']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims and drops case-insensitive duplicates', async () => {
|
||||||
|
const result = await callerFor(proSession(verifiedPro, 'verified')).pro.updateSkills({
|
||||||
|
skills: [' Leak detection ', 'leak detection', 'LEAK DETECTION', 'Boiler swaps'],
|
||||||
|
});
|
||||||
|
// First spelling wins; the rest are the same claim twice.
|
||||||
|
expect(result.skills).toEqual(['Leak detection', 'Boiler swaps']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses more than the cap, and entries that are too long', async () => {
|
||||||
|
const caller = callerFor(proSession(verifiedPro, 'verified'));
|
||||||
|
await expect(
|
||||||
|
caller.pro.updateSkills({ skills: Array.from({ length: 13 }, (_, i) => `Skill ${i}`) }),
|
||||||
|
).rejects.toThrow();
|
||||||
|
await expect(caller.pro.updateSkills({ skills: ['x'.repeat(41)] })).rejects.toThrow();
|
||||||
|
await expect(caller.pro.updateSkills({ skills: ['a'] })).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts an empty list, so a pro can clear it', async () => {
|
||||||
|
const result = await callerFor(proSession(verifiedPro, 'verified')).pro.updateSkills({
|
||||||
|
skills: [],
|
||||||
|
});
|
||||||
|
expect(result.skills).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT send a verified pro back for review', async () => {
|
||||||
|
await callerFor(proSession(verifiedPro, 'verified')).pro.updateSkills({
|
||||||
|
skills: ['Listed buildings'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = await db.execute<{ status: string }>(
|
||||||
|
sql`SELECT verification_status AS status FROM pro_profiles WHERE user_id = ${verifiedPro}`,
|
||||||
|
);
|
||||||
|
// Skills are description, not a licensed claim — demoting for one would
|
||||||
|
// just teach pros to leave the field empty.
|
||||||
|
expect(rows[0]!.status).toBe('verified');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works before verification has passed', async () => {
|
||||||
|
const result = await callerFor(proSession(pendingPro, 'pending')).pro.updateSkills({
|
||||||
|
skills: ['Rewiring'],
|
||||||
|
});
|
||||||
|
expect(result.skills).toEqual(['Rewiring']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never touches another pro row', async () => {
|
||||||
|
await callerFor(proSession(verifiedPro, 'verified')).pro.updateSkills({ skills: ['Mine'] });
|
||||||
|
|
||||||
|
const rows = await db.execute<{ skills: string[] }>(
|
||||||
|
sql`SELECT skills FROM pro_profiles WHERE user_id = ${otherPro}`,
|
||||||
|
);
|
||||||
|
expect(rows[0]!.skills).not.toContain('Mine');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a caller who is not a pro', async () => {
|
||||||
|
const client: Session = {
|
||||||
|
userId: verifiedPro,
|
||||||
|
role: 'client',
|
||||||
|
name: 'Test Client',
|
||||||
|
email: 'client@test',
|
||||||
|
phone: null,
|
||||||
|
verificationStatus: null,
|
||||||
|
};
|
||||||
|
await expect(callerFor(client).pro.updateSkills({ skills: ['Nope'] })).rejects.toThrow();
|
||||||
|
await expect(callerFor(null).pro.updateSkills({ skills: ['Nope'] })).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
/**
|
||||||
|
* Integration tests for the settings surface, against the live seeded database.
|
||||||
|
*
|
||||||
|
* pnpm services:up && pnpm db:migrate && pnpm db:seed
|
||||||
|
*
|
||||||
|
* These are mostly authorization and information-leak tests. Settings hands a
|
||||||
|
* user controls over their own account; the failure mode that matters is one of
|
||||||
|
* them reaching somebody else's.
|
||||||
|
*/
|
||||||
|
import { config } from 'dotenv';
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
config({ path: '../../.env' });
|
||||||
|
|
||||||
|
const { closePool, db } = await import('@linkder/db');
|
||||||
|
const { appRouter } = await import('../src/root');
|
||||||
|
const { createInnerContext } = await import('../src/context');
|
||||||
|
const { createCallerFactory } = await import('../src/trpc');
|
||||||
|
|
||||||
|
const createCaller = createCallerFactory(appRouter);
|
||||||
|
type Session = import('../src/context').Session;
|
||||||
|
|
||||||
|
function callerFor(session: Session | null) {
|
||||||
|
return createCaller(createInnerContext({ db, session }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientSession = (userId: string): Session => ({
|
||||||
|
userId,
|
||||||
|
role: 'client',
|
||||||
|
name: 'Test Client',
|
||||||
|
email: 'client@test',
|
||||||
|
phone: null,
|
||||||
|
verificationStatus: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const proSession = (userId: string): Session => ({
|
||||||
|
userId,
|
||||||
|
role: 'pro',
|
||||||
|
name: 'Test Pro',
|
||||||
|
email: 'pro@test',
|
||||||
|
phone: null,
|
||||||
|
verificationStatus: 'verified',
|
||||||
|
});
|
||||||
|
|
||||||
|
let alice: string;
|
||||||
|
let bob: string;
|
||||||
|
let aliceEmail: string;
|
||||||
|
let bobEmail: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A throwaway verified pro, owned by this file.
|
||||||
|
*
|
||||||
|
* Not a seeded one: vitest runs test FILES in parallel, and the location tests
|
||||||
|
* demote a verified pro to `pending` — doing that to a seeded pro would delete a
|
||||||
|
* card out from under deck.router.test.ts mid-run. This one is parked in the
|
||||||
|
* Gulf of Guinea with no trades, so no deck query can reach it either way.
|
||||||
|
*/
|
||||||
|
let pro: string;
|
||||||
|
const PRO_BASE = { lat: 0.5, lng: 0.5 };
|
||||||
|
|
||||||
|
// Unique per run: these tests write real addresses onto real rows, and a
|
||||||
|
// leftover from a previous run would collide with users.email's UNIQUE index.
|
||||||
|
const RUN = Math.random().toString(36).slice(2, 8);
|
||||||
|
|
||||||
|
/** Marks the session rows this file creates, so they can be cleaned up. */
|
||||||
|
const PROBE_UA = 'SettingsTestProbe';
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Order by id, not created_at: the seed writes clients in one batch and
|
||||||
|
// created_at ties, so created_at ordering is not stable between runs.
|
||||||
|
const rows = await db.execute<{ id: string; email: string }>(
|
||||||
|
sql`SELECT id, email FROM users WHERE role = 'client' ORDER BY id LIMIT 2`,
|
||||||
|
);
|
||||||
|
alice = rows[0]!.id;
|
||||||
|
bob = rows[1]!.id;
|
||||||
|
aliceEmail = rows[0]!.email;
|
||||||
|
bobEmail = rows[1]!.email;
|
||||||
|
|
||||||
|
await db.execute(sql`DELETE FROM email_change_requests`);
|
||||||
|
await db.execute(sql`DELETE FROM deletion_requests`);
|
||||||
|
await db.execute(sql`DELETE FROM notification_preferences`);
|
||||||
|
await db.execute(sql`DELETE FROM sessions WHERE user_agent = ${PROBE_UA}`);
|
||||||
|
|
||||||
|
const created = await db.execute<{ id: string }>(sql`
|
||||||
|
INSERT INTO users (name, email, role)
|
||||||
|
VALUES ('Location Probe', ${`location-probe-${RUN}@example.com`}, 'pro')
|
||||||
|
RETURNING id
|
||||||
|
`);
|
||||||
|
pro = created[0]!.id;
|
||||||
|
await db.execute(sql`
|
||||||
|
INSERT INTO pro_profiles (
|
||||||
|
user_id, headline, bio, hourly_rate_cents, base_location, service_radius_m,
|
||||||
|
verification_status, verified_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
${pro}, 'Location probe', 'Exists only for the settings location tests.', 3000,
|
||||||
|
ST_SetSRID(ST_MakePoint(${PRO_BASE.lng}, ${PRO_BASE.lat}), 4326)::geography, 15000,
|
||||||
|
'verified', now()
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// Put the addresses back, or the next run starts from a different state.
|
||||||
|
await db.execute(sql`UPDATE users SET email = ${aliceEmail} WHERE id = ${alice}`);
|
||||||
|
await db.execute(sql`UPDATE users SET email = ${bobEmail} WHERE id = ${bob}`);
|
||||||
|
await db.execute(sql`DELETE FROM email_change_requests`);
|
||||||
|
await db.execute(sql`DELETE FROM deletion_requests`);
|
||||||
|
await db.execute(sql`DELETE FROM notification_preferences`);
|
||||||
|
await db.execute(sql`DELETE FROM sessions WHERE user_agent = ${PROBE_UA}`);
|
||||||
|
// Cascades to pro_profiles and audit_log.
|
||||||
|
await db.execute(sql`DELETE FROM users WHERE id = ${pro}`);
|
||||||
|
await db.execute(sql`
|
||||||
|
UPDATE users SET location = NULL, location_text = NULL, search_radius_m = 15000
|
||||||
|
WHERE id IN (${alice}, ${bob})
|
||||||
|
`);
|
||||||
|
await closePool();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('notification preferences', () => {
|
||||||
|
it('returns defaults when the user has never saved any', async () => {
|
||||||
|
const prefs = await callerFor(clientSession(alice)).notification.get();
|
||||||
|
expect(prefs.smsNewRequest).toBe(true);
|
||||||
|
// Marketing is the one that must default OFF — opt-in, not opt-out.
|
||||||
|
expect(prefs.smsMarketing).toBe(false);
|
||||||
|
expect(prefs.emailMarketing).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a partial update does not reset the preferences it did not mention', async () => {
|
||||||
|
const caller = callerFor(clientSession(alice));
|
||||||
|
await caller.notification.update({ smsMarketing: true });
|
||||||
|
await caller.notification.update({ smsNewRequest: false });
|
||||||
|
|
||||||
|
const prefs = await caller.notification.get();
|
||||||
|
expect(prefs.smsMarketing).toBe(true);
|
||||||
|
expect(prefs.smsNewRequest).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("one user's preferences are invisible to another", async () => {
|
||||||
|
await callerFor(clientSession(alice)).notification.update({ pushMessages: false });
|
||||||
|
const bobPrefs = await callerFor(clientSession(bob)).notification.get();
|
||||||
|
expect(bobPrefs.pushMessages).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an anonymous caller', async () => {
|
||||||
|
await expect(callerFor(null).notification.get()).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('email change', () => {
|
||||||
|
it('does NOT write the address to users.email before it is confirmed', async () => {
|
||||||
|
const caller = callerFor(clientSession(alice));
|
||||||
|
await caller.user.requestEmailChange({ email: `claimed-${RUN}@example.com` });
|
||||||
|
|
||||||
|
const rows = await db.execute<{ count: number }>(
|
||||||
|
sql`SELECT count(*)::int AS count FROM users WHERE email = ${`claimed-${RUN}@example.com`}`,
|
||||||
|
);
|
||||||
|
// This is the whole point: an unproven address must not occupy the UNIQUE
|
||||||
|
// column, or its real owner can never sign up with Google.
|
||||||
|
expect(rows[0]!.count).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not reveal whether an address is already registered', async () => {
|
||||||
|
// Requesting someone else's address must look exactly like any other request.
|
||||||
|
await expect(
|
||||||
|
callerFor(clientSession(alice)).user.requestEmailChange({ email: bobEmail }),
|
||||||
|
).resolves.toMatchObject({ sent: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('commits the address once the token comes back', async () => {
|
||||||
|
const caller = callerFor(clientSession(alice));
|
||||||
|
const { token } = await caller.user.requestEmailChange({ email: `proven-${RUN}@example.com` });
|
||||||
|
|
||||||
|
await callerFor(null).user.confirmEmailChange({ token });
|
||||||
|
|
||||||
|
const rows = await db.execute<{ email: string; verified: boolean }>(
|
||||||
|
sql`SELECT email, email_verified AS verified FROM users WHERE id = ${alice}`,
|
||||||
|
);
|
||||||
|
expect(rows[0]!.email).toBe(`proven-${RUN}@example.com`);
|
||||||
|
expect(rows[0]!.verified).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a token twice', async () => {
|
||||||
|
const caller = callerFor(clientSession(alice));
|
||||||
|
const { token } = await caller.user.requestEmailChange({ email: `once-${RUN}@example.com` });
|
||||||
|
await callerFor(null).user.confirmEmailChange({ token });
|
||||||
|
await expect(callerFor(null).user.confirmEmailChange({ token })).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to commit an address another account already holds', async () => {
|
||||||
|
const { token } = await callerFor(clientSession(alice)).user.requestEmailChange({
|
||||||
|
email: bobEmail,
|
||||||
|
});
|
||||||
|
// Only now — ownership proven — is the collision reported.
|
||||||
|
await expect(callerFor(null).user.confirmEmailChange({ token })).rejects.toThrow(/already/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sessions', () => {
|
||||||
|
it('never returns another user’s sessions', async () => {
|
||||||
|
await db.execute(sql`
|
||||||
|
INSERT INTO sessions (user_id, token, expires_at, ip_address, user_agent)
|
||||||
|
VALUES (${bob}, ${`bob-token-${RUN}`}, now() + interval '1 day', '10.0.0.1', ${PROBE_UA})
|
||||||
|
`);
|
||||||
|
const aliceSessions = await callerFor(clientSession(alice)).user.sessions();
|
||||||
|
expect(aliceSessions.every((s) => s.userAgent !== PROBE_UA)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never returns the session token', async () => {
|
||||||
|
const rows = await callerFor(clientSession(bob)).user.sessions();
|
||||||
|
for (const row of rows) {
|
||||||
|
expect(Object.keys(row)).not.toContain('token');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deletion request', () => {
|
||||||
|
it('records a request without deleting the user', async () => {
|
||||||
|
const result = await callerFor(clientSession(bob)).user.requestDeletion({ reason: 'testing' });
|
||||||
|
expect(result.requested).toBe(true);
|
||||||
|
|
||||||
|
const still = await db.execute<{ count: number }>(
|
||||||
|
sql`SELECT count(*)::int AS count FROM users WHERE id = ${bob}`,
|
||||||
|
);
|
||||||
|
expect(still[0]!.count).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent while one is still outstanding', async () => {
|
||||||
|
const second = await callerFor(clientSession(bob)).user.requestDeletion({});
|
||||||
|
expect(second.alreadyPending).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('location and range', () => {
|
||||||
|
it('starts with no pin and the default radius', async () => {
|
||||||
|
const location = await callerFor(clientSession(bob)).user.location();
|
||||||
|
expect(location.scope).toBe('client');
|
||||||
|
expect(location.location).toBeNull();
|
||||||
|
expect(location.radiusM).toBe(15_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saves a pin, a label and a radius, and reads them back', async () => {
|
||||||
|
const caller = callerFor(clientSession(alice));
|
||||||
|
await caller.user.updateLocation({
|
||||||
|
location: { lat: 41.4036, lng: 2.1744 },
|
||||||
|
addressText: 'Gracia, Barcelona',
|
||||||
|
radiusM: 8_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const location = await caller.user.location();
|
||||||
|
expect(location.addressText).toBe('Gracia, Barcelona');
|
||||||
|
expect(location.radiusM).toBe(8_000);
|
||||||
|
expect(location.location?.lat).toBeCloseTo(41.4036, 4);
|
||||||
|
expect(location.location?.lng).toBeCloseTo(2.1744, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changes only what it was given', async () => {
|
||||||
|
const caller = callerFor(clientSession(alice));
|
||||||
|
await caller.user.updateLocation({ radiusM: 25_000 });
|
||||||
|
|
||||||
|
const location = await caller.user.location();
|
||||||
|
expect(location.radiusM).toBe(25_000);
|
||||||
|
// The pin saved by the previous test is still there.
|
||||||
|
expect(location.location?.lat).toBeCloseTo(41.4036, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a radius outside the supported range', async () => {
|
||||||
|
const caller = callerFor(clientSession(alice));
|
||||||
|
await expect(caller.user.updateLocation({ radiusM: 500_000 })).rejects.toThrow();
|
||||||
|
await expect(caller.user.updateLocation({ radiusM: 10 })).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses an update that says nothing', async () => {
|
||||||
|
await expect(callerFor(clientSession(alice)).user.updateLocation({})).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never reads or writes another user location', async () => {
|
||||||
|
await callerFor(clientSession(alice)).user.updateLocation({ radiusM: 3_000 });
|
||||||
|
const bobLocation = await callerFor(clientSession(bob)).user.location();
|
||||||
|
expect(bobLocation.radiusM).not.toBe(3_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an anonymous caller', async () => {
|
||||||
|
await expect(callerFor(null).user.location()).rejects.toThrow();
|
||||||
|
await expect(callerFor(null).user.updateLocation({ radiusM: 5_000 })).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads a pro service area from the profile, not the user row', async () => {
|
||||||
|
// A stray value on the user row must not be what a pro is shown: the deck
|
||||||
|
// matches on the profile, so anything else would display a number that
|
||||||
|
// decides nothing.
|
||||||
|
await db.execute(sql`UPDATE users SET search_radius_m = 1000 WHERE id = ${pro}`);
|
||||||
|
|
||||||
|
const location = await callerFor(proSession(pro)).user.location();
|
||||||
|
expect(location.scope).toBe('pro');
|
||||||
|
expect(location.needsProfile).toBe(false);
|
||||||
|
expect(location.radiusM).toBe(15_000);
|
||||||
|
expect(location.location?.lat).toBeCloseTo(PRO_BASE.lat, 4);
|
||||||
|
expect(location.reviewOnChange).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes a pro radius to the profile the deck reads', async () => {
|
||||||
|
await callerFor(proSession(pro)).user.updateLocation({ radiusM: 22_000 });
|
||||||
|
|
||||||
|
const rows = await db.execute<{ radius: number }>(
|
||||||
|
sql`SELECT service_radius_m AS radius FROM pro_profiles WHERE user_id = ${pro}`,
|
||||||
|
);
|
||||||
|
expect(rows[0]!.radius).toBe(22_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends a verified pro back for review when the area changes', async () => {
|
||||||
|
await db.execute(
|
||||||
|
sql`UPDATE pro_profiles SET verification_status = 'verified' WHERE user_id = ${pro}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await callerFor(proSession(pro)).user.updateLocation({ radiusM: 30_000 });
|
||||||
|
expect(result.sentForReview).toBe(true);
|
||||||
|
|
||||||
|
const rows = await db.execute<{ status: string }>(
|
||||||
|
sql`SELECT verification_status AS status FROM pro_profiles WHERE user_id = ${pro}`,
|
||||||
|
);
|
||||||
|
// Settings must not become the way around verification.
|
||||||
|
expect(rows[0]!.status).toBe('pending');
|
||||||
|
|
||||||
|
const audit = await db.execute<{ count: number }>(sql`
|
||||||
|
SELECT count(*)::int AS count FROM audit_log
|
||||||
|
WHERE actor_id = ${pro} AND action = 'verification.re_review_required'
|
||||||
|
`);
|
||||||
|
expect(audit[0]!.count).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves verification alone when nothing material changed', async () => {
|
||||||
|
await db.execute(
|
||||||
|
sql`UPDATE pro_profiles SET verification_status = 'verified' WHERE user_id = ${pro}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The radius the row already holds, plus a label. Neither is material.
|
||||||
|
const result = await callerFor(proSession(pro)).user.updateLocation({
|
||||||
|
radiusM: 30_000,
|
||||||
|
addressText: 'Somewhere warm',
|
||||||
|
});
|
||||||
|
expect(result.sentForReview).toBe(false);
|
||||||
|
|
||||||
|
const rows = await db.execute<{ status: string }>(
|
||||||
|
sql`SELECT verification_status AS status FROM pro_profiles WHERE user_id = ${pro}`,
|
||||||
|
);
|
||||||
|
expect(rows[0]!.status).toBe('verified');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('data export', () => {
|
||||||
|
it('returns only the caller’s own rows', async () => {
|
||||||
|
const data = await callerFor(clientSession(alice)).user.exportData();
|
||||||
|
expect(data.user?.id).toBe(alice);
|
||||||
|
expect(data.jobs.every((j) => j.clientId === alice)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
CREATE TABLE "deletion_requests" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"reason" text,
|
||||||
|
"actioned_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "notification_preferences" (
|
||||||
|
"user_id" uuid PRIMARY KEY NOT NULL,
|
||||||
|
"sms_new_request" boolean DEFAULT true NOT NULL,
|
||||||
|
"sms_booking_reminder" boolean DEFAULT true NOT NULL,
|
||||||
|
"sms_marketing" boolean DEFAULT false NOT NULL,
|
||||||
|
"email_receipts" boolean DEFAULT true NOT NULL,
|
||||||
|
"email_marketing" boolean DEFAULT false NOT NULL,
|
||||||
|
"push_messages" boolean DEFAULT true NOT NULL,
|
||||||
|
"push_requests" boolean DEFAULT true NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "deletion_requests" ADD CONSTRAINT "deletion_requests_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "notification_preferences" ADD CONSTRAINT "notification_preferences_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
CREATE TABLE "email_change_requests" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"email" text NOT NULL,
|
||||||
|
"token" text NOT NULL,
|
||||||
|
"expires_at" timestamp with time zone NOT NULL,
|
||||||
|
"consumed_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "email_change_requests_token_unique" UNIQUE("token")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "email_change_requests" ADD CONSTRAINT "email_change_requests_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE "users" ADD COLUMN "location" geography(Point,4326);--> statement-breakpoint
|
||||||
|
ALTER TABLE "users" ADD COLUMN "location_text" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "users" ADD COLUMN "search_radius_m" integer DEFAULT 15000 NOT NULL;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "pro_profiles" ADD COLUMN "skills" text[] DEFAULT '{}'::text[] NOT NULL;
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,34 @@
|
|||||||
"when": 1787252153406,
|
"when": 1787252153406,
|
||||||
"tag": "0000_material_shadow_king",
|
"tag": "0000_material_shadow_king",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787292806114,
|
||||||
|
"tag": "0001_bouncy_sally_floyd",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787292942440,
|
||||||
|
"tag": "0002_jazzy_mac_gargan",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 3,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787295054810,
|
||||||
|
"tag": "0003_amazing_turbo",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787296176203,
|
||||||
|
"tag": "0004_closed_nextwave",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -176,11 +176,40 @@ export async function getDeck(
|
|||||||
*/
|
*/
|
||||||
export async function getShowcaseDeck(
|
export async function getShowcaseDeck(
|
||||||
db: Db,
|
db: Db,
|
||||||
args: { lat: number; lng: number; limit?: number; now?: Date },
|
args: {
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
categoryId?: string;
|
||||||
|
/**
|
||||||
|
* How far the person looking is willing to go, in metres. Applied ON TOP of
|
||||||
|
* each pro's own radius: both sides have to agree to the distance, and a pro
|
||||||
|
* who covers the whole city still should not fill the deck of someone who
|
||||||
|
* said "walking distance only".
|
||||||
|
*/
|
||||||
|
maxDistanceM?: number;
|
||||||
|
limit?: number;
|
||||||
|
now?: Date;
|
||||||
|
},
|
||||||
): Promise<DeckCard[]> {
|
): Promise<DeckCard[]> {
|
||||||
const limit = args.limit ?? DECK_PAGE_SIZE;
|
const limit = args.limit ?? DECK_PAGE_SIZE;
|
||||||
const now = args.now ?? new Date();
|
const now = args.now ?? new Date();
|
||||||
|
|
||||||
|
// Narrowing to one trade. Omitted means every trade, which is what the entry
|
||||||
|
// screen shows before the visitor has told us what they need.
|
||||||
|
const categoryFilter = args.categoryId
|
||||||
|
? sql`AND EXISTS (
|
||||||
|
SELECT 1 FROM pro_categories pc
|
||||||
|
WHERE pc.pro_id = p.user_id AND pc.category_id = ${args.categoryId}
|
||||||
|
)`
|
||||||
|
: sql``;
|
||||||
|
|
||||||
|
// Same GiST index, same ST_DWithin — just measured against the searcher's
|
||||||
|
// limit rather than the pro's.
|
||||||
|
const distanceFilter =
|
||||||
|
args.maxDistanceM === undefined
|
||||||
|
? sql``
|
||||||
|
: sql`AND ST_DWithin(p.base_location, centre.g, ${args.maxDistanceM})`;
|
||||||
|
|
||||||
const rows = await db.execute<{
|
const rows = await db.execute<{
|
||||||
pro_id: string;
|
pro_id: string;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
@@ -240,6 +269,8 @@ export async function getShowcaseDeck(
|
|||||||
AND p.is_accepting_jobs = true
|
AND p.is_accepting_jobs = true
|
||||||
AND (u.banned IS NOT TRUE OR (u.ban_expires IS NOT NULL AND u.ban_expires < now()))
|
AND (u.banned IS NOT TRUE OR (u.ban_expires IS NOT NULL AND u.ban_expires < now()))
|
||||||
AND ST_DWithin(p.base_location, centre.g, p.service_radius_m)
|
AND ST_DWithin(p.base_location, centre.g, p.service_radius_m)
|
||||||
|
${distanceFilter}
|
||||||
|
${categoryFilter}
|
||||||
ORDER BY ST_Distance(p.base_location, centre.g) ASC
|
ORDER BY ST_Distance(p.base_location, centre.g) ASC
|
||||||
LIMIT ${CANDIDATE_POOL}
|
LIMIT ${CANDIDATE_POOL}
|
||||||
`);
|
`);
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ import { relations } from 'drizzle-orm';
|
|||||||
import {
|
import {
|
||||||
boolean,
|
boolean,
|
||||||
index,
|
index,
|
||||||
|
integer,
|
||||||
pgTable,
|
pgTable,
|
||||||
text,
|
text,
|
||||||
timestamp,
|
timestamp,
|
||||||
unique,
|
unique,
|
||||||
uuid,
|
uuid,
|
||||||
} from 'drizzle-orm/pg-core';
|
} from 'drizzle-orm/pg-core';
|
||||||
|
import { DEFAULT_SERVICE_RADIUS_M } from '@linkder/shared';
|
||||||
|
import { point } from '../postgis';
|
||||||
import { userRole } from './enums';
|
import { userRole } from './enums';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -55,6 +58,23 @@ export const users = pgTable(
|
|||||||
banReason: text('ban_reason'),
|
banReason: text('ban_reason'),
|
||||||
banExpires: timestamp('ban_expires', { withTimezone: true }),
|
banExpires: timestamp('ban_expires', { withTimezone: true }),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where this person is, and how far they are willing to look.
|
||||||
|
*
|
||||||
|
* This is the CLIENT-side answer only. A pro's working area is
|
||||||
|
* `pro_profiles.base_location` + `service_radius_m` — that pair is what the
|
||||||
|
* deck query and the verification review both read, so duplicating it here
|
||||||
|
* would give a pro two radii and no way to tell which one matched them to a
|
||||||
|
* job. Settings routes a pro's edit to the profile instead.
|
||||||
|
*
|
||||||
|
* Nullable because nobody is asked for it at signup: a null location means
|
||||||
|
* "we do not know yet", and callers fall back to the city centre.
|
||||||
|
*/
|
||||||
|
location: point('location'),
|
||||||
|
/** Human label for `location` — "Gràcia, Barcelona". Display only; never matched on. */
|
||||||
|
locationText: text('location_text'),
|
||||||
|
searchRadiusM: integer('search_radius_m').notNull().default(DEFAULT_SERVICE_RADIUS_M),
|
||||||
|
|
||||||
/** Deck ranking penalises dormant pros, so this has to be maintained. */
|
/** Deck ranking penalises dormant pros, so this has to be maintained. */
|
||||||
lastActiveAt: timestamp('last_active_at', { withTimezone: true }).defaultNow(),
|
lastActiveAt: timestamp('last_active_at', { withTimezone: true }).defaultNow(),
|
||||||
|
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ export * from './messaging';
|
|||||||
export * from './commerce';
|
export * from './commerce';
|
||||||
export * from './reviews';
|
export * from './reviews';
|
||||||
export * from './audit';
|
export * from './audit';
|
||||||
|
export * from './settings';
|
||||||
|
|||||||
@@ -37,6 +37,16 @@ export const proProfiles = pgTable(
|
|||||||
baseLocation: point('base_location').notNull(),
|
baseLocation: point('base_location').notNull(),
|
||||||
serviceRadiusM: integer('service_radius_m').notNull().default(15000),
|
serviceRadiusM: integer('service_radius_m').notNull().default(15000),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Free-text specialisms — "underfloor heating", "emergency callouts".
|
||||||
|
*
|
||||||
|
* Deliberately NOT the trade list: `pro_categories` is the matching key and
|
||||||
|
* is what verification checks a licence against, so it stays a closed set of
|
||||||
|
* rows. These are the pro's own words, for a customer to read, and nothing
|
||||||
|
* matches on them.
|
||||||
|
*/
|
||||||
|
skills: text('skills').array().notNull().default(sql`'{}'::text[]`),
|
||||||
|
|
||||||
verificationStatus: verificationStatus('verification_status').notNull().default('draft'),
|
verificationStatus: verificationStatus('verification_status').notNull().default('draft'),
|
||||||
verifiedAt: timestamp('verified_at', { withTimezone: true }),
|
verifiedAt: timestamp('verified_at', { withTimezone: true }),
|
||||||
suspendedReason: text('suspended_reason'),
|
suspendedReason: text('suspended_reason'),
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { relations } from 'drizzle-orm';
|
||||||
|
import { boolean, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
|
||||||
|
import { users } from './auth';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-user notification preferences.
|
||||||
|
*
|
||||||
|
* A missing row means "all defaults", so there is no backfill and no window
|
||||||
|
* where an existing user has no preferences. Every column therefore defaults to
|
||||||
|
* the value we would use in the absence of a row.
|
||||||
|
*
|
||||||
|
* NOTE: nothing consumes these yet — the worker that sends the messages arrives
|
||||||
|
* in M4. Until then this stores intent only, and the UI must say so rather than
|
||||||
|
* implying a toggle stops an SMS today.
|
||||||
|
*/
|
||||||
|
export const notificationPreferences = pgTable('notification_preferences', {
|
||||||
|
userId: uuid('user_id')
|
||||||
|
.primaryKey()
|
||||||
|
.references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
|
||||||
|
// SMS — the only channel that reaches a phone-only client, so the transactional
|
||||||
|
// ones default on and the marketing one defaults off.
|
||||||
|
smsNewRequest: boolean('sms_new_request').notNull().default(true),
|
||||||
|
smsBookingReminder: boolean('sms_booking_reminder').notNull().default(true),
|
||||||
|
smsMarketing: boolean('sms_marketing').notNull().default(false),
|
||||||
|
|
||||||
|
// Email — only ever sent to a contactable address; see isSyntheticEmail.
|
||||||
|
emailReceipts: boolean('email_receipts').notNull().default(true),
|
||||||
|
emailMarketing: boolean('email_marketing').notNull().default(false),
|
||||||
|
|
||||||
|
// Push — the native app does not exist yet; kept so the shape is stable.
|
||||||
|
pushMessages: boolean('push_messages').notNull().default(true),
|
||||||
|
pushRequests: boolean('push_requests').notNull().default(true),
|
||||||
|
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A GDPR erasure request.
|
||||||
|
*
|
||||||
|
* Deliberately a request rather than a cascade: bookings, payments and reviews
|
||||||
|
* carry foreign keys and statutory retention periods, so "delete my account"
|
||||||
|
* cannot simply DELETE the user. This records the ask and the promise; a human
|
||||||
|
* actions it. That is honest for a pre-launch product and becomes a liability
|
||||||
|
* the day there are real users, so a real flow must exist before launch.
|
||||||
|
*/
|
||||||
|
export const deletionRequests = pgTable('deletion_requests', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
userId: uuid('user_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
reason: text('reason'),
|
||||||
|
/** Set when a human has completed the erasure. Null means outstanding. */
|
||||||
|
actionedAt: timestamp('actioned_at', { withTimezone: true }),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const notificationPreferencesRelations = relations(notificationPreferences, ({ one }) => ({
|
||||||
|
user: one(users, { fields: [notificationPreferences.userId], references: [users.id] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const deletionRequestsRelations = relations(deletionRequests, ({ one }) => ({
|
||||||
|
user: one(users, { fields: [deletionRequests.userId], references: [users.id] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A requested — but not yet proven — email address.
|
||||||
|
*
|
||||||
|
* This table exists to keep unverified addresses OUT of `users.email`. Writing
|
||||||
|
* them there directly (the previous behaviour) meant anyone could type a
|
||||||
|
* stranger's address and, because `users.email` is UNIQUE, permanently block
|
||||||
|
* that stranger from ever signing up with Google. It also turned the uniqueness
|
||||||
|
* error into an oracle for "is this address registered?".
|
||||||
|
*
|
||||||
|
* Nothing here is authoritative: the address only moves onto `users` once the
|
||||||
|
* token comes back.
|
||||||
|
*/
|
||||||
|
export const emailChangeRequests = pgTable('email_change_requests', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
userId: uuid('user_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
email: text('email').notNull(),
|
||||||
|
/** Random, single-use. Compared in full; never rendered back to the client. */
|
||||||
|
token: text('token').notNull().unique(),
|
||||||
|
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||||
|
consumedAt: timestamp('consumed_at', { withTimezone: true }),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const emailChangeRequestsRelations = relations(emailChangeRequests, ({ one }) => ({
|
||||||
|
user: one(users, { fields: [emailChangeRequests.userId], references: [users.id] }),
|
||||||
|
}));
|
||||||
+70
-2
@@ -39,7 +39,15 @@ function offset(lat: number, lng: number, metres: number, bearingDeg: number) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The trade taxonomy, in display order — the picker and the landing-page trade
|
||||||
|
* strip both render it top-to-bottom, so the order is the demand order: the
|
||||||
|
* trades a city marketplace sees most first, the long tail after. Slugs are the
|
||||||
|
* stable key (pros, jobs and tests reference them); names and icons are cosmetic.
|
||||||
|
* Icons are lucide names, kebab-case.
|
||||||
|
*/
|
||||||
const CATEGORIES = [
|
const CATEGORIES = [
|
||||||
|
// Core trades — the eight that carry most of the volume.
|
||||||
{ slug: 'plumber', name: 'Plumber', icon: 'shower-head' },
|
{ slug: 'plumber', name: 'Plumber', icon: 'shower-head' },
|
||||||
{ slug: 'electrician', name: 'Electrician', icon: 'zap' },
|
{ slug: 'electrician', name: 'Electrician', icon: 'zap' },
|
||||||
{ slug: 'handyman', name: 'Handyman', icon: 'wrench' },
|
{ slug: 'handyman', name: 'Handyman', icon: 'wrench' },
|
||||||
@@ -48,6 +56,60 @@ const CATEGORIES = [
|
|||||||
{ slug: 'locksmith', name: 'Locksmith', icon: 'key-round' },
|
{ slug: 'locksmith', name: 'Locksmith', icon: 'key-round' },
|
||||||
{ slug: 'appliance-repair', name: 'Appliance Repair', icon: 'washing-machine' },
|
{ slug: 'appliance-repair', name: 'Appliance Repair', icon: 'washing-machine' },
|
||||||
{ slug: 'hvac', name: 'Heating & Cooling', icon: 'thermometer' },
|
{ slug: 'hvac', name: 'Heating & Cooling', icon: 'thermometer' },
|
||||||
|
|
||||||
|
// Home upkeep and renovation.
|
||||||
|
{ slug: 'cleaner', name: 'House Cleaning', icon: 'sparkles' },
|
||||||
|
{ slug: 'gardener', name: 'Gardening & Landscaping', icon: 'sprout' },
|
||||||
|
{ slug: 'mover', name: 'Removals & Moving', icon: 'truck' },
|
||||||
|
{ slug: 'builder', name: 'Builder & Renovation', icon: 'brick-wall' },
|
||||||
|
{ slug: 'tiler', name: 'Tiling', icon: 'grid-2x2' },
|
||||||
|
{ slug: 'plasterer', name: 'Plastering & Drywall', icon: 'layers' },
|
||||||
|
{ slug: 'roofer', name: 'Roofing', icon: 'house' },
|
||||||
|
{ slug: 'flooring', name: 'Flooring & Parquet', icon: 'grid-3x3' },
|
||||||
|
{ slug: 'window-fitter', name: 'Windows & Glazing', icon: 'app-window' },
|
||||||
|
{ slug: 'blinds-curtains', name: 'Blinds & Curtains', icon: 'blinds' },
|
||||||
|
{ slug: 'kitchen-fitter', name: 'Kitchen Fitting', icon: 'cooking-pot' },
|
||||||
|
{ slug: 'bathroom-fitter', name: 'Bathroom Fitting', icon: 'bath' },
|
||||||
|
{ slug: 'gas-engineer', name: 'Gas & Boilers', icon: 'flame' },
|
||||||
|
{ slug: 'solar', name: 'Solar & Batteries', icon: 'sun' },
|
||||||
|
{ slug: 'drain-unblocking', name: 'Drains & Unblocking', icon: 'droplets' },
|
||||||
|
{ slug: 'pest-control', name: 'Pest Control', icon: 'bug' },
|
||||||
|
{ slug: 'waste-removal', name: 'Waste & Junk Removal', icon: 'trash-2' },
|
||||||
|
{ slug: 'window-cleaner', name: 'Window Cleaning', icon: 'spray-can' },
|
||||||
|
{ slug: 'pool-maintenance', name: 'Pool Maintenance', icon: 'waves' },
|
||||||
|
{ slug: 'upholstery', name: 'Upholstery & Furniture Repair', icon: 'sofa' },
|
||||||
|
{ slug: 'alarms-cctv', name: 'Alarms & CCTV', icon: 'cctv' },
|
||||||
|
|
||||||
|
// Devices and vehicles.
|
||||||
|
{ slug: 'it-support', name: 'Computer & IT Support', icon: 'laptop' },
|
||||||
|
{ slug: 'phone-repair', name: 'Phone & Tablet Repair', icon: 'smartphone' },
|
||||||
|
{ slug: 'car-mechanic', name: 'Car Mechanic', icon: 'car' },
|
||||||
|
{ slug: 'car-detailing', name: 'Car Wash & Detailing', icon: 'car-front' },
|
||||||
|
|
||||||
|
// People care.
|
||||||
|
{ slug: 'babysitter', name: 'Childcare & Nannies', icon: 'baby' },
|
||||||
|
{ slug: 'elderly-care', name: 'Elderly Care', icon: 'heart-handshake' },
|
||||||
|
{ slug: 'pet-care', name: 'Pet Care & Dog Walking', icon: 'dog' },
|
||||||
|
{ slug: 'massage', name: 'Massage & Physio', icon: 'hand-heart' },
|
||||||
|
{ slug: 'hairdresser', name: 'Hairdresser & Barber', icon: 'scissors' },
|
||||||
|
{ slug: 'beautician', name: 'Beauty & Nails', icon: 'gem' },
|
||||||
|
{ slug: 'personal-trainer', name: 'Personal Trainer', icon: 'dumbbell' },
|
||||||
|
|
||||||
|
// Lessons.
|
||||||
|
{ slug: 'tutor', name: 'Private Tutor', icon: 'graduation-cap' },
|
||||||
|
{ slug: 'music-teacher', name: 'Music Lessons', icon: 'music' },
|
||||||
|
|
||||||
|
// Events and creative.
|
||||||
|
{ slug: 'photographer', name: 'Photographer', icon: 'camera' },
|
||||||
|
{ slug: 'videographer', name: 'Video & Drone', icon: 'video' },
|
||||||
|
{ slug: 'dj', name: 'DJ & Live Music', icon: 'disc-3' },
|
||||||
|
{ slug: 'catering', name: 'Catering & Private Chefs', icon: 'chef-hat' },
|
||||||
|
{ slug: 'event-planner', name: 'Events & Parties', icon: 'party-popper' },
|
||||||
|
|
||||||
|
// Professional services.
|
||||||
|
{ slug: 'accountant', name: 'Accounting & Tax', icon: 'calculator' },
|
||||||
|
{ slug: 'lawyer', name: 'Legal Services', icon: 'scale' },
|
||||||
|
{ slug: 'architect', name: 'Architect & Surveyor', icon: 'drafting-compass' },
|
||||||
];
|
];
|
||||||
|
|
||||||
interface SeedPro {
|
interface SeedPro {
|
||||||
@@ -189,11 +251,17 @@ async function main() {
|
|||||||
await db.insert(schema.proCategories).values({ proId: user.id, categoryId: catId });
|
await db.insert(schema.proCategories).values({ proId: user.id, categoryId: catId });
|
||||||
|
|
||||||
const slug = encodeURIComponent(p.name.toLowerCase().replace(/\s+/g, '-'));
|
const slug = encodeURIComponent(p.name.toLowerCase().replace(/\s+/g, '-'));
|
||||||
|
// The first photo is the deck card, so it has to be a FACE. picsum returns
|
||||||
|
// landscape stock scenery — a locksmith on a railway track — which makes the
|
||||||
|
// deck unreadable as a people-picker no matter what the layout does.
|
||||||
|
// pravatar serves ~70 portraits; i is 0-based and img is 1-based.
|
||||||
|
const portrait = (i % 70) + 1;
|
||||||
await db.insert(schema.proMedia).values([
|
await db.insert(schema.proMedia).values([
|
||||||
{ proId: user.id, url: `https://picsum.photos/seed/${slug}-1/800/1000`, position: 0 },
|
{ proId: user.id, url: `https://i.pravatar.cc/800?img=${portrait}`, position: 0 },
|
||||||
{
|
{
|
||||||
|
// The second slot is genuinely for work: scenery is fine here.
|
||||||
proId: user.id,
|
proId: user.id,
|
||||||
url: `https://picsum.photos/seed/${slug}-2/800/1000`,
|
url: `https://picsum.photos/seed/${slug}-work/800/1000`,
|
||||||
kind: 'work_sample' as const,
|
kind: 'work_sample' as const,
|
||||||
position: 1,
|
position: 1,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
* three pros specifically to prove each exclusion reason fires.
|
* three pros specifically to prove each exclusion reason fires.
|
||||||
*/
|
*/
|
||||||
import { config } from 'dotenv';
|
import { config } from 'dotenv';
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
config({ path: '../../.env' });
|
config({ path: '../../.env' });
|
||||||
@@ -61,6 +62,48 @@ describe('getShowcaseDeck', () => {
|
|||||||
expect(three).toHaveLength(3);
|
expect(three).toHaveLength(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('honours the searcher own range, not just the pro one', async () => {
|
||||||
|
// Marta Vidal is seeded 9.1 km out with a 30 km radius: she would travel
|
||||||
|
// here happily, but someone who said "within 3 km" did not ask for her.
|
||||||
|
const near = await getShowcaseDeck(db, { ...CENTRE, maxDistanceM: 3_000, limit: 100 });
|
||||||
|
const nearNames = near.map((c) => c.name);
|
||||||
|
|
||||||
|
expect(nearNames).not.toContain('Marta Vidal');
|
||||||
|
expect(nearNames).toContain('Marc Oliveras'); // 800 m away
|
||||||
|
expect(near.every((c) => c.distanceM <= 3_000)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('narrows to a single trade when given a category', async () => {
|
||||||
|
const [plumber] = await db.execute<{ id: string }>(
|
||||||
|
sql`SELECT id FROM categories WHERE slug = 'plumber' LIMIT 1`,
|
||||||
|
);
|
||||||
|
if (!plumber) throw new Error('plumber category missing from seed');
|
||||||
|
|
||||||
|
const cards = await getShowcaseDeck(db, { ...CENTRE, categoryId: plumber.id, limit: 100 });
|
||||||
|
|
||||||
|
expect(cards.length).toBeGreaterThan(0);
|
||||||
|
// Every card carries its trade names, so the filter is checkable per card.
|
||||||
|
expect(cards.every((c) => c.categories.includes('Plumber'))).toBe(true);
|
||||||
|
// And it must be a strict subset — otherwise the filter did nothing.
|
||||||
|
expect(cards.length).toBeLessThan(names.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still excludes the ineligible when a category is given', async () => {
|
||||||
|
const [plumber] = await db.execute<{ id: string }>(
|
||||||
|
sql`SELECT id FROM categories WHERE slug = 'plumber' LIMIT 1`,
|
||||||
|
);
|
||||||
|
if (!plumber) throw new Error('plumber category missing from seed');
|
||||||
|
|
||||||
|
const cards = await getShowcaseDeck(db, { ...CENTRE, categoryId: plumber.id, limit: 100 });
|
||||||
|
const filtered = cards.map((c) => c.name);
|
||||||
|
|
||||||
|
// Pau Ribas is a plumber — he is kept out by radius, not by trade, so this
|
||||||
|
// proves the category filter did not replace the eligibility rules.
|
||||||
|
expect(filtered).not.toContain('Pau Ribas');
|
||||||
|
expect(filtered).not.toContain('Unverified Ulla');
|
||||||
|
expect(filtered).not.toContain('Away Arnau');
|
||||||
|
});
|
||||||
|
|
||||||
it('never returns a card with a distance beyond that pro’s own radius', async () => {
|
it('never returns a card with a distance beyond that pro’s own radius', async () => {
|
||||||
const cards = await getShowcaseDeck(db, { ...CENTRE, limit: 100 });
|
const cards = await getShowcaseDeck(db, { ...CENTRE, limit: 100 });
|
||||||
// The card shape does not expose serviceRadiusM, but ST_DWithin is the only
|
// The card shape does not expose serviceRadiusM, but ST_DWithin is the only
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ export const DEFAULT_PLATFORM_FEE_BPS = 1500; // 15%
|
|||||||
export const MIN_QUOTE_CENTS = 500; // €5 — below this, escrow overhead isn't worth it
|
export const MIN_QUOTE_CENTS = 500; // €5 — below this, escrow overhead isn't worth it
|
||||||
export const MAX_QUOTE_CENTS = 2_000_000; // €20,000 sanity ceiling
|
export const MAX_QUOTE_CENTS = 2_000_000; // €20,000 sanity ceiling
|
||||||
|
|
||||||
|
/** Free-text specialisms on a pro profile. A card nobody can read is worse than a short one. */
|
||||||
|
export const MAX_SKILLS = 12;
|
||||||
|
export const MAX_SKILL_LENGTH = 40;
|
||||||
|
|
||||||
/** Pro service radius bounds, metres. */
|
/** Pro service radius bounds, metres. */
|
||||||
export const MIN_SERVICE_RADIUS_M = 1_000;
|
export const MIN_SERVICE_RADIUS_M = 1_000;
|
||||||
export const MAX_SERVICE_RADIUS_M = 50_000;
|
export const MAX_SERVICE_RADIUS_M = 50_000;
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { z } from 'zod';
|
|||||||
import {
|
import {
|
||||||
MAX_QUOTE_CENTS,
|
MAX_QUOTE_CENTS,
|
||||||
MAX_SERVICE_RADIUS_M,
|
MAX_SERVICE_RADIUS_M,
|
||||||
|
MAX_SKILL_LENGTH,
|
||||||
|
MAX_SKILLS,
|
||||||
MIN_QUOTE_CENTS,
|
MIN_QUOTE_CENTS,
|
||||||
MIN_SERVICE_RADIUS_M,
|
MIN_SERVICE_RADIUS_M,
|
||||||
} from './constants';
|
} from './constants';
|
||||||
@@ -55,6 +57,51 @@ export const proProfileSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type ProProfileInput = z.infer<typeof proProfileSchema>;
|
export type ProProfileInput = z.infer<typeof proProfileSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Where I am, and how far I will go."
|
||||||
|
*
|
||||||
|
* Every field is optional so the settings screen can save a moved pin without
|
||||||
|
* touching the radius, but an empty object is rejected — a mutation that
|
||||||
|
* silently does nothing is indistinguishable from one that failed.
|
||||||
|
*
|
||||||
|
* `addressText` is a label a human typed, never geocoded: matching happens on
|
||||||
|
* the coordinates alone, so an empty or wrong label costs nothing but clarity.
|
||||||
|
*/
|
||||||
|
export const updateLocationSchema = z
|
||||||
|
.object({
|
||||||
|
location: latLngSchema.optional(),
|
||||||
|
addressText: z.string().trim().max(255).optional(),
|
||||||
|
radiusM: z.number().int().min(MIN_SERVICE_RADIUS_M).max(MAX_SERVICE_RADIUS_M).optional(),
|
||||||
|
})
|
||||||
|
.refine((v) => Object.values(v).some((field) => field !== undefined), {
|
||||||
|
message: 'Nothing to update',
|
||||||
|
});
|
||||||
|
export type UpdateLocationInput = z.infer<typeof updateLocationSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pro's own words for what they are good at.
|
||||||
|
*
|
||||||
|
* Deduplicated case-insensitively and server-side, keeping the first spelling:
|
||||||
|
* "Boiler repair" and "boiler repair" are one skill to a reader, and letting
|
||||||
|
* both through is how a card ends up padded with the same claim twice. Doing it
|
||||||
|
* here rather than in the form means it holds for every caller.
|
||||||
|
*/
|
||||||
|
export const updateSkillsSchema = z.object({
|
||||||
|
skills: z
|
||||||
|
.array(z.string().trim().min(2).max(MAX_SKILL_LENGTH))
|
||||||
|
.max(MAX_SKILLS)
|
||||||
|
.transform((values) => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return values.filter((value) => {
|
||||||
|
const key = value.toLocaleLowerCase();
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
export type UpdateSkillsInput = z.input<typeof updateSkillsSchema>;
|
||||||
|
|
||||||
export const swipeSchema = z.object({
|
export const swipeSchema = z.object({
|
||||||
jobId: z.string().uuid(),
|
jobId: z.string().uuid(),
|
||||||
proId: z.string().uuid(),
|
proId: z.string().uuid(),
|
||||||
|
|||||||
Reference in New Issue
Block a user