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:
@@ -1,6 +1,8 @@
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { Wix_Madefor_Display, Wix_Madefor_Text } from 'next/font/google';
|
||||
import { TRPCProvider } from '@/lib/trpc';
|
||||
import { ToastProvider } from '@/components/ui';
|
||||
import { PhoneFrame } from '@/components/chrome/phone-frame';
|
||||
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
|
||||
*/}
|
||||
<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>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -28,7 +28,7 @@ export default async function OnboardingPage() {
|
||||
if (me.role === 'pro') redirect(me.hasProProfile ? '/pro' : '/pro/onboarding');
|
||||
|
||||
return (
|
||||
<BareShell>
|
||||
<BareShell back>
|
||||
<h1 className="text-h1">What brings you here?</h1>
|
||||
<p className="mt-3 text-body text-muted">
|
||||
You can only pick once, so choose the one that fits.
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { getApi } from '@/server/caller';
|
||||
import { PhoneFrame } from '@/components/chrome/phone-frame';
|
||||
import { ShowcaseDeck } from './showcase-deck';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
@@ -18,13 +17,15 @@ export const dynamic = 'force-dynamic';
|
||||
*/
|
||||
export default async function Home() {
|
||||
const api = await getApi();
|
||||
const { cards } = await api.deck.showcase();
|
||||
const [{ cards }, categories] = await Promise.all([
|
||||
api.deck.showcase(),
|
||||
api.job.categories(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-dvh items-center justify-center sm:p-8">
|
||||
<PhoneFrame>
|
||||
<ShowcaseDeck cards={cards} />
|
||||
</PhoneFrame>
|
||||
</div>
|
||||
<ShowcaseDeck
|
||||
categories={categories.map((c) => ({ id: c.id, name: c.name }))}
|
||||
initialCards={cards}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ export function OnboardingWizard({
|
||||
const upsert = api.pro.upsertProfile.useMutation();
|
||||
const addMedia = api.pro.addMedia.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({
|
||||
onSuccess: () => router.push('/pro'),
|
||||
onError: (e) => setError(e.message),
|
||||
@@ -276,12 +276,14 @@ export function OnboardingWizard({
|
||||
await setEmailMutation.mutateAsync({ email });
|
||||
setError(null);
|
||||
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) {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Save email
|
||||
{setEmailMutation.isSuccess ? "Check your inbox" : "Send confirmation"}
|
||||
</Button>
|
||||
</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 Link from 'next/link';
|
||||
import { X } from 'lucide-react';
|
||||
import type { DeckCard } from '@linkder/db';
|
||||
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
|
||||
* writes NOTHING. A visitor with no session swiping right must not send a job
|
||||
* to a real tradesperson; the card simply leaves. The funnel starts when they
|
||||
* tap "Post a job", which is where a real deck (deck.list / deck.swipe, both
|
||||
* authenticated) takes over.
|
||||
* The trade strip exists because a deck of every trade is useless to someone
|
||||
* with a leaking sink — a dating app can show you anyone, a trades app cannot.
|
||||
* Until a trade is picked the deck shows everyone, so the screen is never empty
|
||||
* and the first swipe costs no taps.
|
||||
*
|
||||
* `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[] }) {
|
||||
const [seen, setSeen] = useState(0);
|
||||
const done = seen >= cards.length;
|
||||
export function ShowcaseDeck({
|
||||
categories,
|
||||
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 (
|
||||
<div className="flex h-full flex-col px-4 pb-4 pt-[3.25rem]">
|
||||
<header className="mb-3 flex shrink-0 items-baseline justify-between">
|
||||
<p className="text-h4">Verified pros near you</p>
|
||||
{!done && cards.length > 0 && (
|
||||
<p className="text-meta tabular-nums text-muted">{cards.length - seen} left</p>
|
||||
<div className="flex h-full w-full min-w-0 flex-col overflow-hidden">
|
||||
{tab === 'settings' ? (
|
||||
<SettingsPanel />
|
||||
) : tab === 'profile' ? (
|
||||
<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>
|
||||
|
||||
<Link
|
||||
href="/jobs/new"
|
||||
className={buttonClasses({ variant: 'primary', size: 'lg', block: true })}
|
||||
>
|
||||
Post a job
|
||||
</Link>
|
||||
{/* The deck owns everything left over. min-h-0 so it can actually shrink. */}
|
||||
<div className="mt-3 min-h-0 flex-1 px-4">
|
||||
{isFetching && cards.length === 0 ? (
|
||||
<div className="h-full rounded-deck bg-inset" aria-busy />
|
||||
) : (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ export const metadata = { title: 'Sign in' };
|
||||
|
||||
export default function SignInPage() {
|
||||
return (
|
||||
<BareShell>
|
||||
<BareShell back>
|
||||
<p className="text-overline uppercase text-accent">Linkder</p>
|
||||
<h1 className="mt-3 text-h1">Sign in</h1>
|
||||
<p className="mt-3 text-body text-muted">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { Button, Field, FormError, Input } from '@/components/ui';
|
||||
import { GoogleButton } from '@/components/auth/google-button';
|
||||
|
||||
type Step = 'phone' | 'code';
|
||||
|
||||
@@ -121,15 +122,7 @@ export function SignInForm() {
|
||||
<span className="h-px flex-1 bg-hairline" />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
block
|
||||
onClick={() => authClient.signIn.social({ provider: 'google', callbackURL: next })}
|
||||
>
|
||||
Continue with Google
|
||||
</Button>
|
||||
<GoogleButton callbackURL={next} />
|
||||
|
||||
<p className="text-meta text-muted">
|
||||
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 { BackLink } from './back-link';
|
||||
|
||||
/**
|
||||
* DESIGN.md §4. The one screen wrapper.
|
||||
@@ -22,7 +23,7 @@ export function AppShell({
|
||||
return (
|
||||
<main
|
||||
className={cn(
|
||||
'mx-auto min-h-dvh max-w-app bg-page',
|
||||
'h-full overflow-y-auto bg-page',
|
||||
'px-5 pt-8 pb-[calc(2rem+env(safe-area-inset-bottom))]',
|
||||
className,
|
||||
)}
|
||||
@@ -37,11 +38,28 @@ export function AppShell({
|
||||
* A screen with no chrome and no heading — sign in, role choice, the entry
|
||||
* screen. Vertically centred, because these are single-decision screens with
|
||||
* little on them.
|
||||
*
|
||||
* `back` puts a link home in the top-left corner. It is absolutely positioned so
|
||||
* that adding it does not push the centred content off centre — these screens
|
||||
* are composed around the middle of the viewport, not around the top.
|
||||
*/
|
||||
export function BareShell({ children }: { children: React.ReactNode }) {
|
||||
return <main className="mx-auto flex min-h-dvh max-w-app flex-col justify-center bg-page px-5 py-10">
|
||||
export function BareShell({
|
||||
back = false,
|
||||
children,
|
||||
}: {
|
||||
back?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<main className="relative flex h-full flex-col justify-center overflow-y-auto bg-page px-5 py-10">
|
||||
{back && (
|
||||
<div className="absolute left-5 top-[calc(0.5rem+env(safe-area-inset-top))] z-10">
|
||||
<BackLink />
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</main>;
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,7 +68,7 @@ export function BareShell({ children }: { children: React.ReactNode }) {
|
||||
*/
|
||||
export function DeckShell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<main className="mx-auto flex min-h-dvh max-w-app flex-col bg-page px-5 pt-6 pb-[calc(1.5rem+env(safe-area-inset-bottom))]">
|
||||
<main className="flex h-full flex-col bg-page px-5 pt-6 pb-[calc(1.5rem+env(safe-area-inset-bottom))]">
|
||||
{children}
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import Link from 'next/link';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* The way out of a dead end.
|
||||
*
|
||||
* DESIGN.md §6.6 hangs a back chevron off the app bar, but there is no app bar
|
||||
* here — so on the bare screens it sits in the top-left corner of the screen
|
||||
* instead, clear of the vertically centred content and of the Dynamic Island.
|
||||
*
|
||||
* A real <Link>, not `router.back()`: someone who landed on sign-in from a
|
||||
* bookmark or an expired-session redirect has no history to go back to, and a
|
||||
* button that sometimes does nothing is worse than no button.
|
||||
*/
|
||||
export function BackLink({
|
||||
href = '/',
|
||||
label = 'Home',
|
||||
className,
|
||||
}: {
|
||||
href?: string;
|
||||
label?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
// -ml-2 pulls the chevron out to the gutter so the label lines up with
|
||||
// the screen text, while the tap target keeps its 44px. §6.1
|
||||
'-ml-2 inline-flex h-11 items-center gap-0.5 rounded-pill pl-1 pr-3',
|
||||
'font-display text-body-sm font-semibold text-muted',
|
||||
'transition-colors duration-[120ms] ease-standard hover:text-strong',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5" aria-hidden />
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -22,10 +22,14 @@ export function PhoneFrame({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
// Phone: no frame at all, app fills the screen.
|
||||
'h-dvh w-full',
|
||||
// Tablet and up: a 390x844 device, the iPhone 14 logical viewport.
|
||||
'sm:h-[844px] sm:w-[390px] sm:shrink-0',
|
||||
// Phone: no frame at all, app fills the screen. min-w-0 because this is
|
||||
// a flex item, and a flex item defaults to min-width:auto -- without it
|
||||
// the deck's intrinsic width stretches the frame past the viewport and
|
||||
// the card hangs off the right edge.
|
||||
'h-dvh w-full min-w-0',
|
||||
// Tablet and up: a 390x844 device, the iPhone 14 logical viewport —
|
||||
// capped at the window height so the bezel is never clipped on a laptop.
|
||||
'sm:h-[min(844px,92dvh)] sm:w-[390px] sm:shrink-0',
|
||||
// The bezel. ink-950 rather than pure black, per DESIGN.md §2.2.
|
||||
'sm:rounded-[3.25rem] sm:bg-ink-950 sm:p-[0.7rem] sm:shadow-lg',
|
||||
// A hairline of light along the top edge reads as a chamfer and stops
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
'use client';
|
||||
|
||||
import { Flame, Search, Layers, UserRound, Settings } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type PhoneTab = 'swipe' | 'search' | 'jobs' | 'profile' | 'settings';
|
||||
|
||||
const TABS: { id: PhoneTab; label: string; icon: typeof Flame }[] = [
|
||||
{ id: 'swipe', label: 'Swipe', icon: Flame },
|
||||
{ id: 'search', label: 'Search', icon: Search },
|
||||
{ id: 'jobs', label: 'Past jobs', icon: Layers },
|
||||
{ id: 'profile', label: 'Profile', icon: UserRound },
|
||||
{ id: 'settings', label: 'Settings', icon: Settings },
|
||||
];
|
||||
|
||||
/**
|
||||
* Bottom tab bar, Tinder-style: icon-only, evenly spaced, active tab in the
|
||||
* accent colour and inactive in grey. Lives inside the phone screen, not in the
|
||||
* page — this is app chrome.
|
||||
*/
|
||||
export function PhoneTabs({
|
||||
active,
|
||||
onChange,
|
||||
}: {
|
||||
active: PhoneTab;
|
||||
onChange: (tab: PhoneTab) => void;
|
||||
}) {
|
||||
return (
|
||||
<nav
|
||||
aria-label="Main"
|
||||
className="flex shrink-0 items-center justify-around border-t border-hairline bg-page px-2 pb-[calc(0.5rem+env(safe-area-inset-bottom))] pt-2"
|
||||
>
|
||||
{TABS.map(({ id, label, icon: Icon }) => {
|
||||
const isActive = id === active;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => onChange(id)}
|
||||
aria-label={label}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
className={cn(
|
||||
'flex h-11 w-11 items-center justify-center rounded-pill',
|
||||
'transition-colors duration-[120ms] ease-standard',
|
||||
isActive ? 'text-accent' : 'text-faint hover:text-muted',
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className="h-7 w-7"
|
||||
strokeWidth={isActive ? 2.5 : 2}
|
||||
fill={isActive && id === 'swipe' ? 'currentColor' : 'none'}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import Link from 'next/link';
|
||||
import { LogIn } from 'lucide-react';
|
||||
import { buttonClasses } from '@/components/ui';
|
||||
|
||||
/**
|
||||
* What a signed-in-only tab shows to an anonymous visitor.
|
||||
*
|
||||
* The tab stays tappable rather than being greyed out — a bar of dead icons on
|
||||
* first open reads as a broken app, whereas this explains what is behind it.
|
||||
*/
|
||||
export function SignedOut({ title, body }: { title: string; body: string }) {
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-4 px-8 text-center">
|
||||
<LogIn className="h-8 w-8 text-accent" aria-hidden />
|
||||
<div>
|
||||
<h1 className="text-h3">{title}</h1>
|
||||
<p className="mt-2 text-body-sm text-muted">{body}</p>
|
||||
</div>
|
||||
<Link href="/sign-in?next=/" className={buttonClasses({ variant: 'primary', size: 'md' })}>
|
||||
Continue with phone
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -66,13 +66,20 @@ export function Deck({ cards, onDecide }: DeckProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function Card({
|
||||
/**
|
||||
* One card.
|
||||
*
|
||||
* Exported so the profile screen can render a pro their OWN card, byte for byte
|
||||
* what a client sees. A lookalike would drift the moment either side changed.
|
||||
* Pass no `onDecide` to get a static, non-draggable card.
|
||||
*/
|
||||
export function Card({
|
||||
card,
|
||||
depth,
|
||||
depth = 0,
|
||||
onDecide,
|
||||
}: {
|
||||
card: DeckCard;
|
||||
depth: number;
|
||||
depth?: number;
|
||||
onDecide?: (proId: string, direction: 'left' | 'right') => void;
|
||||
}) {
|
||||
const x = useMotionValue(0);
|
||||
@@ -86,7 +93,7 @@ function Card({
|
||||
<motion.article
|
||||
className={cn(
|
||||
'deck-card absolute inset-0 overflow-hidden rounded-3xl border shadow-xl',
|
||||
'border-[var(--border)] bg-[var(--card)]',
|
||||
'border-hairline bg-raised',
|
||||
interactive ? 'cursor-grab active:cursor-grabbing' : 'pointer-events-none',
|
||||
)}
|
||||
style={{ x, rotate, zIndex: 10 - depth }}
|
||||
@@ -119,7 +126,7 @@ function Card({
|
||||
<>
|
||||
<motion.div
|
||||
style={{ opacity: hireOpacity }}
|
||||
className="absolute left-6 top-6 rotate-[-12deg] rounded-lg border-4 border-[var(--color-go-500)] px-3 py-1 text-2xl font-black tracking-wide text-[var(--color-go-500)]"
|
||||
className="absolute left-6 top-6 rotate-[-12deg] rounded-lg border-4 border-[var(--color-go-600)] px-3 py-1 text-2xl font-black tracking-wide text-[var(--color-go-600)]"
|
||||
>
|
||||
SEND JOB
|
||||
</motion.div>
|
||||
@@ -134,7 +141,13 @@ function Card({
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 p-6 text-white">
|
||||
<div className="mb-1 flex items-baseline gap-2">
|
||||
<h2 className="text-2xl font-semibold">{card.name}</h2>
|
||||
{/*
|
||||
text-white must be on the h2 itself, not inherited from the wrapper:
|
||||
globals.css sets `h1..h6 { color: var(--text-strong) }` in @layer
|
||||
base, and that rule beats the parent's colour. Without this the name
|
||||
renders ink-950 navy on a dark photo and is unreadable.
|
||||
*/}
|
||||
<h2 className="text-2xl font-semibold text-white">{card.name}</h2>
|
||||
{card.ratingCount > 0 ? (
|
||||
<span className="flex items-center gap-1 text-sm">
|
||||
<Star className="h-4 w-4 fill-current" aria-hidden />
|
||||
@@ -187,10 +200,10 @@ function ActionButton({
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={cn(
|
||||
'flex h-16 w-16 items-center justify-center rounded-full border-2 bg-[var(--card)] shadow-lg',
|
||||
'flex h-16 w-16 items-center justify-center rounded-full border-2 bg-raised shadow-lg',
|
||||
'transition hover:scale-105 active:scale-95',
|
||||
isHire
|
||||
? 'border-[var(--color-go-500)] text-[var(--color-go-500)]'
|
||||
? 'border-[var(--color-go-600)] text-[var(--color-go-600)]'
|
||||
: 'border-[var(--color-stop-500)] text-[var(--color-stop-500)]',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Plus, X } from 'lucide-react';
|
||||
import { MAX_SKILL_LENGTH, MAX_SKILLS } from '@linkder/shared';
|
||||
import { api } from '@/lib/trpc';
|
||||
import { Button, FormError, Input, SettingsGroup } from '@/components/ui';
|
||||
|
||||
/**
|
||||
* What this pro is actually good at, in their own words.
|
||||
*
|
||||
* Free text on purpose. The trade list is a closed set because matching and
|
||||
* licence checks run on it; this is the line underneath that separates two
|
||||
* plumbers who match the same job — "underfloor heating", "listed buildings",
|
||||
* "emergency callouts". Nothing matches on it, so nothing here can distort the
|
||||
* deck; it only has to read well.
|
||||
*/
|
||||
export function SkillsGroup({ skills }: { skills: string[] }) {
|
||||
const utils = api.useUtils();
|
||||
const [list, setList] = useState<string[]>(skills);
|
||||
const [draft, setDraft] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
|
||||
// Adopt the server's list until the pro starts editing — after that the
|
||||
// in-progress edit wins, or a refetch would wipe it mid-sentence.
|
||||
useEffect(() => {
|
||||
if (!dirty) setList(skills);
|
||||
}, [skills, dirty]);
|
||||
|
||||
const save = api.pro.updateSkills.useMutation({
|
||||
onSuccess: ({ skills: next }) => {
|
||||
setList(next);
|
||||
setDirty(false);
|
||||
void utils.pro.me.invalidate();
|
||||
},
|
||||
onError: (e) => setError(e.message),
|
||||
});
|
||||
|
||||
function add() {
|
||||
const value = draft.trim();
|
||||
if (!value) return;
|
||||
if (value.length < 2) return setError('That is too short to mean anything.');
|
||||
if (list.some((s) => s.toLocaleLowerCase() === value.toLocaleLowerCase())) {
|
||||
setDraft('');
|
||||
return setError('That one is already on the list.');
|
||||
}
|
||||
if (list.length >= MAX_SKILLS) {
|
||||
return setError(`${MAX_SKILLS} is the most a customer will read.`);
|
||||
}
|
||||
|
||||
setList([...list, value]);
|
||||
setDraft('');
|
||||
setError(null);
|
||||
setDirty(true);
|
||||
}
|
||||
|
||||
function remove(skill: string) {
|
||||
setList(list.filter((s) => s !== skill));
|
||||
setError(null);
|
||||
setDirty(true);
|
||||
}
|
||||
|
||||
const full = list.length >= MAX_SKILLS;
|
||||
|
||||
return (
|
||||
<SettingsGroup
|
||||
title="Skills"
|
||||
note="Shown to customers. Your trades decide which jobs reach you; these say what you are best at within them."
|
||||
>
|
||||
<div className="flex flex-col gap-3 px-4 py-4">
|
||||
{list.length > 0 ? (
|
||||
<ul className="flex flex-wrap gap-2">
|
||||
{list.map((skill) => (
|
||||
<li key={skill}>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-pill border border-hairline py-1.5 pl-3 pr-1.5 text-body-sm text-strong">
|
||||
{skill}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(skill)}
|
||||
aria-label={`Remove ${skill}`}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-pill text-faint transition-colors duration-[120ms] ease-standard hover:bg-sunken hover:text-strong"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" aria-hidden />
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-body-sm text-muted">
|
||||
Nothing yet. Two or three specific ones beat a long generic list.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/*
|
||||
Not a <form>: this sits inside the profile screen, and a nested form
|
||||
would submit the wrong thing the day that screen grows one.
|
||||
*/}
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={draft}
|
||||
onChange={(e) => {
|
||||
setDraft(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== 'Enter') return;
|
||||
e.preventDefault();
|
||||
add();
|
||||
}}
|
||||
maxLength={MAX_SKILL_LENGTH}
|
||||
placeholder="Boiler repair"
|
||||
aria-label="Add a skill"
|
||||
disabled={full}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="md"
|
||||
onClick={add}
|
||||
disabled={!draft.trim() || full}
|
||||
>
|
||||
<Plus className="h-4 w-4" aria-hidden />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-meta text-faint tabular-nums">
|
||||
{list.length}/{MAX_SKILLS}
|
||||
</p>
|
||||
|
||||
{error && <FormError>{error}</FormError>}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
size="md"
|
||||
block
|
||||
disabled={!dirty}
|
||||
busy={save.isPending}
|
||||
onClick={() => save.mutate({ skills: list })}
|
||||
>
|
||||
{dirty ? 'Save skills' : 'Saved'}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { LocateFixed } from 'lucide-react';
|
||||
import {
|
||||
DEFAULT_SERVICE_RADIUS_M,
|
||||
MAX_SERVICE_RADIUS_M,
|
||||
MIN_SERVICE_RADIUS_M,
|
||||
} from '@linkder/shared';
|
||||
import { api } from '@/lib/trpc';
|
||||
import { Button, FormError, Input, SettingsGroup } from '@/components/ui';
|
||||
|
||||
const CITY_NAME = process.env.NEXT_PUBLIC_CITY_NAME ?? 'the city centre';
|
||||
|
||||
/**
|
||||
* Where you are, and how far you will go.
|
||||
*
|
||||
* One group for both sides of the market, because it is one question — only the
|
||||
* words change. What it writes does not: a pro's answer is their service area
|
||||
* and lands on the pro profile the deck matches against, a customer's is a
|
||||
* search preference and lands on the user. The server picks; this only renders.
|
||||
*/
|
||||
export function LocationGroup({ isPro }: { isPro: boolean }) {
|
||||
const utils = api.useUtils();
|
||||
const saved = api.user.location.useQuery();
|
||||
|
||||
const [addressText, setAddressText] = useState('');
|
||||
const [radiusKm, setRadiusKm] = useState(DEFAULT_SERVICE_RADIUS_M / 1000);
|
||||
const [pin, setPin] = useState<{ lat: number; lng: number } | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [savedJustNow, setSavedJustNow] = useState(false);
|
||||
// Seeding the controls from the query would otherwise overwrite what someone
|
||||
// is halfway through typing, every time the query refetches.
|
||||
const [dirty, setDirty] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!saved.data || dirty) return;
|
||||
setAddressText(saved.data.addressText ?? '');
|
||||
setRadiusKm(Math.round(saved.data.radiusM / 1000));
|
||||
setPin(saved.data.location);
|
||||
}, [saved.data, dirty]);
|
||||
|
||||
const update = api.user.updateLocation.useMutation({
|
||||
onSuccess: () => {
|
||||
setDirty(false);
|
||||
setSavedJustNow(true);
|
||||
void utils.user.location.invalidate();
|
||||
// A customer's deck is filtered by this, so it cannot keep serving the
|
||||
// results of the old radius.
|
||||
void utils.deck.invalidate();
|
||||
},
|
||||
onError: (e) => setError(e.message),
|
||||
});
|
||||
|
||||
function edit<T>(set: (value: T) => void) {
|
||||
return (value: T) => {
|
||||
set(value);
|
||||
setDirty(true);
|
||||
setSavedJustNow(false);
|
||||
setError(null);
|
||||
};
|
||||
}
|
||||
|
||||
const title = isPro ? 'Where you work' : 'Where you are';
|
||||
|
||||
if (saved.isLoading) {
|
||||
return (
|
||||
<SettingsGroup title={title}>
|
||||
<div className="h-44 animate-pulse bg-inset" />
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
|
||||
// A pro who has not finished onboarding has no service area yet, and inventing
|
||||
// one here would be a second source of truth for the wizard to fight.
|
||||
if (saved.data?.needsProfile) {
|
||||
return (
|
||||
<SettingsGroup title={title} note="Finish your profile to set your base and radius.">
|
||||
<div className="px-4 py-3.5 text-body-sm text-muted">
|
||||
Your working area is part of your pro profile.
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsGroup
|
||||
title={title}
|
||||
note={
|
||||
saved.data?.reviewOnChange
|
||||
? 'Changing your base or radius sends your profile back for review.'
|
||||
: isPro
|
||||
? 'You are only shown jobs inside this radius.'
|
||||
: 'Your deck is centred here, and only shows pros inside this range.'
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4 px-4 py-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="flex flex-col gap-2">
|
||||
<span className="text-body-sm text-strong">
|
||||
{isPro ? 'Base address' : 'Your address'}
|
||||
</span>
|
||||
<Input
|
||||
value={addressText}
|
||||
onChange={(e) => edit(setAddressText)(e.target.value)}
|
||||
maxLength={255}
|
||||
placeholder={`Neighbourhood, ${CITY_NAME}`}
|
||||
/>
|
||||
</label>
|
||||
<p className="text-meta text-muted">
|
||||
{pin
|
||||
? `Pinned to ${pin.lat.toFixed(4)}, ${pin.lng.toFixed(4)}. `
|
||||
: `No pin yet — we measure from ${CITY_NAME}. `}
|
||||
{isPro
|
||||
? 'Matching uses the pin, never the text.'
|
||||
: 'Matching uses the pin; your address is only shared once you book.'}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="self-start"
|
||||
onClick={() =>
|
||||
navigator.geolocation?.getCurrentPosition(
|
||||
(pos) => edit(setPin)({ lat: pos.coords.latitude, lng: pos.coords.longitude }),
|
||||
() => setError('We could not get your location. Type an address instead.'),
|
||||
)
|
||||
}
|
||||
>
|
||||
<LocateFixed className="h-4 w-4" aria-hidden />
|
||||
Use my current location
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<label className="flex flex-col gap-2">
|
||||
<span className="text-body-sm text-strong">
|
||||
{isPro ? 'How far will you travel?' : 'How far will you look?'}{' '}
|
||||
<span className="text-muted tabular-nums">{radiusKm} km</span>
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min={MIN_SERVICE_RADIUS_M / 1000}
|
||||
max={MAX_SERVICE_RADIUS_M / 1000}
|
||||
value={radiusKm}
|
||||
onChange={(e) => edit(setRadiusKm)(Number(e.target.value))}
|
||||
className="w-full accent-brand-500"
|
||||
/>
|
||||
<span className="flex justify-between text-meta text-faint tabular-nums">
|
||||
<span>{MIN_SERVICE_RADIUS_M / 1000} km</span>
|
||||
<span>{MAX_SERVICE_RADIUS_M / 1000} km</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{error && <FormError>{error}</FormError>}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
size="md"
|
||||
block
|
||||
disabled={!dirty}
|
||||
busy={update.isPending}
|
||||
onClick={() =>
|
||||
update.mutate({
|
||||
addressText,
|
||||
radiusM: radiusKm * 1000,
|
||||
...(pin ? { location: pin } : {}),
|
||||
})
|
||||
}
|
||||
>
|
||||
{savedJustNow && !dirty ? 'Saved' : 'Save location'}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
@@ -4,3 +4,5 @@ export { Card, EmptyState, Stat } from './card';
|
||||
export { Chip, OptionCard, Tag } from './chip';
|
||||
export { Field, FieldNote, FieldSet, Input, Textarea } from './field';
|
||||
export { ScreenIntro, Section, StickyAction } from './page';
|
||||
export { SettingsGroup, SettingsRow, SettingsToggle } from './settings-row';
|
||||
export { ToastProvider, useToast, type ToastTone } from './toast';
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
'use client';
|
||||
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/** A titled group of rows. Settings is a list of lists. */
|
||||
export function SettingsGroup({
|
||||
title,
|
||||
note,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
note?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="mb-6">
|
||||
<h2 className="mb-2 px-1 text-overline uppercase text-faint">{title}</h2>
|
||||
<div className="divide-y divide-hairline overflow-hidden rounded-card border border-hairline bg-raised">
|
||||
{children}
|
||||
</div>
|
||||
{note && <p className="mt-2 px-1 text-meta text-muted">{note}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** A read-only or navigational row. */
|
||||
export function SettingsRow({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
onClick,
|
||||
danger,
|
||||
disabled,
|
||||
}: {
|
||||
label: string;
|
||||
value?: React.ReactNode;
|
||||
hint?: string;
|
||||
onClick?: () => void;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const interactive = Boolean(onClick) && !disabled;
|
||||
const Tag = interactive ? 'button' : 'div';
|
||||
|
||||
return (
|
||||
<Tag
|
||||
{...(interactive ? { type: 'button' as const, onClick } : {})}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 px-4 py-3.5 text-left',
|
||||
interactive && 'transition-colors duration-[120ms] ease-standard hover:bg-sunken',
|
||||
disabled && 'opacity-45',
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className={cn('block text-body-sm', danger ? 'text-stop-500' : 'text-strong')}>
|
||||
{label}
|
||||
</span>
|
||||
{hint && <span className="mt-0.5 block text-meta text-muted">{hint}</span>}
|
||||
</span>
|
||||
{value !== undefined && (
|
||||
<span className="shrink-0 text-body-sm text-muted">{value}</span>
|
||||
)}
|
||||
{interactive && <ChevronRight className="h-4 w-4 shrink-0 text-faint" aria-hidden />}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A row carrying a switch. The label is the control's accessible name, so the
|
||||
* whole row is one tap target rather than a label and a separate 20px switch.
|
||||
*/
|
||||
export function SettingsToggle({
|
||||
label,
|
||||
hint,
|
||||
checked,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
checked: boolean;
|
||||
onChange: (next: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 px-4 py-3.5 text-left',
|
||||
'transition-colors duration-[120ms] ease-standard hover:bg-sunken',
|
||||
disabled && 'pointer-events-none opacity-45',
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-body-sm text-strong">{label}</span>
|
||||
{hint && <span className="mt-0.5 block text-meta text-muted">{hint}</span>}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'relative h-[1.6rem] w-[2.75rem] shrink-0 rounded-pill',
|
||||
'transition-colors duration-[120ms] ease-standard',
|
||||
checked ? 'bg-brand-500' : 'bg-ink-300',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'absolute top-[0.2rem] h-[1.2rem] w-[1.2rem] rounded-pill bg-white shadow-sm',
|
||||
'transition-[left] duration-[120ms] ease-standard',
|
||||
checked ? 'left-[1.35rem]' : 'left-[0.2rem]',
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useCallback, useContext, useRef, useState } from 'react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { AlertTriangle, CheckCircle2, Info, X, XCircle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Transient messages.
|
||||
*
|
||||
* Deliberately NOT a second visual language: a toast is §6.5's banner on a
|
||||
* shadow, so a tone means the same thing wherever it appears. What it adds is
|
||||
* placement — bottom of the viewport rather than in the flow — for things the
|
||||
* user needs to be told but that do not belong to any one field.
|
||||
*
|
||||
* Use `<FormError>` for "this input is wrong" and a `<Banner>` for state a
|
||||
* screen is permanently in. A toast is for the third case: an outcome that has
|
||||
* no home on the screen, like a provider the server is not configured for.
|
||||
*/
|
||||
export type ToastTone = 'info' | 'success' | 'warning' | 'error';
|
||||
|
||||
export interface ToastOptions {
|
||||
tone?: ToastTone;
|
||||
title?: string;
|
||||
/** Milliseconds on screen. `null` keeps it up until dismissed. */
|
||||
duration?: number | null;
|
||||
}
|
||||
|
||||
interface Toast extends ToastOptions {
|
||||
id: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
type ToastFn = (message: string, options?: ToastOptions) => void;
|
||||
|
||||
const ToastContext = createContext<ToastFn | null>(null);
|
||||
|
||||
/**
|
||||
* Throws rather than no-oping when the provider is missing. A toast that
|
||||
* silently does nothing is worse than no toast — it looks like the click did
|
||||
* not register, and the bug only shows up in the case nobody tests.
|
||||
*/
|
||||
export function useToast(): ToastFn {
|
||||
const toast = useContext(ToastContext);
|
||||
if (!toast) throw new Error('useToast must be used inside <ToastProvider>');
|
||||
return toast;
|
||||
}
|
||||
|
||||
const DEFAULT_DURATION = 6000;
|
||||
|
||||
const TONE_SURFACE: Record<ToastTone, string> = {
|
||||
info: 'border-brand-200 bg-brand-100',
|
||||
success: 'border-go-100 bg-go-50',
|
||||
warning: 'border-sun-100 bg-sun-50',
|
||||
error: 'border-stop-100 bg-stop-50',
|
||||
};
|
||||
|
||||
const TONE_ICON = {
|
||||
info: Info,
|
||||
success: CheckCircle2,
|
||||
warning: AlertTriangle,
|
||||
error: XCircle,
|
||||
} as const;
|
||||
|
||||
const TONE_ICON_COLOR: Record<ToastTone, string> = {
|
||||
info: 'text-brand-500',
|
||||
success: 'text-go-600',
|
||||
warning: 'text-sun-500',
|
||||
error: 'text-stop-500',
|
||||
};
|
||||
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
const nextId = useRef(0);
|
||||
// Cleared on dismiss so a hand-dismissed toast does not leave a timer that
|
||||
// later removes whatever toast happens to be in its place.
|
||||
const timers = useRef(new Map<number, ReturnType<typeof setTimeout>>());
|
||||
|
||||
const dismiss = useCallback((id: number) => {
|
||||
const timer = timers.current.get(id);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timers.current.delete(id);
|
||||
}
|
||||
setToasts((current) => current.filter((t) => t.id !== id));
|
||||
}, []);
|
||||
|
||||
const toast = useCallback<ToastFn>(
|
||||
(message, options = {}) => {
|
||||
const id = nextId.current++;
|
||||
const duration = options.duration === undefined ? DEFAULT_DURATION : options.duration;
|
||||
// Three is the point where the stack starts covering the thing the user
|
||||
// was looking at; drop the oldest rather than growing upward forever.
|
||||
setToasts((current) => [...current, { ...options, id, message }].slice(-3));
|
||||
if (duration !== null) {
|
||||
timers.current.set(id, setTimeout(() => dismiss(id), duration));
|
||||
}
|
||||
},
|
||||
[dismiss],
|
||||
);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={toast}>
|
||||
{children}
|
||||
{/*
|
||||
The live region is mounted always, empty or not: a region inserted at
|
||||
the same moment as its content is not reliably announced.
|
||||
*/}
|
||||
<div
|
||||
aria-live="polite"
|
||||
// Bottom-centred and capped narrower than the app column so it reads as
|
||||
// an overlay on the phone screen rather than a full-width bar.
|
||||
className="pointer-events-none fixed inset-x-0 bottom-0 z-50 flex flex-col items-center gap-2 px-4 pb-[calc(1rem+env(safe-area-inset-bottom))]"
|
||||
>
|
||||
<AnimatePresence initial={false}>
|
||||
{toasts.map((t) => (
|
||||
<ToastCard key={t.id} toast={t} onDismiss={() => dismiss(t.id)} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function ToastCard({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }) {
|
||||
const tone = toast.tone ?? 'info';
|
||||
const Icon = TONE_ICON[tone];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
// §7 motion-base for enter/exit. The spring is decorative, so it collapses
|
||||
// to a plain fade under reduced motion.
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 12 }}
|
||||
transition={{ duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
|
||||
className={cn(
|
||||
'pointer-events-auto flex w-full max-w-[22rem] gap-3 rounded-card border p-4',
|
||||
'text-ink-950 shadow-lg',
|
||||
TONE_SURFACE[tone],
|
||||
)}
|
||||
>
|
||||
<Icon className={cn('mt-0.5 h-5 w-5 shrink-0', TONE_ICON_COLOR[tone])} aria-hidden />
|
||||
<div className="min-w-0 flex-1">
|
||||
{toast.title && <p className="font-display text-h4 text-ink-950">{toast.title}</p>}
|
||||
<p className={cn('text-body-sm text-ink-800', toast.title && 'mt-1')}>{toast.message}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
aria-label="Dismiss"
|
||||
className="-m-1 h-8 w-8 shrink-0 rounded-pill p-1 text-ink-600 transition-colors duration-[120ms] ease-standard hover:bg-ink-950/8 hover:text-ink-950"
|
||||
>
|
||||
<X className="h-full w-full" aria-hidden />
|
||||
</button>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { nextCookies } from 'better-auth/next-js';
|
||||
import { db, schema } from '@linkder/db';
|
||||
import { isE164 } from '@linkder/shared';
|
||||
import { sendVerificationSms } from '@/server/sms';
|
||||
import { isDevLoginPhone, pinDevLoginCode } from '@/server/dev-login';
|
||||
|
||||
/**
|
||||
* Authentication.
|
||||
@@ -53,6 +54,16 @@ const appUrl =
|
||||
})()
|
||||
: '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({
|
||||
// Passing `schema` explicitly (rather than letting the adapter read
|
||||
// db._.fullSchema) keeps it from forcing our lazy db Proxy open at module
|
||||
@@ -128,16 +139,32 @@ export const auth = betterAuth({
|
||||
|
||||
emailAndPassword: { enabled: false },
|
||||
|
||||
socialProviders: {
|
||||
google: {
|
||||
clientId: process.env.AUTH_GOOGLE_ID ?? '',
|
||||
clientSecret: process.env.AUTH_GOOGLE_SECRET ?? '',
|
||||
},
|
||||
},
|
||||
/**
|
||||
* Google is registered only when it can actually work.
|
||||
*
|
||||
* 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: [
|
||||
phoneNumber({
|
||||
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);
|
||||
},
|
||||
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;
|
||||
Reference in New Issue
Block a user