The homepage was a marketing brochure -- a hero paragraph, a "three steps" explainer and a tag list. It described the gesture in prose while the actual Tinder deck sat behind /deck/[jobId], reachable only after signing in AND posting a job. Nobody opening the app ever saw the product. Now / renders a phone illustration with the real app running inside it: the same <Deck>, the same drag physics, real verified pros. On a phone the bezel collapses and the deck simply fills the viewport -- drawing a picture of a phone on a phone is absurd, and it would eat the width the cards need. - Removed the fixed app bar and bottom tab bar. AppShell now renders the screen title as an in-flow h1; the bar owned the only h1 on every screen, so dropping it silently would have left every page headingless. The /jobs "post" action moved from bar chrome into the content, since the tab bar was its only other route there. - getShowcaseDeck(): a deck with no job behind it. getDeck is job-scoped (joins jobs for category and location, anti-joins swipes), which an anonymous visitor has none of, so this centres on the launch city. Eligibility rules are copied verbatim -- nobody may appear in the shop window who could not appear on a real deck. - deck.showcase: the only public procedure in the router. list and swipe stay behind clientProcedure. It reads nothing about the caller and writes nothing, so a right swipe on the entry screen is purely local. No real tradesperson is contacted until a job is posted. - <Deck> filled a hardcoded 560px desktop box; it now fills its container. The card counter moved out from between the two action buttons so the thumb zone holds nothing but the two controls. Verified against the seeded database: 22 eligible pros returned, and all three seeded traps excluded for the right reason -- Pau Ribas (22km out, 5km radius), Unverified Ulla (pending), Away Arnau (not accepting). 7 new integration tests cover exactly that. Also corrects a label I had written as "Plumbers near you" -- the showcase deck is not category-filtered and shows every trade. Includes concurrent edits to the mobile shell, ui/ primitives and DESIGN.md made outside this session. typecheck, lint, build clean; 141 tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
431 lines
13 KiB
TypeScript
431 lines
13 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import { Check, Loader2, Upload } from 'lucide-react';
|
|
import type { RouterOutputs } from '@/lib/trpc';
|
|
import { api } from '@/lib/trpc';
|
|
import {
|
|
DEFAULT_SERVICE_RADIUS_M,
|
|
MAX_SERVICE_RADIUS_M,
|
|
MIN_SERVICE_RADIUS_M,
|
|
} from '@linkder/shared';
|
|
import { cn } from '@/lib/utils';
|
|
import { uploadFile } from '@/lib/upload';
|
|
import {
|
|
Button,
|
|
Chip,
|
|
Field,
|
|
FieldNote,
|
|
FieldSet,
|
|
FormError,
|
|
Input,
|
|
Section,
|
|
StickyAction,
|
|
Textarea,
|
|
} from '@/components/ui';
|
|
|
|
type Categories = RouterOutputs['job']['categories'];
|
|
type Profile = RouterOutputs['pro']['me'];
|
|
|
|
const CITY = {
|
|
lat: Number(process.env.NEXT_PUBLIC_CITY_LAT ?? 41.3874),
|
|
lng: Number(process.env.NEXT_PUBLIC_CITY_LNG ?? 2.1686),
|
|
};
|
|
|
|
const STEPS = ['Trade', 'About you', 'Photos', 'Documents'] as const;
|
|
|
|
export function OnboardingWizard({
|
|
categories,
|
|
initialProfile,
|
|
hasContactableEmail,
|
|
}: {
|
|
categories: Categories;
|
|
initialProfile: Profile;
|
|
hasContactableEmail: boolean;
|
|
}) {
|
|
const router = useRouter();
|
|
const [step, setStep] = useState(0);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const [categoryIds, setCategoryIds] = useState<string[]>(initialProfile?.categoryIds ?? []);
|
|
const [headline, setHeadline] = useState(initialProfile?.headline ?? '');
|
|
const [bio, setBio] = useState(initialProfile?.bio ?? '');
|
|
const [hourlyRate, setHourlyRate] = useState(
|
|
initialProfile ? String(initialProfile.hourlyRateCents / 100) : '',
|
|
);
|
|
const [yearsExperience, setYearsExperience] = useState(
|
|
String(initialProfile?.yearsExperience ?? ''),
|
|
);
|
|
const [radiusKm, setRadiusKm] = useState(
|
|
(initialProfile?.serviceRadiusM ?? DEFAULT_SERVICE_RADIUS_M) / 1000,
|
|
);
|
|
const [location, setLocation] = useState(initialProfile?.baseLocation ?? CITY);
|
|
const [email, setEmail] = useState('');
|
|
|
|
const utils = api.useUtils();
|
|
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 submit = api.pro.submitForReview.useMutation({
|
|
onSuccess: () => router.push('/pro'),
|
|
onError: (e) => setError(e.message),
|
|
});
|
|
|
|
const media = initialProfile?.media ?? [];
|
|
const credentials = initialProfile?.credentials ?? [];
|
|
|
|
async function saveProfile() {
|
|
setError(null);
|
|
const rateCents = Math.round(Number(hourlyRate) * 100);
|
|
if (!Number.isFinite(rateCents) || rateCents <= 0) {
|
|
setError('Enter your hourly rate.');
|
|
return false;
|
|
}
|
|
try {
|
|
await upsert.mutateAsync({
|
|
headline,
|
|
bio,
|
|
hourlyRateCents: rateCents,
|
|
yearsExperience: Number(yearsExperience) || 0,
|
|
categoryIds,
|
|
location,
|
|
serviceRadiusM: Math.round(radiusKm * 1000),
|
|
});
|
|
await utils.pro.me.invalidate();
|
|
return true;
|
|
} catch (e) {
|
|
setError((e as Error).message);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<ol className="mb-10 flex gap-2" aria-label="Progress">
|
|
{STEPS.map((label, i) => (
|
|
<li key={label} className="flex-1">
|
|
<div
|
|
className={cn(
|
|
'h-1 rounded-pill transition-colors duration-[200ms] ease-standard',
|
|
i <= step ? 'bg-brand-500' : 'bg-inset',
|
|
)}
|
|
/>
|
|
<span
|
|
className={cn(
|
|
'mt-2 block text-meta',
|
|
i === step ? 'font-semibold text-strong' : 'text-muted',
|
|
)}
|
|
aria-current={i === step ? 'step' : undefined}
|
|
>
|
|
{label}
|
|
</span>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
|
|
{step === 0 && (
|
|
<Section
|
|
title="What do you do?"
|
|
hint="Pick every trade you actually take jobs for — you will only be shown matching work."
|
|
>
|
|
<div className="flex flex-wrap gap-2">
|
|
{categories.map((c) => {
|
|
const selected = categoryIds.includes(c.id);
|
|
return (
|
|
<Chip
|
|
key={c.id}
|
|
selected={selected}
|
|
onClick={() =>
|
|
setCategoryIds((prev) =>
|
|
selected ? prev.filter((id) => id !== c.id) : [...prev, c.id].slice(0, 5),
|
|
)
|
|
}
|
|
>
|
|
{c.name}
|
|
</Chip>
|
|
);
|
|
})}
|
|
</div>
|
|
<Nav
|
|
onNext={() => {
|
|
if (categoryIds.length === 0) return setError('Pick at least one trade.');
|
|
setError(null);
|
|
setStep(1);
|
|
}}
|
|
/>
|
|
</Section>
|
|
)}
|
|
|
|
{step === 1 && (
|
|
<Section title="About you" hint="This is what a customer reads on your card.">
|
|
<Field label="Headline" hint="e.g. Emergency plumber, 15 years in Barcelona">
|
|
<Input
|
|
value={headline}
|
|
onChange={(e) => setHeadline(e.target.value)}
|
|
maxLength={100}
|
|
/>
|
|
</Field>
|
|
|
|
<Field label="About your work">
|
|
<Textarea
|
|
value={bio}
|
|
onChange={(e) => setBio(e.target.value)}
|
|
rows={5}
|
|
maxLength={2000}
|
|
/>
|
|
<FieldNote>{bio.length}/2000, minimum 30</FieldNote>
|
|
</Field>
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<Field label="Hourly rate (€)">
|
|
<Input
|
|
value={hourlyRate}
|
|
onChange={(e) => setHourlyRate(e.target.value.replace(/[^0-9.]/g, ''))}
|
|
inputMode="decimal"
|
|
/>
|
|
</Field>
|
|
<Field label="Years experience">
|
|
<Input
|
|
value={yearsExperience}
|
|
onChange={(e) => setYearsExperience(e.target.value.replace(/\D/g, ''))}
|
|
inputMode="numeric"
|
|
/>
|
|
</Field>
|
|
</div>
|
|
|
|
<Field
|
|
label={`How far will you travel? ${radiusKm} km`}
|
|
hint="You will only be shown jobs inside this radius."
|
|
>
|
|
<input
|
|
type="range"
|
|
min={MIN_SERVICE_RADIUS_M / 1000}
|
|
max={MAX_SERVICE_RADIUS_M / 1000}
|
|
value={radiusKm}
|
|
onChange={(e) => setRadiusKm(Number(e.target.value))}
|
|
className="w-full accent-brand-500"
|
|
/>
|
|
</Field>
|
|
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="self-start"
|
|
onClick={() =>
|
|
navigator.geolocation?.getCurrentPosition(
|
|
(pos) => setLocation({ lat: pos.coords.latitude, lng: pos.coords.longitude }),
|
|
() => setError('We could not get your location. The city centre will be used.'),
|
|
)
|
|
}
|
|
>
|
|
Use my current location as my base
|
|
</Button>
|
|
|
|
<Nav
|
|
onBack={() => setStep(0)}
|
|
busy={upsert.isPending}
|
|
onNext={async () => {
|
|
if (await saveProfile()) setStep(2);
|
|
}}
|
|
/>
|
|
</Section>
|
|
)}
|
|
|
|
{step === 2 && (
|
|
<Section
|
|
title="Photos"
|
|
hint="A face and a few examples of your work. This is the single biggest thing customers judge you on."
|
|
>
|
|
<UploadList
|
|
kind="pro_photo"
|
|
existing={media.map((m) => m.url)}
|
|
onUploaded={async (url) => {
|
|
await addMedia.mutateAsync({ url });
|
|
await utils.pro.me.invalidate();
|
|
router.refresh();
|
|
}}
|
|
onError={setError}
|
|
/>
|
|
<Nav onBack={() => setStep(1)} onNext={() => setStep(3)} />
|
|
</Section>
|
|
)}
|
|
|
|
{step === 3 && (
|
|
<Section
|
|
title="Documents"
|
|
hint="We check these by hand before your profile goes live. They are never shown to customers."
|
|
>
|
|
{!hasContactableEmail && (
|
|
<div className="flex flex-col gap-2">
|
|
<Field
|
|
label="Email address"
|
|
hint="Required for pros — payout statements, tax records and dispute notices go here."
|
|
>
|
|
<Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
|
|
</Field>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="self-start"
|
|
onClick={async () => {
|
|
try {
|
|
await setEmailMutation.mutateAsync({ email });
|
|
setError(null);
|
|
router.refresh();
|
|
} catch (e) {
|
|
setError((e as Error).message);
|
|
}
|
|
}}
|
|
>
|
|
Save email
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{(['id', 'insurance', 'licence'] as const).map((kind) => (
|
|
<FieldSet
|
|
key={kind}
|
|
label={
|
|
kind === 'id'
|
|
? 'Photo ID (required)'
|
|
: kind === 'insurance'
|
|
? 'Public liability insurance (required)'
|
|
: 'Trade licence (if your trade needs one)'
|
|
}
|
|
>
|
|
<UploadList
|
|
kind="credential"
|
|
existing={credentials.filter((c) => c.kind === kind).map((c) => c.fileKey)}
|
|
onUploaded={async (url) => {
|
|
await addCredential.mutateAsync({ kind, fileKey: url });
|
|
await utils.pro.me.invalidate();
|
|
router.refresh();
|
|
}}
|
|
onError={setError}
|
|
/>
|
|
</FieldSet>
|
|
))}
|
|
|
|
<StickyAction>
|
|
<div className="flex gap-3">
|
|
<Button type="button" variant="outline" size="lg" onClick={() => setStep(2)}>
|
|
Back
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
size="lg"
|
|
className="flex-1"
|
|
busy={submit.isPending}
|
|
onClick={() => submit.mutate()}
|
|
>
|
|
Submit for review
|
|
</Button>
|
|
</div>
|
|
</StickyAction>
|
|
</Section>
|
|
)}
|
|
|
|
{error && (
|
|
<div className="mt-6">
|
|
<FormError>{error}</FormError>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Nav({
|
|
onBack,
|
|
onNext,
|
|
busy,
|
|
}: {
|
|
onBack?: () => void;
|
|
onNext: () => void | Promise<void>;
|
|
busy?: boolean;
|
|
}) {
|
|
return (
|
|
<StickyAction>
|
|
<div className="flex gap-3">
|
|
{onBack && (
|
|
<Button type="button" variant="outline" size="lg" onClick={onBack}>
|
|
Back
|
|
</Button>
|
|
)}
|
|
<Button type="button" size="lg" className="flex-1" busy={busy} onClick={() => void onNext()}>
|
|
Continue
|
|
</Button>
|
|
</div>
|
|
</StickyAction>
|
|
);
|
|
}
|
|
|
|
function UploadList({
|
|
kind,
|
|
existing,
|
|
onUploaded,
|
|
onError,
|
|
}: {
|
|
kind: 'pro_photo' | 'credential';
|
|
existing: string[];
|
|
onUploaded: (url: string) => Promise<void>;
|
|
onError: (message: string) => void;
|
|
}) {
|
|
const [busy, setBusy] = useState(false);
|
|
const presign = api.upload.presign.useMutation();
|
|
|
|
return (
|
|
<div className="flex flex-col gap-3">
|
|
{existing.length > 0 && (
|
|
<ul className="flex flex-wrap gap-2">
|
|
{existing.map((url) => (
|
|
<li
|
|
key={url}
|
|
className="flex items-center gap-1.5 rounded-pill border border-go-100 bg-go-50 px-3 py-1.5 text-meta text-go-700"
|
|
>
|
|
<Check className="h-4 w-4" aria-hidden />
|
|
Uploaded
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
<label
|
|
className={cn(
|
|
'flex cursor-pointer items-center justify-center gap-2 rounded-card border border-dashed',
|
|
'border-hairline px-4 py-8 text-body-sm text-muted',
|
|
'transition-colors duration-[120ms] ease-standard hover:border-brand-500 hover:text-strong',
|
|
)}
|
|
>
|
|
{busy ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
|
|
) : (
|
|
<Upload className="h-4 w-4" aria-hidden />
|
|
)}
|
|
{busy ? 'Uploading…' : 'Choose a file'}
|
|
<input
|
|
type="file"
|
|
className="sr-only"
|
|
accept={kind === 'credential' ? 'image/*,application/pdf' : 'image/*'}
|
|
onChange={async (event) => {
|
|
const file = event.target.files?.[0];
|
|
if (!file) return;
|
|
setBusy(true);
|
|
try {
|
|
const url = await uploadFile(file, kind, (input) => presign.mutateAsync(input));
|
|
await onUploaded(url);
|
|
} catch (e) {
|
|
onError((e as Error).message);
|
|
} finally {
|
|
setBusy(false);
|
|
event.target.value = '';
|
|
}
|
|
}}
|
|
/>
|
|
</label>
|
|
</div>
|
|
);
|
|
}
|