M2: the full job lifecycle — chat, hiring, geocoding, quotes, bookings, reviews
Closes the funnel. Before this the product could match two people and then stopped: `quotes`, `bookings` and `reviews` had tables and state machines and nothing that wrote a row, the entry deck's right swipe was wired to an empty handler, and every address resolved to the city centre. Jobs tab and chat - message router: thread, send, markRead, unreadTotal. A thread is a MATCH, not a job — one job with three interested pros is three private conversations. - Current/Past segments derived from ACTIVE_JOB_STATUSES, job detail listing the pros who accepted, and the conversation itself with attachments. Hiring from the deck - A right swipe on the entry deck opened nothing. It now resolves "which job?" through a sheet — sign in, pick an open job, or post one — and calls the same deck.swipe the per-job deck does, so the open-request cap and row lock apply exactly once. Swipes are vetoable so closing the sheet returns the card. Geocoding - ST_Distance and ST_DWithin rank and filter every deck, and both operands were placeholders. Addresses now resolve through Mapbox (permanent=true, which is what licenses storing the coordinates), the server resolves points rather than trusting client-supplied lat/lng, and every stored point records how it was obtained. A `city`-precision base cannot reach the verification queue. Quote -> booking -> review - The commercial chain, minus payments. Accepting a quote is the only place a booking is created; confirming completion is what unlocks reviews and moves the pro's completed_jobs. - Reviews publish double-blind with no sweeper: each is written with published_at already set to its embargo deadline and every read filters published_at <= now(), so it publishes itself. The second review pulls both forward. A silent counterparty cannot bury a bad review by never replying. State machine changes, both deliberate - booked -> matched: a cancelled booking is not a cancelled job. - scheduled -> awaiting_confirmation: in_progress is optional, so a pro who never tapped Start can still say the work is done. Test suite - api tests ran files in parallel against one database and failed roughly one run in three on whichever file lost the race. Serialised, and three fixtures that grabbed "the first client" pinned to the seeded accounts. Also includes work from a parallel session: admin verification queue, pro public profile and reviews read path, notification sending, denormalised stats recompute, search, and observability. 318 tests passing; typecheck and lint clean across 7 packages. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { api } from '@/lib/trpc';
|
||||
import { Button, Field, FormError, Textarea, useToast } from '@/components/ui';
|
||||
|
||||
/**
|
||||
* Approve, reject, suspend.
|
||||
*
|
||||
* The only place in the product where one person's decision makes another
|
||||
* person visible to customers, so nothing here is a one-tap action: approving
|
||||
* asks for confirmation, and rejecting refuses to proceed without a reason —
|
||||
* the server enforces that too, because a rejection the pro cannot act on
|
||||
* becomes a support ticket instead of a fixed profile.
|
||||
*/
|
||||
export function DecisionPanel({
|
||||
proId,
|
||||
status,
|
||||
banned,
|
||||
}: {
|
||||
proId: string;
|
||||
status: string;
|
||||
banned: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
const [notes, setNotes] = useState('');
|
||||
const [reason, setReason] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const onError = (e: { message: string }) => setError(e.message);
|
||||
const done = (message: string) => {
|
||||
setError(null);
|
||||
toast(message);
|
||||
// The queue, the badge counts and this page all move together.
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
const decide = api.admin.decide.useMutation({
|
||||
onSuccess: (r) => done(r.status === 'verified' ? 'Approved — they are live.' : 'Rejected.'),
|
||||
onError,
|
||||
});
|
||||
const suspend = api.admin.suspend.useMutation({
|
||||
onSuccess: () => done('Suspended. They are off every surface.'),
|
||||
onError,
|
||||
});
|
||||
const unsuspend = api.admin.unsuspend.useMutation({
|
||||
onSuccess: () => done('Reinstated.'),
|
||||
onError,
|
||||
});
|
||||
|
||||
const busy = decide.isPending || suspend.isPending || unsuspend.isPending;
|
||||
const canDecide = status === 'pending';
|
||||
|
||||
return (
|
||||
<div className="rounded-card border border-hairline bg-raised p-5">
|
||||
<h2 className="text-h4">Decision</h2>
|
||||
|
||||
{canDecide ? (
|
||||
<>
|
||||
<Field
|
||||
label="Notes"
|
||||
hint="Required to reject — the pro reads this and has to be able to act on it."
|
||||
>
|
||||
<Textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
rows={3}
|
||||
maxLength={1000}
|
||||
placeholder="Insurance certificate expired in March."
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
<Button
|
||||
size="lg"
|
||||
block
|
||||
busy={decide.isPending}
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
if (!confirm('Approve this pro? They go live to customers immediately.')) return;
|
||||
decide.mutate({ proId, decision: 'verified', notes: notes || undefined });
|
||||
}}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
block
|
||||
busy={decide.isPending}
|
||||
disabled={busy || notes.trim().length === 0}
|
||||
onClick={() => decide.mutate({ proId, decision: 'rejected', notes })}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="mt-2 text-body-sm text-muted">
|
||||
{/* The transition graph is the authority, so the UI says the same
|
||||
thing rather than offering a button the server will refuse. */}
|
||||
This profile is <strong>{status}</strong>. Only a pending profile can be approved or
|
||||
rejected; a live pro is taken down with Suspend instead.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<hr className="my-5 border-hairline" />
|
||||
|
||||
{banned ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="md"
|
||||
block
|
||||
busy={unsuspend.isPending}
|
||||
disabled={busy}
|
||||
onClick={() => unsuspend.mutate({ proId })}
|
||||
>
|
||||
Lift suspension
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Field label="Suspension reason">
|
||||
<Textarea
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
rows={2}
|
||||
maxLength={1000}
|
||||
placeholder="Insurance lapsed — off the deck until renewed."
|
||||
/>
|
||||
</Field>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="md"
|
||||
block
|
||||
className="mt-3"
|
||||
busy={suspend.isPending}
|
||||
disabled={busy || reason.trim().length === 0}
|
||||
onClick={() => {
|
||||
if (!confirm('Suspend this pro? They disappear from every surface at once.')) return;
|
||||
suspend.mutate({ proId, reason });
|
||||
}}
|
||||
>
|
||||
Suspend
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mt-4">
|
||||
<FormError>{error}</FormError>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { AlertTriangle, ChevronLeft, ExternalLink } from 'lucide-react';
|
||||
import { getApi } from '@/server/caller';
|
||||
import { Banner, Tag } from '@/components/ui';
|
||||
import { formatRelativeTime } from '@/lib/utils';
|
||||
import { DecisionPanel } from './decision-panel';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* One pro, and everything a reviewer needs to decide about them.
|
||||
*
|
||||
* The documents are the point. They are private R2 objects with no public URL,
|
||||
* resolved by `admin.proDetail` into signed links that expire in minutes — so
|
||||
* this page is readable by the person looking at it and not by anyone they
|
||||
* forward it to. The object keys never reach the browser.
|
||||
*/
|
||||
export default async function AdminProPage({ params }: { params: Promise<{ proId: string }> }) {
|
||||
const { proId } = await params;
|
||||
const api = await getApi();
|
||||
|
||||
let pro: Awaited<ReturnType<typeof api.admin.proDetail>>;
|
||||
try {
|
||||
pro = await api.admin.proDetail({ proId });
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const suspended = Boolean(pro.banned);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Link
|
||||
href="/admin"
|
||||
className="-ml-2 mb-4 inline-flex h-11 items-center gap-1 rounded-pill pl-1 pr-3 text-body-sm text-accent"
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5" aria-hidden />
|
||||
Queue
|
||||
</Link>
|
||||
|
||||
<h1 className="text-h1">{pro.name ?? 'Unnamed'}</h1>
|
||||
<p className="mt-1 text-body-sm text-muted">{pro.profile.headline}</p>
|
||||
|
||||
{suspended && (
|
||||
<Banner tone="error" title="Suspended" className="mt-5">
|
||||
{pro.profile.suspendedReason ?? 'No reason recorded.'}
|
||||
{pro.banExpires && ` Expires ${formatRelativeTime(pro.banExpires)}.`}
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
{pro.missing.length > 0 && (
|
||||
<Banner tone="warning" title="Documents missing" className="mt-5">
|
||||
This profile has no {pro.missing.join(' and ')}. Approving it would put an unchecked
|
||||
tradesperson in front of customers.
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
<div className="mt-8 grid gap-8 md:grid-cols-[1fr_20rem]">
|
||||
<div className="min-w-0">
|
||||
<Section title="Documents">
|
||||
{pro.documents.length === 0 ? (
|
||||
<p className="text-body-sm text-muted">Nothing uploaded.</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{pro.documents.map((doc) => (
|
||||
<li
|
||||
key={doc.id}
|
||||
className="flex items-center justify-between gap-4 rounded-card border border-hairline bg-raised p-4"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block font-semibold capitalize text-strong">{doc.kind}</span>
|
||||
<span className="mt-0.5 block text-meta text-faint">
|
||||
{doc.issuer ?? 'No issuer given'}
|
||||
{doc.expiresAt && ` · expires ${formatRelativeTime(doc.expiresAt)}`}
|
||||
{` · ${doc.reviewStatus}`}
|
||||
</span>
|
||||
</span>
|
||||
{doc.url ? (
|
||||
<a
|
||||
href={doc.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex shrink-0 items-center gap-1.5 text-body-sm font-semibold text-accent hover:underline"
|
||||
>
|
||||
Open
|
||||
<ExternalLink className="h-4 w-4" aria-hidden />
|
||||
</a>
|
||||
) : (
|
||||
// Storage is not configured, or the object is gone. Say so
|
||||
// — a missing link must not read as a missing document.
|
||||
<span className="flex shrink-0 items-center gap-1.5 text-meta text-stop-600">
|
||||
<AlertTriangle className="h-4 w-4" aria-hidden />
|
||||
unavailable
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="Profile">
|
||||
<dl className="grid grid-cols-2 gap-x-6 gap-y-3 text-body-sm">
|
||||
<Row label="Status" value={pro.profile.verificationStatus} />
|
||||
<Row label="Accepting jobs" value={pro.profile.isAcceptingJobs ? 'Yes' : 'No'} />
|
||||
<Row label="Hourly rate" value={`€${(pro.profile.hourlyRateCents / 100).toFixed(0)}`} />
|
||||
<Row label="Experience" value={`${pro.profile.yearsExperience} years`} />
|
||||
<Row
|
||||
label="Service area"
|
||||
value={`${Math.round(pro.profile.serviceRadiusM / 1000)} km`}
|
||||
/>
|
||||
<Row label="Phone" value={pro.phone ?? '—'} />
|
||||
<Row label="Email" value={pro.email ?? '—'} />
|
||||
<Row
|
||||
label="Verified"
|
||||
value={pro.profile.verifiedAt ? formatRelativeTime(pro.profile.verifiedAt) : 'Never'}
|
||||
/>
|
||||
</dl>
|
||||
|
||||
<p className="mt-5 whitespace-pre-line text-body-sm text-strong">{pro.profile.bio}</p>
|
||||
|
||||
{pro.categories.length > 0 && (
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
{pro.categories.map((c) => (
|
||||
<Tag key={c}>{c}</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{pro.photos.length > 0 && (
|
||||
<Section title="Photos">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{pro.photos.map((url) => (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- remote pro media, no loader configured
|
||||
<img
|
||||
key={url}
|
||||
src={url}
|
||||
alt=""
|
||||
className="h-40 w-32 rounded-md object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{pro.history.length > 0 && (
|
||||
<Section title="History">
|
||||
<ul className="flex flex-col gap-2 text-body-sm">
|
||||
{pro.history.map((entry) => (
|
||||
<li key={entry.id} className="flex items-baseline justify-between gap-4">
|
||||
<span className="text-strong">{entry.action}</span>
|
||||
<span className="shrink-0 text-meta text-faint">
|
||||
{formatRelativeTime(entry.createdAt)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="md:sticky md:top-24 md:self-start">
|
||||
<DecisionPanel
|
||||
proId={pro.proId}
|
||||
status={pro.profile.verificationStatus}
|
||||
banned={suspended}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="mb-8">
|
||||
<h2 className="mb-3 text-h4">{title}</h2>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<dt className="text-meta text-faint">{label}</dt>
|
||||
<dd className="mt-0.5 text-strong">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { getApi } from '@/server/caller';
|
||||
|
||||
export const metadata = { title: 'Admin' };
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* The back office.
|
||||
*
|
||||
* Deliberately outside the phone. Every other route renders inside the bezel
|
||||
* that `PhoneFrame` draws in the root layout, because every other route is the
|
||||
* product — but this is a reviewer reading a passport scan next to an insurance
|
||||
* certificate, and 390px is the wrong tool for that job.
|
||||
*
|
||||
* It escapes with `fixed inset-0` rather than by splitting the app into two
|
||||
* root layouts. A route group with its own `<html>` would mean moving every
|
||||
* existing route into a sibling group to match, which is a large change to make
|
||||
* for one screen, and one that every other page would have to keep working
|
||||
* around forever.
|
||||
*
|
||||
* `notFound()` and not `redirect('/sign-in')` for the same reason
|
||||
* `adminProcedure` answers NOT_FOUND: a surface that redirects instead of
|
||||
* 404ing has confirmed it exists.
|
||||
*/
|
||||
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const api = await getApi();
|
||||
|
||||
let me: Awaited<ReturnType<typeof api.user.me>>;
|
||||
try {
|
||||
me = await api.user.me();
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
if (me.role !== 'admin') notFound();
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto bg-page">
|
||||
<header className="sticky top-0 z-10 border-b border-hairline bg-page/95 backdrop-blur-[12px]">
|
||||
<div className="mx-auto flex max-w-5xl items-center justify-between gap-4 px-6 py-4">
|
||||
<Link href="/admin" className="font-display text-h4 text-strong">
|
||||
Linkder admin
|
||||
</Link>
|
||||
<span className="text-meta text-faint">
|
||||
Signed in as {me.name ?? me.email ?? 'admin'}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-5xl px-6 py-8">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import Link from 'next/link';
|
||||
import { AlertTriangle, ChevronRight } from 'lucide-react';
|
||||
import { getApi } from '@/server/caller';
|
||||
import { EmptyState } from '@/components/ui';
|
||||
import { cn, formatRelativeTime } from '@/lib/utils';
|
||||
|
||||
export const metadata = { title: 'Review queue' };
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const STATUSES = ['pending', 'verified', 'rejected', 'suspended', 'draft'] as const;
|
||||
type Status = (typeof STATUSES)[number];
|
||||
|
||||
/**
|
||||
* The review queue.
|
||||
*
|
||||
* The whole reason this exists: `pro.submitForReview` could move a profile to
|
||||
* `pending` and nothing could move it on, so a tradesperson who finished
|
||||
* onboarding waited forever unless somebody edited the row by hand.
|
||||
*
|
||||
* Oldest first. The pro who has been waiting four days is the one who gives up
|
||||
* on us, and a newest-first queue is precisely the one that never reaches them.
|
||||
*/
|
||||
export default async function AdminQueuePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ status?: string }>;
|
||||
}) {
|
||||
const params = await searchParams;
|
||||
const status: Status = STATUSES.includes(params.status as Status)
|
||||
? (params.status as Status)
|
||||
: 'pending';
|
||||
|
||||
const api = await getApi();
|
||||
const [queue, counts] = await Promise.all([api.admin.queue({ status }), api.admin.counts()]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="text-h1">Verification</h1>
|
||||
<p className="mt-2 text-body-sm text-muted">
|
||||
Approving a pro is a statement about their licence and insurance. It is recorded against
|
||||
your account.
|
||||
</p>
|
||||
|
||||
<nav className="mt-6 flex flex-wrap gap-2" aria-label="Filter by status">
|
||||
{STATUSES.map((s) => (
|
||||
<Link
|
||||
key={s}
|
||||
href={`/admin?status=${s}`}
|
||||
aria-current={s === status ? 'page' : undefined}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 rounded-pill border px-4 py-2 text-body-sm',
|
||||
'transition-[color,background-color,border-color] duration-[120ms] ease-standard',
|
||||
s === status
|
||||
? 'border-brand-500 bg-brand-100 font-semibold text-ink-950'
|
||||
: 'border-hairline text-strong hover:border-brand-500',
|
||||
)}
|
||||
>
|
||||
<span className="capitalize">{s}</span>
|
||||
{/* Rendered even at zero: "pending 0" is information, and a missing
|
||||
number reads as still loading. */}
|
||||
<span className="tabular-nums text-faint">{counts[s] ?? 0}</span>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{queue.length === 0 ? (
|
||||
<EmptyState
|
||||
className="mt-8"
|
||||
title={status === 'pending' ? 'Nothing waiting' : `No ${status} pros`}
|
||||
body={
|
||||
status === 'pending'
|
||||
? 'Every pro who has submitted has been dealt with.'
|
||||
: 'Nobody is in this state right now.'
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ul className="mt-6 flex flex-col gap-3">
|
||||
{queue.map((pro) => (
|
||||
<li key={pro.proId}>
|
||||
<Link
|
||||
href={`/admin/${pro.proId}`}
|
||||
className={cn(
|
||||
'flex items-center justify-between gap-4 rounded-card border border-hairline',
|
||||
'bg-raised p-4 transition-[border-color] duration-[120ms] ease-standard',
|
||||
'hover:border-brand-500',
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block font-display text-h4 text-strong">
|
||||
{pro.name ?? 'Unnamed'}
|
||||
</span>
|
||||
<span className="mt-1 block truncate text-body-sm text-muted">
|
||||
{pro.categories.join(', ') || 'No trade selected'} · {pro.headline}
|
||||
</span>
|
||||
<span className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-meta text-faint tabular-nums">
|
||||
<span>waiting {formatRelativeTime(pro.submittedAt)}</span>
|
||||
<span aria-hidden>·</span>
|
||||
<span>{pro.photoCount} photos</span>
|
||||
{pro.banned && (
|
||||
<span className="font-semibold text-stop-600">suspended</span>
|
||||
)}
|
||||
</span>
|
||||
{/* The single most useful thing in the list: a profile
|
||||
missing its insurance certificate can be skipped here
|
||||
rather than opened, read and closed again. */}
|
||||
{pro.missing.length > 0 && (
|
||||
<span className="mt-2 flex items-center gap-1.5 text-meta font-semibold text-sun-600">
|
||||
<AlertTriangle className="h-3.5 w-3.5" aria-hidden />
|
||||
missing {pro.missing.join(' and ')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<ChevronRight className="h-5 w-5 shrink-0 text-accent" aria-hidden />
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
|
||||
import { appRouter, createContext } from '@linkder/api';
|
||||
import { db } from '@linkder/db';
|
||||
@@ -20,11 +21,20 @@ function handler(req: Request) {
|
||||
resolveSession,
|
||||
ip: req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? null,
|
||||
}),
|
||||
onError({ error, path }) {
|
||||
onError({ error, path, type }) {
|
||||
// Client errors are expected; server errors are ours and must be visible.
|
||||
if (error.code === 'INTERNAL_SERVER_ERROR') {
|
||||
console.error(`tRPC ${path ?? '<no path>'} failed:`, error.cause ?? error);
|
||||
}
|
||||
// Reporting BAD_REQUEST or UNAUTHORIZED to Bugsink would bury the real
|
||||
// failures under a stream of ordinary validation and sign-in noise.
|
||||
if (error.code !== 'INTERNAL_SERVER_ERROR') return;
|
||||
|
||||
console.error(`tRPC ${path ?? '<no path>'} failed:`, error.cause ?? error);
|
||||
|
||||
Sentry.captureException(error.cause ?? error, {
|
||||
tags: { trpcPath: path ?? 'unknown', trpcType: type },
|
||||
// NOT the input: a procedure's input is where phone numbers and OTP
|
||||
// codes live. The path plus the stack is enough to find the bug.
|
||||
fingerprint: ['trpc', path ?? 'unknown', error.code],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { api } from '@/lib/trpc';
|
||||
import { SignedOut } from '@/components/chrome/signed-out';
|
||||
import { buttonClasses, EmptyState, Segmented } from '@/components/ui';
|
||||
import { JobRow, JobRowSkeleton, type JobRowData, type Perspective } from '@/components/jobs/job-row';
|
||||
import { JobDetail } from '@/components/jobs/job-detail';
|
||||
import { ChatThread } from '@/components/jobs/chat-thread';
|
||||
import { ReviewSheet } from '@/components/jobs/review-sheet';
|
||||
import { ReviewPrompt } from '@/components/jobs/review-prompt';
|
||||
|
||||
/**
|
||||
* Where you are inside the tab.
|
||||
*
|
||||
* `jobId` on a thread is where Back goes, not where the thread came from: a
|
||||
* client reaches a conversation through their job and should land back on it,
|
||||
* while a pro's list row IS the conversation and has no middle screen to return
|
||||
* to. Storing the destination rather than the history keeps Back from ever
|
||||
* needing a stack.
|
||||
*/
|
||||
export type JobsView =
|
||||
| { kind: 'list' }
|
||||
| { kind: 'job'; jobId: string }
|
||||
| { kind: 'thread'; matchId: string; jobId: string | null };
|
||||
|
||||
export type JobsSegment = 'current' | 'past';
|
||||
|
||||
export interface JobsState {
|
||||
view: JobsView;
|
||||
segment: JobsSegment;
|
||||
}
|
||||
|
||||
export const INITIAL_JOBS_STATE: JobsState = { view: { kind: 'list' }, segment: 'current' };
|
||||
|
||||
/**
|
||||
* The Jobs tab.
|
||||
*
|
||||
* Current and past are one list under a segmented control rather than two tabs,
|
||||
* because they are the same objects at different points in their life — and
|
||||
* because a job moves between them on its own, without the user doing anything.
|
||||
* `isActive` comes from the server (ACTIVE_JOB_STATUSES), so the split cannot
|
||||
* drift from the state machine.
|
||||
*
|
||||
* State is owned by the parent for the reason SearchPanel's is: the tab bar
|
||||
* unmounts panels on switch, and losing your place in a half-read conversation
|
||||
* because you glanced at Settings is worse than losing a search query.
|
||||
*/
|
||||
export function JobsPanel({
|
||||
state,
|
||||
onChange,
|
||||
}: {
|
||||
state: JobsState;
|
||||
onChange: (next: JobsState) => void;
|
||||
}) {
|
||||
const me = api.user.me.useQuery(undefined, { retry: false });
|
||||
|
||||
const go = (view: JobsView) => onChange({ ...state, view });
|
||||
|
||||
if (me.isLoading) {
|
||||
return (
|
||||
<Shell>
|
||||
<div className="flex flex-col gap-3">
|
||||
<JobRowSkeleton />
|
||||
<JobRowSkeleton />
|
||||
<JobRowSkeleton />
|
||||
</div>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
if (me.error || !me.data) {
|
||||
return (
|
||||
<SignedOut
|
||||
title="Sign in to see your jobs"
|
||||
body="Everything you have posted, and every conversation about it."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.view.kind === 'thread') {
|
||||
const { matchId, jobId } = state.view;
|
||||
return (
|
||||
<ChatThread
|
||||
matchId={matchId}
|
||||
onBack={() => go(jobId ? { kind: 'job', jobId } : { kind: 'list' })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.view.kind === 'job') {
|
||||
const { jobId } = state.view;
|
||||
return (
|
||||
<JobDetail
|
||||
jobId={jobId}
|
||||
onBack={() => go({ kind: 'list' })}
|
||||
onOpenThread={(matchId) => go({ kind: 'thread', matchId, jobId })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return me.data.role === 'pro' ? (
|
||||
<ProJobs state={state} onChange={onChange} />
|
||||
) : (
|
||||
<ClientJobs state={state} onChange={onChange} />
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Work that is finished and still owed a review, above the Past list.
|
||||
*
|
||||
* The reason the Past segment is worth opening. A rating is the only thing a new
|
||||
* pro has to trade on, and nobody navigates to a finished job to leave one
|
||||
* unprompted — so it is asked for where the finished job already is.
|
||||
*/
|
||||
function PendingReviews() {
|
||||
const pending = api.review.pending.useQuery(undefined, { retry: false });
|
||||
const [open, setOpen] = useState<{
|
||||
bookingId: string;
|
||||
subjectName: string;
|
||||
jobTitle: string;
|
||||
} | null>(null);
|
||||
|
||||
const rows = pending.data ?? [];
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ul className="mb-4 flex flex-col gap-2">
|
||||
{rows.map((r) => (
|
||||
<li key={r.bookingId}>
|
||||
<ReviewPrompt
|
||||
subjectName={r.subjectName}
|
||||
jobTitle={r.jobTitle}
|
||||
onOpen={() =>
|
||||
setOpen({
|
||||
bookingId: r.bookingId,
|
||||
subjectName: r.subjectName,
|
||||
jobTitle: r.jobTitle,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<ReviewSheet
|
||||
bookingId={open?.bookingId ?? null}
|
||||
subjectName={open?.subjectName ?? ''}
|
||||
jobTitle={open?.jobTitle ?? ''}
|
||||
open={open !== null}
|
||||
onClose={() => setOpen(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────── client ─────────────────────────────── */
|
||||
|
||||
function ClientJobs({
|
||||
state,
|
||||
onChange,
|
||||
}: {
|
||||
state: JobsState;
|
||||
onChange: (next: JobsState) => void;
|
||||
}) {
|
||||
// Polled, not pushed: a job list that goes stale hides the one thing this tab
|
||||
// exists to surface — that somebody replied. Thirty seconds is cheap here and
|
||||
// the thread itself refreshes far faster while it is open.
|
||||
const jobs = api.job.mine.useQuery(undefined, { refetchInterval: 30_000 });
|
||||
|
||||
const rows: JobRowData[] = (jobs.data ?? []).map((j) => ({
|
||||
id: j.id,
|
||||
title: j.title,
|
||||
status: j.status,
|
||||
subtitle: j.categoryName,
|
||||
createdAt: j.createdAt,
|
||||
matchCount: j.matchCount,
|
||||
pendingCount: j.pendingCount,
|
||||
unreadCount: j.unreadCount,
|
||||
lastMessageAt: j.lastMessageAt,
|
||||
nextBookingAt: j.nextBookingAt,
|
||||
}));
|
||||
const active = (jobs.data ?? []).filter((j) => j.isActive).map((j) => j.id);
|
||||
|
||||
return (
|
||||
<JobList
|
||||
perspective="client"
|
||||
loading={jobs.isLoading}
|
||||
rows={rows}
|
||||
activeIds={new Set(active)}
|
||||
state={state}
|
||||
onChange={onChange}
|
||||
onOpen={(jobId) => onChange({ ...state, view: { kind: 'job', jobId } })}
|
||||
empty={{
|
||||
current: {
|
||||
title: 'No jobs on the go',
|
||||
body: 'Post a job and we will build you a deck of verified pros who cover your area.',
|
||||
action: (
|
||||
<Link href="/jobs/new" className={buttonClasses({ variant: 'primary', size: 'md' })}>
|
||||
Post a job
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
past: {
|
||||
title: 'Nothing finished yet',
|
||||
body: 'Jobs you complete or cancel move here, with the conversation kept as a record.',
|
||||
},
|
||||
}}
|
||||
action={
|
||||
<Link
|
||||
href="/jobs/new"
|
||||
className={buttonClasses({ variant: 'primary', size: 'md', block: true })}
|
||||
>
|
||||
Post a job
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────────────────────────────── pro ──────────────────────────────── */
|
||||
|
||||
function ProJobs({ state, onChange }: { state: JobsState; onChange: (next: JobsState) => void }) {
|
||||
const jobs = api.job.mineForPro.useQuery(undefined, { refetchInterval: 30_000 });
|
||||
|
||||
const byId = new Map((jobs.data ?? []).map((j) => [j.id, j]));
|
||||
|
||||
const rows: JobRowData[] = (jobs.data ?? []).map((j) => ({
|
||||
id: j.id,
|
||||
title: j.title,
|
||||
status: j.status,
|
||||
// The customer, not the trade — a plumber's list of plumbing jobs does not
|
||||
// need to say "Plumber" eleven times.
|
||||
subtitle: j.clientName ?? 'Customer',
|
||||
createdAt: j.createdAt,
|
||||
unreadCount: j.unreadCount,
|
||||
lastMessageAt: j.lastMessageAt,
|
||||
nextBookingAt: j.nextBookingAt,
|
||||
}));
|
||||
const active = (jobs.data ?? []).filter((j) => j.isActive).map((j) => j.id);
|
||||
|
||||
return (
|
||||
<JobList
|
||||
perspective="pro"
|
||||
loading={jobs.isLoading}
|
||||
rows={rows}
|
||||
activeIds={new Set(active)}
|
||||
state={state}
|
||||
onChange={onChange}
|
||||
// A pro's row is one match, so it opens the conversation directly. There
|
||||
// is no middle screen listing "the pros on this job" — that is them.
|
||||
onOpen={(jobId) => {
|
||||
const match = byId.get(jobId);
|
||||
if (!match) return;
|
||||
onChange({ ...state, view: { kind: 'thread', matchId: match.matchId, jobId: null } });
|
||||
}}
|
||||
empty={{
|
||||
current: {
|
||||
title: 'No live jobs',
|
||||
body: 'Jobs you accept from your inbox appear here, with the customer chat attached.',
|
||||
},
|
||||
past: {
|
||||
title: 'No finished jobs yet',
|
||||
body: 'Work you complete moves here, and the customer can leave you a review.',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─────────────────────────────── shared ─────────────────────────────── */
|
||||
|
||||
interface EmptyCopy {
|
||||
title: string;
|
||||
body: string;
|
||||
action?: React.ReactNode;
|
||||
}
|
||||
|
||||
function JobList({
|
||||
perspective,
|
||||
loading,
|
||||
rows,
|
||||
activeIds,
|
||||
state,
|
||||
onChange,
|
||||
onOpen,
|
||||
empty,
|
||||
action,
|
||||
}: {
|
||||
perspective: Perspective;
|
||||
loading: boolean;
|
||||
rows: JobRowData[];
|
||||
activeIds: Set<string>;
|
||||
state: JobsState;
|
||||
onChange: (next: JobsState) => void;
|
||||
onOpen: (jobId: string) => void;
|
||||
empty: Record<JobsSegment, EmptyCopy>;
|
||||
action?: React.ReactNode;
|
||||
}) {
|
||||
const current = rows.filter((r) => activeIds.has(r.id));
|
||||
const past = rows.filter((r) => !activeIds.has(r.id));
|
||||
const shown = state.segment === 'current' ? current : past;
|
||||
const copy = empty[state.segment];
|
||||
|
||||
return (
|
||||
<Shell>
|
||||
<h1 className="mb-4 text-h2">Jobs</h1>
|
||||
|
||||
{/* Counts are rendered even at zero — a missing number reads as loading. */}
|
||||
<Segmented
|
||||
label="Job list view"
|
||||
value={state.segment}
|
||||
onChange={(segment) => onChange({ ...state, segment })}
|
||||
segments={[
|
||||
{ id: 'current', label: 'Current', count: current.length },
|
||||
{ id: 'past', label: 'Past', count: past.length },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Past only: a review belongs beside finished work, not beside a job
|
||||
somebody is still waiting on. */}
|
||||
{state.segment === 'past' && (
|
||||
<div className="mt-5">
|
||||
<PendingReviews />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="mt-5 flex flex-col gap-3">
|
||||
<JobRowSkeleton />
|
||||
<JobRowSkeleton />
|
||||
<JobRowSkeleton />
|
||||
</div>
|
||||
) : shown.length === 0 ? (
|
||||
<EmptyState className="mt-5" title={copy.title} body={copy.body} action={copy.action} />
|
||||
) : (
|
||||
<>
|
||||
{action && <div className="mt-5">{action}</div>}
|
||||
<ul className="mt-3 flex flex-col gap-3">
|
||||
{shown.map((job) => (
|
||||
<li key={job.id}>
|
||||
<JobRow job={job} perspective={perspective} onOpen={onOpen} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import type { RouterOutputs } from '@/lib/trpc';
|
||||
import { api } from '@/lib/trpc';
|
||||
import { clearPendingHire, readPendingHire } from '@/lib/pending-hire';
|
||||
import {
|
||||
AddressField,
|
||||
EMPTY_ADDRESS,
|
||||
type AddressValue,
|
||||
Banner,
|
||||
Button,
|
||||
Chip,
|
||||
Field,
|
||||
@@ -19,33 +24,67 @@ import {
|
||||
|
||||
type Categories = RouterOutputs['job']['categories'];
|
||||
|
||||
const CITY = {
|
||||
lat: Number(process.env.NEXT_PUBLIC_CITY_LAT ?? 41.3874),
|
||||
lng: Number(process.env.NEXT_PUBLIC_CITY_LNG ?? 2.1686),
|
||||
};
|
||||
|
||||
const URGENCIES = [
|
||||
{ value: 'now', label: 'As soon as possible', hint: 'Pros have 12 hours to respond' },
|
||||
{ value: 'this_week', label: 'This week', hint: '48 hours to respond' },
|
||||
{ value: 'flexible', label: "I'm flexible", hint: '48 hours to respond' },
|
||||
] as const;
|
||||
|
||||
export function NewJobForm({ categories }: { categories: Categories }) {
|
||||
export function NewJobForm({
|
||||
categories,
|
||||
sendTo = null,
|
||||
}: {
|
||||
categories: Categories;
|
||||
/** A pro this job is being posted for — see the entry deck's send sheet. */
|
||||
sendTo?: string | null;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [categoryId, setCategoryId] = useState('');
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [urgency, setUrgency] = useState<(typeof URGENCIES)[number]['value']>('this_week');
|
||||
const [addressText, setAddressText] = useState('');
|
||||
const [address, setAddress] = useState<AddressValue>(EMPTY_ADDRESS);
|
||||
const [budgetMin, setBudgetMin] = useState('');
|
||||
const [budgetMax, setBudgetMax] = useState('');
|
||||
const [location, setLocation] = useState(CITY);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [sendToName, setSendToName] = useState<string | null>(null);
|
||||
|
||||
// The name was parked alongside the id when they left the deck. Reading it in
|
||||
// an effect rather than during render because sessionStorage does not exist
|
||||
// on the server and this component is prerendered.
|
||||
useEffect(() => {
|
||||
if (!sendTo) return;
|
||||
const pending = readPendingHire();
|
||||
if (pending?.proId === sendTo) setSendToName(pending.name);
|
||||
}, [sendTo]);
|
||||
|
||||
/**
|
||||
* Posting for a specific pro sends it to them as well.
|
||||
*
|
||||
* Same `deck.swipe` the deck itself calls, so the open-request cap and the
|
||||
* verification check still apply — this is a shortcut through the deck, not
|
||||
* around it. A failure here is not fatal: the job exists either way, so it
|
||||
* lands on the job rather than throwing the whole form away.
|
||||
*/
|
||||
const swipe = api.deck.swipe.useMutation();
|
||||
|
||||
const create = api.job.create.useMutation({
|
||||
// Straight into the deck — the whole point is that posting and browsing are
|
||||
// one continuous motion, not two separate visits.
|
||||
onSuccess: (job) => router.push(`/deck/${job.id}`),
|
||||
onSuccess: async (job) => {
|
||||
if (sendTo) {
|
||||
try {
|
||||
await swipe.mutateAsync({ jobId: job.id, proId: sendTo, direction: 'right' });
|
||||
clearPendingHire();
|
||||
router.push('/?tab=jobs');
|
||||
return;
|
||||
} catch {
|
||||
// Fall through to the deck: they still have a job, and the deck is
|
||||
// where they can send it to somebody.
|
||||
}
|
||||
}
|
||||
// Straight into the deck — the whole point is that posting and browsing are
|
||||
// one continuous motion, not two separate visits.
|
||||
router.push(`/deck/${job.id}`);
|
||||
},
|
||||
onError: (e) => setError(e.message),
|
||||
});
|
||||
|
||||
@@ -69,13 +108,18 @@ export function NewJobForm({ categories }: { categories: Categories }) {
|
||||
urgency,
|
||||
budgetMinCents: min,
|
||||
budgetMaxCents: max,
|
||||
location,
|
||||
addressText,
|
||||
place: address.place,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="flex flex-col gap-6">
|
||||
{sendTo && (
|
||||
<Banner tone="info" title={`This one goes to ${sendToName ?? 'the pro you picked'}`}>
|
||||
As soon as you post it, we send it straight to them. You can send it to more pros
|
||||
afterwards.
|
||||
</Banner>
|
||||
)}
|
||||
<FieldSet label="Trade">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{categories.map((c) => (
|
||||
@@ -128,31 +172,13 @@ export function NewJobForm({ categories }: { categories: Categories }) {
|
||||
</div>
|
||||
</FieldSet>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Field label="Address" hint="Only shared with a pro once you have booked them.">
|
||||
<Input
|
||||
value={addressText}
|
||||
onChange={(e) => setAddressText(e.target.value)}
|
||||
required
|
||||
minLength={3}
|
||||
maxLength={255}
|
||||
/>
|
||||
</Field>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="self-start"
|
||||
onClick={() =>
|
||||
navigator.geolocation?.getCurrentPosition(
|
||||
(pos) => setLocation({ lat: pos.coords.latitude, lng: pos.coords.longitude }),
|
||||
() => setError('We could not get your location, so we will search from the centre.'),
|
||||
)
|
||||
}
|
||||
>
|
||||
Use my current location
|
||||
</Button>
|
||||
</div>
|
||||
<AddressField
|
||||
label="Address"
|
||||
hint="Only shared with a pro once you have booked them."
|
||||
value={address}
|
||||
onChange={setAddress}
|
||||
required
|
||||
/>
|
||||
|
||||
<FieldSet label="Budget (optional)">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
|
||||
@@ -7,7 +7,11 @@ import { NewJobForm } from './form';
|
||||
export const metadata = { title: 'Post a job' };
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function NewJobPage() {
|
||||
export default async function NewJobPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ pro?: string }>;
|
||||
}) {
|
||||
const api = await getApi();
|
||||
|
||||
let me: Awaited<ReturnType<typeof api.user.me>>;
|
||||
@@ -18,14 +22,18 @@ export default async function NewJobPage() {
|
||||
}
|
||||
if (me.role === 'pro') redirect('/pro');
|
||||
|
||||
const categories = await api.job.categories();
|
||||
const [categories, { pro }] = await Promise.all([api.job.categories(), searchParams]);
|
||||
|
||||
// Arrives from the entry deck's "Send a job" sheet: this job is being posted
|
||||
// FOR someone, and gets sent to them the moment it exists.
|
||||
const sendTo = /^[0-9a-f-]{36}$/i.test(pro ?? '') ? pro! : null;
|
||||
|
||||
return (
|
||||
<AppShell title="Post a job">
|
||||
<ScreenIntro>
|
||||
Describe it once. We will show you verified pros nearby who can take it on.
|
||||
</ScreenIntro>
|
||||
<NewJobForm categories={categories} />
|
||||
<NewJobForm categories={categories} sendTo={sendTo} />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,95 +1,17 @@
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { ChevronRight, Plus } from 'lucide-react';
|
||||
import { getApi } from '@/server/caller';
|
||||
import { AppShell } from '@/components/chrome/app-shell';
|
||||
import { buttonClasses, EmptyState } from '@/components/ui';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export const metadata = { title: 'Your jobs' };
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/** Label plus the tint it carries. §8 — status is never colour alone. */
|
||||
const STATUS: Record<string, { label: string; className: string }> = {
|
||||
open: { label: 'Looking for pros', className: 'border-brand-200 bg-brand-100 text-ink-950' },
|
||||
matched: { label: 'Pros interested', className: 'border-go-100 bg-go-50 text-go-700' },
|
||||
booked: { label: 'Booked', className: 'border-go-100 bg-go-50 text-go-700' },
|
||||
completed: { label: 'Done', className: 'border-hairline bg-inset text-muted' },
|
||||
cancelled: { label: 'Cancelled', className: 'border-stop-100 bg-stop-50 text-stop-600' },
|
||||
};
|
||||
|
||||
export default async function JobsPage() {
|
||||
const api = await getApi();
|
||||
|
||||
let jobs: Awaited<ReturnType<typeof api.job.mine>>;
|
||||
try {
|
||||
jobs = await api.job.mine();
|
||||
} catch {
|
||||
redirect('/sign-in?next=/jobs');
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell title="Your jobs">
|
||||
{jobs.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No jobs yet"
|
||||
body="Post a job and we will build you a deck of verified pros who cover your area."
|
||||
action={
|
||||
<Link
|
||||
href="/jobs/new"
|
||||
className={cn('mt-2', buttonClasses({ variant: 'primary', size: 'md' }))}
|
||||
>
|
||||
Post a job
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Link
|
||||
href="/jobs/new"
|
||||
className={cn('mb-4', buttonClasses({ variant: 'primary', size: 'md', block: true }))}
|
||||
>
|
||||
<Plus className="h-5 w-5" aria-hidden />
|
||||
Post a job
|
||||
</Link>
|
||||
<ul className="flex flex-col gap-3">
|
||||
{jobs.map((job) => {
|
||||
const status = STATUS[job.status] ?? {
|
||||
label: job.status,
|
||||
className: 'border-hairline bg-inset text-muted',
|
||||
};
|
||||
return (
|
||||
<li key={job.id}>
|
||||
<Link
|
||||
href={`/deck/${job.id}`}
|
||||
className={cn(
|
||||
'flex items-center justify-between gap-3 rounded-card border border-hairline',
|
||||
'bg-raised p-4 transition-[border-color] duration-[120ms] ease-standard',
|
||||
'active:border-brand-500',
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block font-display text-h4">{job.title}</span>
|
||||
<span className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-pill border px-3 py-1 text-meta',
|
||||
status.className,
|
||||
)}
|
||||
>
|
||||
{status.label}
|
||||
</span>
|
||||
<span className="truncate text-body-sm text-muted">{job.addressText}</span>
|
||||
</span>
|
||||
</span>
|
||||
<ChevronRight className="h-5 w-5 shrink-0 text-accent" aria-hidden />
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</AppShell>
|
||||
);
|
||||
/**
|
||||
* Jobs live in the app shell, not on a page of their own.
|
||||
*
|
||||
* This route used to render a second jobs list — its own status pills, its own
|
||||
* row markup, its own chrome — beside the one in the Jobs tab. Two lists of the
|
||||
* same objects drift within a week, and the tab is the better of the two: it has
|
||||
* the current/past split and the conversations hanging off each job, which a
|
||||
* standalone page with no tab bar cannot reach.
|
||||
*
|
||||
* Kept as a redirect rather than deleted because `/jobs` is the default `next`
|
||||
* after sign-in and is linked from elsewhere in the app.
|
||||
*/
|
||||
export default function JobsPage() {
|
||||
redirect('/?tab=jobs');
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getApi } from '@/server/caller';
|
||||
import type { PhoneTab } from '@/components/chrome/phone-tabs';
|
||||
import { ShowcaseDeck } from './showcase-deck';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
@@ -15,17 +16,30 @@ export const dynamic = 'force-dynamic';
|
||||
* PhoneFrame) — this is a mobile product, and the illustration exists only so
|
||||
* the desktop visitor understands that.
|
||||
*/
|
||||
export default async function Home() {
|
||||
/** The tabs a link is allowed to open on. Anything else falls back to the deck. */
|
||||
const DEEP_LINKABLE = ['swipe', 'search', 'jobs', 'profile', 'settings'] as const;
|
||||
|
||||
function tabFrom(value: string | undefined): PhoneTab {
|
||||
return DEEP_LINKABLE.includes(value as PhoneTab) ? (value as PhoneTab) : 'swipe';
|
||||
}
|
||||
|
||||
export default async function Home({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ tab?: string }>;
|
||||
}) {
|
||||
const api = await getApi();
|
||||
const [{ cards }, categories] = await Promise.all([
|
||||
const [{ cards }, categories, { tab }] = await Promise.all([
|
||||
api.deck.showcase(),
|
||||
api.job.categories(),
|
||||
searchParams,
|
||||
]);
|
||||
|
||||
return (
|
||||
<ShowcaseDeck
|
||||
categories={categories.map((c) => ({ id: c.id, name: c.name }))}
|
||||
initialCards={cards}
|
||||
initialTab={tabFrom(tab)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ import {
|
||||
import { cn } from '@/lib/utils';
|
||||
import { uploadFile } from '@/lib/upload';
|
||||
import {
|
||||
AddressField,
|
||||
EMPTY_ADDRESS,
|
||||
type AddressValue,
|
||||
Button,
|
||||
Chip,
|
||||
Field,
|
||||
@@ -28,11 +31,6 @@ import {
|
||||
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({
|
||||
@@ -60,7 +58,16 @@ export function OnboardingWizard({
|
||||
const [radiusKm, setRadiusKm] = useState(
|
||||
(initialProfile?.serviceRadiusM ?? DEFAULT_SERVICE_RADIUS_M) / 1000,
|
||||
);
|
||||
const [location, setLocation] = useState(initialProfile?.baseLocation ?? CITY);
|
||||
/*
|
||||
* Starts empty even for a returning pro.
|
||||
*
|
||||
* The profile stores a POINT, not the text that produced it, and seeding the
|
||||
* box with a label we cannot prove still matches that point is how the two
|
||||
* drift apart. An empty box says plainly that saving this step re-picks the
|
||||
* base — and `upsertProfile` only sends it back for review if the resolved
|
||||
* point actually moved, so re-picking the same address costs nothing.
|
||||
*/
|
||||
const [address, setAddress] = useState<AddressValue>(EMPTY_ADDRESS);
|
||||
const [email, setEmail] = useState('');
|
||||
|
||||
const utils = api.useUtils();
|
||||
@@ -90,7 +97,7 @@ export function OnboardingWizard({
|
||||
hourlyRateCents: rateCents,
|
||||
yearsExperience: Number(yearsExperience) || 0,
|
||||
categoryIds,
|
||||
location,
|
||||
place: address.place,
|
||||
serviceRadiusM: Math.round(radiusKm * 1000),
|
||||
});
|
||||
await utils.pro.me.invalidate();
|
||||
@@ -195,6 +202,14 @@ export function OnboardingWizard({
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<AddressField
|
||||
label="Your base address"
|
||||
hint="Where you set off from. Customers never see it — it only decides which jobs reach you."
|
||||
value={address}
|
||||
onChange={setAddress}
|
||||
required
|
||||
/>
|
||||
|
||||
<Field
|
||||
label={`How far will you travel? ${radiusKm} km`}
|
||||
hint="You will only be shown jobs inside this radius."
|
||||
@@ -209,20 +224,6 @@ export function OnboardingWizard({
|
||||
/>
|
||||
</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)}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
'use client';
|
||||
|
||||
import { DEFAULT_SERVICE_RADIUS_M } from '@linkder/shared';
|
||||
import type { DeckCard } from '@linkder/db';
|
||||
import { api } from '@/lib/trpc';
|
||||
import { useDebouncedValue } from '@/lib/use-debounced-value';
|
||||
import { EmptyState } from '@/components/ui';
|
||||
import { SearchField } from '@/components/search/search-field';
|
||||
import { SearchFilters, type SearchFilterState } from '@/components/search/search-filters';
|
||||
import { ResultRow, ResultRowSkeleton } from '@/components/search/result-row';
|
||||
import { ProProfilePanel } from '@/components/pro/pro-profile-panel';
|
||||
import type { Category } from './showcase-deck';
|
||||
|
||||
const CITY_NAME = process.env.NEXT_PUBLIC_CITY_NAME ?? 'your city';
|
||||
|
||||
export interface SearchState extends SearchFilterState {
|
||||
q: string;
|
||||
filtersOpen: boolean;
|
||||
/**
|
||||
* The pro whose profile is open on top of the results, if any.
|
||||
*
|
||||
* Up here with the query and the filters for the same reason they are: the
|
||||
* tab bar unmounts this panel on every switch, and a profile that closed
|
||||
* itself because someone glanced at their jobs would take the search behind
|
||||
* it with it.
|
||||
*/
|
||||
openProId: string | null;
|
||||
}
|
||||
|
||||
export const INITIAL_SEARCH_STATE: SearchState = {
|
||||
q: '',
|
||||
categoryId: null,
|
||||
radiusKm: DEFAULT_SERVICE_RADIUS_M / 1000,
|
||||
sort: 'best',
|
||||
filtersOpen: false,
|
||||
openProId: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* The Search tab.
|
||||
*
|
||||
* The deck answers "who next?"; this answers "who is out there?" — the same
|
||||
* verified pros, but browsable, filterable and searchable by what they say they
|
||||
* are good at. Both read the same eligibility rule server-side, so nothing found
|
||||
* here turns out to be unbookable.
|
||||
*
|
||||
* State is owned by the parent, not this component: the tab bar unmounts panels
|
||||
* on switch, and a query lost every time someone checks their profile is a
|
||||
* search box nobody trusts.
|
||||
*/
|
||||
export function SearchPanel({
|
||||
categories,
|
||||
state,
|
||||
onChange,
|
||||
onHire,
|
||||
}: {
|
||||
categories: Category[];
|
||||
state: SearchState;
|
||||
onChange: (next: SearchState) => void;
|
||||
/** Bubbled to the single SendJobSheet at the phone root. */
|
||||
onHire: (pro: DeckCard) => void;
|
||||
}) {
|
||||
// Per pause, not per keystroke.
|
||||
const q = useDebouncedValue(state.q, 250);
|
||||
|
||||
const search = api.pro.search.useQuery(
|
||||
{
|
||||
q: q.trim() || undefined,
|
||||
categoryId: state.categoryId ?? undefined,
|
||||
maxDistanceM: state.radiusKm * 1000,
|
||||
sort: state.sort,
|
||||
},
|
||||
{
|
||||
// Keep the previous list on screen while the next one loads: a list that
|
||||
// blanks on every keystroke reads as "no results" over and over.
|
||||
placeholderData: (previous) => previous,
|
||||
staleTime: 30_000,
|
||||
},
|
||||
);
|
||||
|
||||
const results = search.data?.results ?? [];
|
||||
const hasQuery = Boolean(q.trim()) || Boolean(state.categoryId);
|
||||
const firstLoad = search.isLoading;
|
||||
|
||||
/**
|
||||
* The open profile, taken from the row that was tapped.
|
||||
*
|
||||
* Looked up in the current results rather than stored, so the profile always
|
||||
* describes a pro this search actually returned. If a refetch drops them the
|
||||
* lookup fails and the list comes back, which is the right answer: they are no
|
||||
* longer a result.
|
||||
*/
|
||||
const openPro = state.openProId
|
||||
? (results.find((r) => r.proId === state.openProId) ?? null)
|
||||
: null;
|
||||
|
||||
if (openPro) {
|
||||
return (
|
||||
<ProProfilePanel
|
||||
pro={openPro}
|
||||
onBack={() => onChange({ ...state, openProId: null })}
|
||||
onHire={onHire}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-4 pt-[3.25rem]">
|
||||
<h1 className="mb-4 text-h2">Search</h1>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<SearchField value={state.q} onChange={(next) => onChange({ ...state, q: next })} />
|
||||
<SearchFilters
|
||||
categories={categories}
|
||||
state={state}
|
||||
onChange={(next) => onChange({ ...state, ...next })}
|
||||
expanded={state.filtersOpen}
|
||||
onToggleExpanded={() => onChange({ ...state, filtersOpen: !state.filtersOpen })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-col gap-3">
|
||||
{firstLoad ? (
|
||||
<>
|
||||
<ResultRowSkeleton />
|
||||
<ResultRowSkeleton />
|
||||
<ResultRowSkeleton />
|
||||
</>
|
||||
) : results.length === 0 ? (
|
||||
<EmptyState
|
||||
title={hasQuery ? 'Nothing matched' : `No pros within ${state.radiusKm} km yet`}
|
||||
body={
|
||||
hasQuery
|
||||
? 'Try a wider distance, a different trade, or fewer words. Searching a trade name works better than a brand name.'
|
||||
: `We are still signing up pros in ${CITY_NAME}. Widen the distance to see who is out there.`
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-meta text-muted tabular-nums" aria-live="polite">
|
||||
{results.length} {results.length === 1 ? 'pro' : 'pros'} within {state.radiusKm} km of{' '}
|
||||
{search.data?.centredOnYou ? 'you' : CITY_NAME}
|
||||
{/* Honesty about the ceiling: 50 is a cap, not a count. */}
|
||||
{results.length === 50 && ' — narrow your search to see the rest'}
|
||||
</p>
|
||||
|
||||
<ul className="flex flex-col gap-3">
|
||||
{results.map((pro) => (
|
||||
<li key={pro.proId}>
|
||||
<ResultRow
|
||||
pro={pro}
|
||||
onOpen={(proId) => onChange({ ...state, openProId: proId })}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, 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 { Chip } from '@/components/ui';
|
||||
import { Deck, type SwipeVerdict } from '@/components/deck';
|
||||
import { Chip, ScrollStrip } from '@/components/ui';
|
||||
import { PhoneTabs, type PhoneTab } from '@/components/chrome/phone-tabs';
|
||||
import { SettingsPanel } from './settings-panel';
|
||||
import { INITIAL_SEARCH_STATE, SearchPanel, type SearchState } from './search-panel';
|
||||
import { ProfilePanel } from './profile-panel';
|
||||
import { INITIAL_JOBS_STATE, JobsPanel, type JobsState } from './jobs-panel';
|
||||
import { SendJobSheet } from '@/components/hire/send-job-sheet';
|
||||
import { clearPendingHire, readPendingHire } from '@/lib/pending-hire';
|
||||
import { api } from '@/lib/trpc';
|
||||
|
||||
export interface Category {
|
||||
@@ -25,20 +29,39 @@ export interface Category {
|
||||
* 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.
|
||||
* A right swipe opens SendJobSheet rather than writing anything directly. The
|
||||
* card promises "Send this job" and there is no job in context here, so the
|
||||
* sheet is where that gets decided — sign in, pick one of your open jobs, or
|
||||
* post one. Nobody is contacted until a job is chosen, which is the property
|
||||
* the old no-op handler was protecting; it just protected it by doing nothing
|
||||
* at all, including for the signed-in client who had a job ready to send.
|
||||
*
|
||||
* A left swipe still writes nothing. `swipes` rows are job-scoped, so with no
|
||||
* job there is no tombstone to record — passing here is genuinely local.
|
||||
*/
|
||||
export function ShowcaseDeck({
|
||||
categories,
|
||||
initialCards,
|
||||
initialTab = 'swipe',
|
||||
}: {
|
||||
categories: Category[];
|
||||
initialCards: DeckCard[];
|
||||
/**
|
||||
* Which tab to open on. The app is one screen with no routes inside it, so a
|
||||
* deep link like `/jobs` — or `?next=/jobs` after sign-in — has nowhere to
|
||||
* land unless the shell can be told where to start.
|
||||
*/
|
||||
initialTab?: PhoneTab;
|
||||
}) {
|
||||
const [categoryId, setCategoryId] = useState<string | null>(null);
|
||||
const [tab, setTab] = useState<PhoneTab>('swipe');
|
||||
const [tab, setTab] = useState<PhoneTab>(initialTab);
|
||||
// Search state lives here, beside the tab state, because the panels unmount on
|
||||
// every tab switch — owning it inside SearchPanel would throw the query away
|
||||
// each time someone glanced at another tab.
|
||||
const [search, setSearch] = useState<SearchState>(INITIAL_SEARCH_STATE);
|
||||
// Same reason, and it matters more here: losing your place in a half-read
|
||||
// conversation because you glanced at another tab is worse than losing a query.
|
||||
const [jobs, setJobs] = useState<JobsState>(INITIAL_JOBS_STATE);
|
||||
|
||||
// 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.
|
||||
@@ -47,7 +70,69 @@ export function ShowcaseDeck({
|
||||
{ initialData: categoryId ? undefined : { cards: initialCards }, staleTime: 60_000 },
|
||||
);
|
||||
|
||||
const cards = data?.cards ?? [];
|
||||
// Errors for an anonymous visitor, which is fine — no session, no badge.
|
||||
const unread = api.message.unreadTotal.useQuery(undefined, {
|
||||
retry: false,
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
// Memoised because `decide` and the resume effect both depend on it: the bare
|
||||
// `?? []` mints a new array on every render where the query has no data, which
|
||||
// would rebuild the swipe handler mid-gesture.
|
||||
const cards = useMemo(() => data?.cards ?? [], [data]);
|
||||
|
||||
/**
|
||||
* The pro a right swipe is asking about, and the promise the Deck is waiting
|
||||
* on. The Deck holds the card until `resolve` is called, so closing the sheet
|
||||
* puts the pro back instead of losing them.
|
||||
*/
|
||||
const [hiring, setHiring] = useState<DeckCard | null>(null);
|
||||
const resolve = useRef<((verdict: SwipeVerdict) => void) | null>(null);
|
||||
|
||||
const decide = useCallback(
|
||||
(proId: string, direction: 'left' | 'right'): Promise<SwipeVerdict> => {
|
||||
// Passing writes nothing without a job — see the note above.
|
||||
if (direction === 'left') return Promise.resolve('commit');
|
||||
|
||||
const card = cards.find((c) => c.proId === proId);
|
||||
if (!card) return Promise.resolve('commit');
|
||||
|
||||
setHiring(card);
|
||||
return new Promise<SwipeVerdict>((done) => {
|
||||
resolve.current = done;
|
||||
});
|
||||
},
|
||||
[cards],
|
||||
);
|
||||
|
||||
const onResolved = useCallback((outcome: 'sent' | 'dismissed') => {
|
||||
setHiring(null);
|
||||
resolve.current?.(outcome === 'sent' ? 'commit' : 'revert');
|
||||
resolve.current = null;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Resume after a round trip.
|
||||
*
|
||||
* Someone who swiped right while signed out went to sign in; someone with no
|
||||
* open job went to post one. Both land back here, and the pro they picked is
|
||||
* waiting in sessionStorage. Reopening the sheet on their behalf is the whole
|
||||
* reason the intent is stored at all.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const pending = readPendingHire();
|
||||
if (!pending) return;
|
||||
clearPendingHire();
|
||||
|
||||
const card = cards.find((c) => c.proId === pending.proId);
|
||||
// Only resumable while they are still on the deck we loaded. If the trade
|
||||
// filter moved them out of it, silently dropping the intent beats reopening
|
||||
// a sheet about somebody who is no longer on screen.
|
||||
if (card) {
|
||||
setTab('swipe');
|
||||
setHiring(card);
|
||||
}
|
||||
}, [cards]);
|
||||
const selected = categories.find((c) => c.id === categoryId) ?? null;
|
||||
|
||||
return (
|
||||
@@ -56,10 +141,15 @@ export function ShowcaseDeck({
|
||||
<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>
|
||||
) : tab === 'search' ? (
|
||||
<SearchPanel
|
||||
categories={categories}
|
||||
state={search}
|
||||
onChange={setSearch}
|
||||
onHire={setHiring}
|
||||
/>
|
||||
) : tab === 'jobs' ? (
|
||||
<JobsPanel state={jobs} onChange={setJobs} />
|
||||
) : (
|
||||
<>
|
||||
{/* Trade strip. Above the card, never over the photo — so it cannot steal
|
||||
@@ -76,11 +166,7 @@ export function ShowcaseDeck({
|
||||
// is the only affordance saying so — the scrollbar is hidden, and a
|
||||
// row that simply ends at the bezel reads as the whole list.
|
||||
<div className="relative -mx-4">
|
||||
<div
|
||||
className="flex gap-1.5 overflow-x-auto px-4 pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
role="group"
|
||||
aria-label="Filter by trade"
|
||||
>
|
||||
<ScrollStrip className="gap-1.5 px-4 pb-1" role="group" aria-label="Filter by trade">
|
||||
{categories.map((c) => (
|
||||
<Chip
|
||||
key={c.id}
|
||||
@@ -91,7 +177,7 @@ export function ShowcaseDeck({
|
||||
{c.name}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</ScrollStrip>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-page to-transparent"
|
||||
@@ -110,7 +196,7 @@ export function ShowcaseDeck({
|
||||
// instead of resuming at the previous deck's index.
|
||||
key={categoryId ?? 'all'}
|
||||
cards={cards}
|
||||
onDecide={() => {}}
|
||||
onDecide={decide}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -124,7 +210,11 @@ export function ShowcaseDeck({
|
||||
</>
|
||||
)}
|
||||
|
||||
<PhoneTabs active={tab} onChange={setTab} />
|
||||
<PhoneTabs active={tab} onChange={setTab} badges={{ jobs: unread.data?.unread ?? 0 }} />
|
||||
|
||||
{/* Inside the phone frame, not the page — the sheet belongs to this
|
||||
screen and must not cover the browser chrome around the mock. */}
|
||||
<SendJobSheet pro={hiring} open={hiring !== null} onResolved={onResolved} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +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';
|
||||
import { SocialSignIn } from '@/components/auth/social-sign-in';
|
||||
|
||||
type Step = 'phone' | 'code';
|
||||
|
||||
@@ -122,11 +122,11 @@ export function SignInForm() {
|
||||
<span className="h-px flex-1 bg-hairline" />
|
||||
</div>
|
||||
|
||||
<GoogleButton callbackURL={next} />
|
||||
<SocialSignIn 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
|
||||
both, contact us and we will link them.
|
||||
Signing in with Google or Microsoft creates a separate account from a phone sign-in. If
|
||||
you have used more than one, contact us and we will link them.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { Button, useToast } from '@/components/ui';
|
||||
|
||||
export type SocialProvider = 'google' | 'microsoft' | 'github';
|
||||
|
||||
/**
|
||||
* The social routes in, offered wherever we ask someone to sign in.
|
||||
*
|
||||
* Buttons render whether or not the server holds credentials for that provider.
|
||||
* Hiding one when its 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. They are always here; when the server cannot
|
||||
* honour a click, the click says so out loud.
|
||||
*
|
||||
* `lib/auth.ts` registers each provider only when both of its env vars 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 "the provider is down".
|
||||
*/
|
||||
const PROVIDERS: Record<
|
||||
SocialProvider,
|
||||
{ name: string; label: string; mark: () => React.ReactElement }
|
||||
> = {
|
||||
google: { name: 'Google', label: 'Continue with Google', mark: GoogleMark },
|
||||
microsoft: { name: 'Microsoft', label: 'Continue with Microsoft', mark: MicrosoftMark },
|
||||
github: { name: 'GitHub', label: 'Continue with GitHub', mark: GitHubMark },
|
||||
};
|
||||
|
||||
export function SocialButton({
|
||||
provider,
|
||||
callbackURL,
|
||||
size = 'lg',
|
||||
block = true,
|
||||
label,
|
||||
onBeforeStart,
|
||||
}: {
|
||||
provider: SocialProvider;
|
||||
/** Where to land after the provider sends the browser back. */
|
||||
callbackURL: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
block?: boolean;
|
||||
label?: string;
|
||||
/**
|
||||
* Runs immediately before the redirect.
|
||||
*
|
||||
* The click navigates away, so anything that has to survive the round trip
|
||||
* has to be written first — a `onClick` alongside this one would be racing a
|
||||
* navigation already in flight.
|
||||
*/
|
||||
onBeforeStart?: () => void;
|
||||
}) {
|
||||
const toast = useToast();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const config = PROVIDERS[provider];
|
||||
const Mark = config.mark;
|
||||
|
||||
async function start() {
|
||||
setBusy(true);
|
||||
onBeforeStart?.();
|
||||
|
||||
const { error } = await authClient.signIn.social({ provider, callbackURL });
|
||||
// On success better-auth's redirect plugin has already sent the browser to
|
||||
// the provider, 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);
|
||||
|
||||
const { name } = config;
|
||||
|
||||
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: `${name} sign-in is not set up yet`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
toast(error.message ?? `${name} did not respond. Try again, or use your mobile number.`, {
|
||||
tone: 'error',
|
||||
title: `Could not continue with ${name}`,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Button type="button" variant="outline" size={size} block={block} busy={busy} onClick={start}>
|
||||
{!busy && <Mark />}
|
||||
{label ?? config.label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every social route, in one place.
|
||||
*
|
||||
* Screens compose this rather than the individual buttons, so adding a third
|
||||
* provider is one edit rather than four — and so the order and spacing cannot
|
||||
* drift between the sign-in page, the signed-out tabs and the hire sheet.
|
||||
*/
|
||||
export function SocialSignIn({
|
||||
callbackURL,
|
||||
size = 'lg',
|
||||
onBeforeStart,
|
||||
}: {
|
||||
callbackURL: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
onBeforeStart?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{(Object.keys(PROVIDERS) as SocialProvider[]).map((provider) => (
|
||||
<SocialButton
|
||||
key={provider}
|
||||
provider={provider}
|
||||
callbackURL={callbackURL}
|
||||
size={size}
|
||||
onBeforeStart={onBeforeStart}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub's Invertocat, per their logo terms: monochrome only, and it takes the
|
||||
* button's own ink via `currentColor` so it stays legible in both themes rather
|
||||
* than being pinned to black on a dark surface.
|
||||
*/
|
||||
function GitHubMark() {
|
||||
return (
|
||||
<svg className="h-5 w-5 shrink-0" viewBox="0 0 16 16" fill="currentColor" aria-hidden focusable="false">
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82a7.42 7.42 0 0 1 2-.27c.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Microsoft's mark, per their brand guidelines: the four squares at their fixed
|
||||
* colours, never recoloured and never redrawn as a single-colour glyph.
|
||||
*/
|
||||
function MicrosoftMark() {
|
||||
return (
|
||||
<svg className="h-5 w-5 shrink-0" viewBox="0 0 21 21" aria-hidden focusable="false">
|
||||
<path fill="#F25022" d="M1 1h9v9H1z" />
|
||||
<path fill="#7FBA00" d="M11 1h9v9h-9z" />
|
||||
<path fill="#00A4EF" d="M1 11h9v9H1z" />
|
||||
<path fill="#FFB900" d="M11 11h9v9h-9z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ 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: 'jobs', label: 'Jobs', icon: Layers },
|
||||
{ id: 'profile', label: 'Profile', icon: UserRound },
|
||||
{ id: 'settings', label: 'Settings', icon: Settings },
|
||||
];
|
||||
@@ -21,9 +21,12 @@ const TABS: { id: PhoneTab; label: string; icon: typeof Flame }[] = [
|
||||
export function PhoneTabs({
|
||||
active,
|
||||
onChange,
|
||||
badges,
|
||||
}: {
|
||||
active: PhoneTab;
|
||||
onChange: (tab: PhoneTab) => void;
|
||||
/** Unread counts per tab. Zero and undefined both render nothing. */
|
||||
badges?: Partial<Record<PhoneTab, number>>;
|
||||
}) {
|
||||
return (
|
||||
<nav
|
||||
@@ -32,15 +35,18 @@ export function PhoneTabs({
|
||||
>
|
||||
{TABS.map(({ id, label, icon: Icon }) => {
|
||||
const isActive = id === active;
|
||||
const badge = badges?.[id] ?? 0;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => onChange(id)}
|
||||
aria-label={label}
|
||||
// The count goes in the accessible name, not just the pixel badge —
|
||||
// §8, colour and position are never the only carrier of meaning.
|
||||
aria-label={badge > 0 ? `${label}, ${badge} unread` : label}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
className={cn(
|
||||
'flex h-11 w-11 items-center justify-center rounded-pill',
|
||||
'relative 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',
|
||||
)}
|
||||
@@ -51,6 +57,18 @@ export function PhoneTabs({
|
||||
fill={isActive && id === 'swipe' ? 'currentColor' : 'none'}
|
||||
aria-hidden
|
||||
/>
|
||||
{badge > 0 && (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'absolute right-0.5 top-0.5 flex h-4 min-w-4 items-center justify-center',
|
||||
'rounded-pill bg-brand-500 px-1 text-[0.625rem] font-semibold leading-none',
|
||||
'text-white tabular-nums ring-2 ring-page',
|
||||
)}
|
||||
>
|
||||
{badge > 9 ? '9+' : badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Link from 'next/link';
|
||||
import { LogIn } from 'lucide-react';
|
||||
import { SocialSignIn } from '@/components/auth/social-sign-in';
|
||||
import { buttonClasses } from '@/components/ui';
|
||||
|
||||
/**
|
||||
@@ -7,6 +8,11 @@ import { buttonClasses } from '@/components/ui';
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Every route in, in the same order as /sign-in. This screen used to offer only
|
||||
* the phone, which made the social options look like something the product had
|
||||
* dropped: somebody who signed up with Google would land here, see one button
|
||||
* that was not the one they used, and have no way in short of guessing.
|
||||
*/
|
||||
export function SignedOut({ title, body }: { title: string; body: string }) {
|
||||
return (
|
||||
@@ -16,9 +22,17 @@ export function SignedOut({ title, body }: { title: string; body: string }) {
|
||||
<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 className="flex w-full max-w-72 flex-col gap-2">
|
||||
<Link
|
||||
href="/sign-in?next=/"
|
||||
className={buttonClasses({ variant: 'primary', size: 'md', block: true })}
|
||||
>
|
||||
Continue with phone
|
||||
</Link>
|
||||
{/* Lands back on the app, not on /sign-in — they never went there. */}
|
||||
<SocialSignIn callbackURL="/" size="md" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { AnimatePresence, motion, useMotionValue, useTransform } from 'motion/react';
|
||||
import { Check, MapPin, Star, X } from 'lucide-react';
|
||||
import type { DeckCard } from '@linkder/db';
|
||||
@@ -9,21 +9,54 @@ import { cn, formatDistance, formatResponseTime } from '@/lib/utils';
|
||||
/** Horizontal drag past this many pixels commits the swipe. */
|
||||
const COMMIT_PX = 110;
|
||||
|
||||
/**
|
||||
* What a handler can say about a swipe it was given.
|
||||
*
|
||||
* `revert` puts the card back. It exists because a right swipe does not always
|
||||
* complete on its own: on the entry deck it opens a sheet asking which job to
|
||||
* send, and someone who closes that sheet must not lose the pro they just
|
||||
* picked. Returning nothing means "committed", which is what the per-job deck
|
||||
* does — there the swipe IS the send.
|
||||
*/
|
||||
export type SwipeVerdict = 'commit' | 'revert';
|
||||
|
||||
export interface DeckProps {
|
||||
cards: DeckCard[];
|
||||
onDecide: (proId: string, direction: 'left' | 'right') => void | Promise<void>;
|
||||
onDecide: (
|
||||
proId: string,
|
||||
direction: 'left' | 'right',
|
||||
) => void | Promise<void | SwipeVerdict>;
|
||||
}
|
||||
|
||||
export function Deck({ cards, onDecide }: DeckProps) {
|
||||
const [index, setIndex] = useState(0);
|
||||
const remaining = useMemo(() => cards.slice(index), [cards, index]);
|
||||
|
||||
// One decision at a time. Without this, a second swipe landing while a sheet
|
||||
// is open would advance past a card nobody ever saw.
|
||||
const inFlight = useRef(false);
|
||||
|
||||
const decide = useCallback(
|
||||
(proId: string, direction: 'left' | 'right') => {
|
||||
async (proId: string, direction: 'left' | 'right') => {
|
||||
if (inFlight.current) return;
|
||||
inFlight.current = true;
|
||||
|
||||
// The card leaves first and comes back only if refused. Waiting for the
|
||||
// handler before animating would make every swipe feel like it stuck.
|
||||
setIndex((i) => i + 1);
|
||||
void onDecide(proId, direction);
|
||||
try {
|
||||
const verdict = await onDecide(proId, direction);
|
||||
if (verdict !== 'revert') return;
|
||||
|
||||
// Restore exactly that card rather than stepping the index back — a
|
||||
// blind decrement would put back whichever card happened to be behind.
|
||||
const at = cards.findIndex((c) => c.proId === proId);
|
||||
if (at >= 0) setIndex((i) => Math.min(i, at));
|
||||
} finally {
|
||||
inFlight.current = false;
|
||||
}
|
||||
},
|
||||
[onDecide],
|
||||
[cards, onDecide],
|
||||
);
|
||||
|
||||
if (remaining.length === 0) {
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { AlertTriangle, Check } from 'lucide-react';
|
||||
import type { DeckCard } from '@linkder/db';
|
||||
import { api } from '@/lib/trpc';
|
||||
import { SocialSignIn } from '@/components/auth/social-sign-in';
|
||||
import { Banner, Button, Sheet } from '@/components/ui';
|
||||
import { setPendingHire } from '@/lib/pending-hire';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* "Send this job to Marc" — the thing a right swipe on the entry deck opens.
|
||||
*
|
||||
* A request needs a job and the entry deck has none, so this is where that gets
|
||||
* decided. It is deliberately the ONLY new place a job gets sent: picking a job
|
||||
* here calls the same `deck.swipe` the per-job deck calls, so the open-request
|
||||
* cap, the row lock and the verification check all still apply exactly once.
|
||||
*/
|
||||
export function SendJobSheet({
|
||||
pro,
|
||||
open,
|
||||
onResolved,
|
||||
}: {
|
||||
pro: DeckCard | null;
|
||||
open: boolean;
|
||||
/**
|
||||
* `sent` when the pro now has the job — the card should stay gone.
|
||||
* `dismissed` when nothing happened and the card should come back.
|
||||
*/
|
||||
onResolved: (outcome: 'sent' | 'dismissed') => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [chosen, setChosen] = useState<string | null>(null);
|
||||
|
||||
const me = api.user.me.useQuery(undefined, { retry: false });
|
||||
const sendable = api.deck.sendable.useQuery(
|
||||
{ proId: pro?.proId ?? '' },
|
||||
// Only ask once there is somebody to ask about, and only for a client — a
|
||||
// pro browsing the deck gets FORBIDDEN from this procedure by design.
|
||||
{ enabled: open && Boolean(pro) && me.data?.role === 'client', retry: false },
|
||||
);
|
||||
|
||||
const utils = api.useUtils();
|
||||
const swipe = api.deck.swipe.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.job.mine.invalidate();
|
||||
void utils.deck.sendable.invalidate();
|
||||
onResolved('sent');
|
||||
},
|
||||
});
|
||||
|
||||
if (!pro) return null;
|
||||
|
||||
// DeckCard.name is nullable. Every line of copy below names this person, and
|
||||
// "Send a job to null" is worse than a generic noun.
|
||||
const name = pro.name ?? 'this pro';
|
||||
|
||||
const close = () => {
|
||||
setChosen(null);
|
||||
swipe.reset();
|
||||
onResolved('dismissed');
|
||||
};
|
||||
|
||||
/* ── anonymous ── */
|
||||
if (!me.isLoading && (me.error || !me.data)) {
|
||||
return (
|
||||
<Sheet
|
||||
open={open}
|
||||
onClose={close}
|
||||
title={`Send a job to ${name}`}
|
||||
body="Sign in first — we need to know whose job it is before we send it."
|
||||
actions={
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
size="lg"
|
||||
block
|
||||
onClick={() => {
|
||||
// Parked so the deck can resume here once they are back.
|
||||
setPendingHire({ proId: pro.proId, name });
|
||||
router.push('/sign-in?next=/');
|
||||
}}
|
||||
>
|
||||
Continue with phone
|
||||
</Button>
|
||||
{/*
|
||||
Every route in, same as /sign-in. A social click sends the browser
|
||||
away immediately, so the intent has to be parked BEFORE it can
|
||||
land — hence onBeforeStart rather than an onClick racing a
|
||||
redirect that is already in flight.
|
||||
*/}
|
||||
<SocialSignIn
|
||||
callbackURL="/"
|
||||
onBeforeStart={() => setPendingHire({ proId: pro.proId, name })}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<p className="text-body-sm text-muted">
|
||||
Nobody is contacted until you pick a job and send it.
|
||||
</p>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── a pro is browsing ── */
|
||||
if (me.data?.role === 'pro') {
|
||||
return (
|
||||
<Sheet
|
||||
open={open}
|
||||
onClose={close}
|
||||
title="You are signed in as a pro"
|
||||
body="Hiring needs a customer account. You can still browse who else is on here."
|
||||
actions={
|
||||
<Button variant="outline" size="lg" block onClick={close}>
|
||||
Back to the deck
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const loading = me.isLoading || sendable.isLoading;
|
||||
const data = sendable.data;
|
||||
const jobs = data?.jobs ?? [];
|
||||
const sendableJobs = jobs.filter((j) => !j.alreadySent && !j.atCap);
|
||||
|
||||
/* ── nothing to send ── */
|
||||
if (!loading && sendableJobs.length === 0) {
|
||||
const blockedBySent = jobs.some((j) => j.alreadySent);
|
||||
const blockedByCap = jobs.some((j) => j.atCap);
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
open={open}
|
||||
onClose={close}
|
||||
title={
|
||||
blockedBySent && jobs.length === 1
|
||||
? `${name} already has this job`
|
||||
: `Post a job for ${name}`
|
||||
}
|
||||
body={
|
||||
blockedBySent && jobs.length === 1
|
||||
? 'They have not replied yet. You will hear as soon as they do.'
|
||||
: blockedByCap
|
||||
? `Every job you have open already has ${data?.cap} pros considering it. Wait for a reply, or post a new job.`
|
||||
: 'You have no jobs open yet. Tell us what needs doing and we will send it straight to them.'
|
||||
}
|
||||
actions={
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
size="lg"
|
||||
block
|
||||
onClick={() => {
|
||||
setPendingHire({ proId: pro.proId, name });
|
||||
router.push(`/jobs/new?pro=${pro.proId}`);
|
||||
}}
|
||||
>
|
||||
Post a job
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" block onClick={close}>
|
||||
Not now
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const target = jobs.find((j) => j.id === chosen);
|
||||
// One open job needs no picking — the question answers itself.
|
||||
const only = sendableJobs.length === 1 ? sendableJobs[0] : null;
|
||||
const selected = target ?? only ?? null;
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
open={open}
|
||||
onClose={close}
|
||||
title={`Send a job to ${name}`}
|
||||
body={
|
||||
only
|
||||
? 'This goes straight to them. They have a limited time to accept.'
|
||||
: 'Which job is this for?'
|
||||
}
|
||||
actions={
|
||||
<div className="flex flex-col gap-2">
|
||||
{swipe.error && (
|
||||
<p role="alert" className="text-meta text-stop-500">
|
||||
{swipe.error.message}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
size="lg"
|
||||
block
|
||||
busy={swipe.isPending}
|
||||
disabled={!selected || loading}
|
||||
onClick={() =>
|
||||
selected &&
|
||||
swipe.mutate({ jobId: selected.id, proId: pro.proId, direction: 'right' })
|
||||
}
|
||||
>
|
||||
{selected ? `Send “${truncate(selected.title)}”` : 'Pick a job'}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" block onClick={close}>
|
||||
Not now
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="h-16 animate-pulse rounded-lg bg-inset" />
|
||||
<div className="h-16 animate-pulse rounded-lg bg-inset" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Shown, not hidden: the client picked this person on purpose and may
|
||||
know something the trade list does not. But it should be a choice
|
||||
made with open eyes. */}
|
||||
{selected && !selected.tradeMatches && (
|
||||
<Banner tone="warning" title="Different trade" className="mb-3">
|
||||
{name} is not listed for {selected.categoryName.toLowerCase()} work. You can
|
||||
still send it — they may just turn it down.
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
{!only && (
|
||||
<ul
|
||||
role="radiogroup"
|
||||
aria-label="Which job"
|
||||
className="flex flex-col gap-2 pb-1"
|
||||
>
|
||||
{jobs.map((job) => {
|
||||
const blocked = job.alreadySent || job.atCap;
|
||||
const isSelected = selected?.id === job.id;
|
||||
|
||||
return (
|
||||
<li key={job.id}>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={isSelected}
|
||||
disabled={blocked}
|
||||
onClick={() => setChosen(job.id)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-lg border-[1.5px] p-4 text-left',
|
||||
'transition-[border-color,background-color] duration-[120ms] ease-standard',
|
||||
'disabled:pointer-events-none disabled:opacity-45',
|
||||
isSelected
|
||||
? 'border-brand-500 bg-brand-50 dark:bg-accent-soft'
|
||||
: 'border-hairline hover:border-brand-500',
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-display text-h4 text-strong">
|
||||
{job.title}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-meta text-muted">
|
||||
{job.alreadySent
|
||||
? `${name} already has this one`
|
||||
: job.atCap
|
||||
? `${data?.cap} pros already considering it`
|
||||
: `${job.categoryName} · ${job.pendingCount} of ${data?.cap} sent`}
|
||||
</span>
|
||||
</span>
|
||||
{isSelected && (
|
||||
<Check className="h-5 w-5 shrink-0 text-accent" aria-hidden />
|
||||
)}
|
||||
{!job.tradeMatches && !blocked && (
|
||||
<AlertTriangle className="h-4 w-4 shrink-0 text-sun-500" aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{only && (
|
||||
<div className="rounded-lg border border-hairline p-4">
|
||||
<span className="block truncate font-display text-h4 text-strong">
|
||||
{only.title}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-meta text-muted">
|
||||
{only.categoryName} · {only.pendingCount} of {data?.cap} pros sent
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
function truncate(value: string, max = 24): string {
|
||||
return value.length <= max ? value : `${value.slice(0, max - 1)}…`;
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
import { FileText, Paperclip, X } from 'lucide-react';
|
||||
import { api } from '@/lib/trpc';
|
||||
import { uploadFile } from '@/lib/upload';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/** The schema's ceiling. Enforced here too so the picker refuses before uploading. */
|
||||
const MAX_ATTACHMENTS = 5;
|
||||
|
||||
const ACCEPT = 'image/jpeg,image/png,image/webp,application/pdf';
|
||||
|
||||
export interface PendingAttachment {
|
||||
/** The public R2 URL, once the bytes are up. */
|
||||
url: string;
|
||||
name: string;
|
||||
isImage: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaching files to a message.
|
||||
*
|
||||
* A hook and two dumb components rather than one, because the two halves belong
|
||||
* in different places: the previews sit above the composer and the paperclip
|
||||
* sits inside it, beside the text box. One component cannot be in both.
|
||||
*
|
||||
* Uploads happen on PICK, not on send: a 12 MB photo takes seconds on a phone,
|
||||
* and doing it inside the send handler leaves the send button spinning with
|
||||
* nothing to show for it. By the time a caption is typed the bytes are usually
|
||||
* already in R2, and `send` is just a row insert with URLs in it.
|
||||
*/
|
||||
export function useAttachments(
|
||||
attachments: PendingAttachment[],
|
||||
onChange: (next: PendingAttachment[]) => void,
|
||||
) {
|
||||
const [busy, setBusy] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const presign = api.upload.presign.useMutation();
|
||||
|
||||
// The upload loop appends across awaits, so it cannot close over the array it
|
||||
// was rendered with — two files would each overwrite the other's result.
|
||||
const latest = useRef(attachments);
|
||||
latest.current = attachments;
|
||||
|
||||
async function pick(files: FileList | null) {
|
||||
if (!files?.length) return;
|
||||
setError(null);
|
||||
|
||||
const room = MAX_ATTACHMENTS - latest.current.length - busy;
|
||||
const chosen = Array.from(files).slice(0, Math.max(0, room));
|
||||
if (files.length > chosen.length) {
|
||||
setError(`You can attach ${MAX_ATTACHMENTS} files to a message.`);
|
||||
}
|
||||
if (!chosen.length) return;
|
||||
|
||||
setBusy((n) => n + chosen.length);
|
||||
|
||||
// Sequential rather than parallel: these are phone photos on a phone
|
||||
// connection, and five at once is how you get five timeouts.
|
||||
for (const file of chosen) {
|
||||
try {
|
||||
const url = await uploadFile(file, 'message_attachment', (i) => presign.mutateAsync(i));
|
||||
const next = [
|
||||
...latest.current,
|
||||
{ url, name: file.name, isImage: file.type.startsWith('image/') },
|
||||
];
|
||||
latest.current = next;
|
||||
onChange(next);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'That file would not upload.');
|
||||
} finally {
|
||||
setBusy((n) => n - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const remove = (url: string) => {
|
||||
const next = latest.current.filter((a) => a.url !== url);
|
||||
latest.current = next;
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
return {
|
||||
pick,
|
||||
remove,
|
||||
busy,
|
||||
error,
|
||||
full: attachments.length + busy >= MAX_ATTACHMENTS,
|
||||
};
|
||||
}
|
||||
|
||||
export type Attachments = ReturnType<typeof useAttachments>;
|
||||
|
||||
/** Sits above the composer. Renders nothing when there is nothing to show. */
|
||||
export function AttachmentPreviews({
|
||||
attachments,
|
||||
state,
|
||||
}: {
|
||||
attachments: PendingAttachment[];
|
||||
state: Attachments;
|
||||
}) {
|
||||
if (attachments.length === 0 && state.busy === 0 && !state.error) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-2 flex flex-col gap-2">
|
||||
{(attachments.length > 0 || state.busy > 0) && (
|
||||
<ul className="flex flex-wrap gap-2">
|
||||
{attachments.map((a) => (
|
||||
<li key={a.url} className="relative">
|
||||
{a.isImage ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- remote R2 object, no loader configured
|
||||
<img
|
||||
src={a.url}
|
||||
alt={a.name}
|
||||
className="h-16 w-16 rounded-md border border-hairline object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex h-16 w-16 flex-col items-center justify-center gap-1 rounded-md border border-hairline bg-inset px-1">
|
||||
<FileText className="h-5 w-5 text-muted" aria-hidden />
|
||||
<span className="w-full truncate text-center text-[0.625rem] text-faint">
|
||||
{a.name}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => state.remove(a.url)}
|
||||
aria-label={`Remove ${a.name}`}
|
||||
className="absolute -right-1.5 -top-1.5 flex h-6 w-6 items-center justify-center rounded-pill bg-ink-950 text-white ring-2 ring-page"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" aria-hidden />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{Array.from({ length: state.busy }, (_, i) => (
|
||||
<li
|
||||
key={`pending-${i}`}
|
||||
className="h-16 w-16 animate-pulse rounded-md bg-inset"
|
||||
aria-label="Uploading"
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{state.error && (
|
||||
<p role="alert" className="text-meta text-stop-500">
|
||||
{state.error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Sits in the composer row, left of the text box. */
|
||||
export function AttachmentButton({
|
||||
state,
|
||||
disabled,
|
||||
}: {
|
||||
state: Attachments;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
ref={input}
|
||||
type="file"
|
||||
multiple
|
||||
accept={ACCEPT}
|
||||
className="sr-only"
|
||||
onChange={(e) => {
|
||||
void state.pick(e.target.files);
|
||||
// Reset, or picking the same file twice in a row fires no change event.
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => input.current?.click()}
|
||||
disabled={disabled || state.full}
|
||||
aria-label={state.full ? `Attachment limit of ${MAX_ATTACHMENTS} reached` : 'Attach a file'}
|
||||
className={cn(
|
||||
'flex h-12 w-12 shrink-0 items-center justify-center rounded-pill text-muted',
|
||||
'transition-colors duration-[120ms] ease-standard hover:text-accent',
|
||||
'disabled:pointer-events-none disabled:opacity-45',
|
||||
)}
|
||||
>
|
||||
<Paperclip className="h-5 w-5" aria-hidden />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attachments on a sent message.
|
||||
*
|
||||
* Images render inline — a photo of the leak is the message, and making someone
|
||||
* tap a filename to see it defeats the point. Anything else is a named link,
|
||||
* because a PDF has no useful thumbnail.
|
||||
*/
|
||||
export function SentAttachments({ urls, isMine }: { urls: readonly string[]; isMine: boolean }) {
|
||||
if (urls.length === 0) return null;
|
||||
|
||||
return (
|
||||
<ul className={cn('mt-1 flex flex-wrap gap-1.5', isMine ? 'justify-end' : 'justify-start')}>
|
||||
{urls.map((url) => {
|
||||
const name = decodeURIComponent(url.split('/').pop() ?? 'file');
|
||||
const isImage = /\.(jpe?g|png|webp|heic)$/i.test(url);
|
||||
|
||||
return (
|
||||
<li key={url}>
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="block rounded-md border border-hairline focus:outline-none focus:ring-[3px] focus:ring-brand-200"
|
||||
>
|
||||
{isImage ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- remote R2 object, no loader configured
|
||||
<img
|
||||
src={url}
|
||||
alt="Attachment"
|
||||
className="h-40 w-40 rounded-md object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex h-14 items-center gap-2 rounded-md bg-inset px-3 text-body-sm text-strong">
|
||||
<FileText className="h-4 w-4 shrink-0 text-muted" aria-hidden />
|
||||
<span className="max-w-40 truncate">{name}</span>
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { ChevronLeft, SendHorizontal } from 'lucide-react';
|
||||
import { api } from '@/lib/trpc';
|
||||
import {
|
||||
AttachmentButton,
|
||||
AttachmentPreviews,
|
||||
SentAttachments,
|
||||
useAttachments,
|
||||
type PendingAttachment,
|
||||
} from './attachment-tray';
|
||||
import { DealStrip } from './deal-strip';
|
||||
import { cn, formatRelativeTime } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* The conversation between the two parties on a job.
|
||||
*
|
||||
* Full height inside the phone frame rather than a scrolling page: the composer
|
||||
* has to stay on the thumb, and a chat whose input scrolls away with the history
|
||||
* is a chat you have to hunt for.
|
||||
*/
|
||||
export function ChatThread({ matchId, onBack }: { matchId: string; onBack: () => void }) {
|
||||
const utils = api.useUtils();
|
||||
const [draft, setDraft] = useState('');
|
||||
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
|
||||
const attach = useAttachments(attachments, setAttachments);
|
||||
const bottom = useRef<HTMLDivElement>(null);
|
||||
|
||||
const thread = api.message.thread.useQuery(
|
||||
{ matchId },
|
||||
{
|
||||
// Polled while open. Four seconds is short enough to feel live and cheap
|
||||
// enough to run without a socket; the real push lands with M4.
|
||||
refetchInterval: 4_000,
|
||||
// Keep the messages on screen through a refetch — a chat that blanks every
|
||||
// four seconds is unusable.
|
||||
placeholderData: (previous) => previous,
|
||||
retry: false,
|
||||
},
|
||||
);
|
||||
|
||||
const markRead = api.message.markRead.useMutation({
|
||||
onSuccess: ({ read }) => {
|
||||
if (read === 0) return;
|
||||
// Only invalidate when something actually changed, or the 4s poll would
|
||||
// drag the whole jobs list along behind it.
|
||||
void utils.job.mine.invalidate();
|
||||
void utils.job.mineForPro.invalidate();
|
||||
void utils.job.matches.invalidate();
|
||||
void utils.message.unreadTotal.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const send = api.message.send.useMutation({
|
||||
onSuccess: () => {
|
||||
setDraft('');
|
||||
setAttachments([]);
|
||||
void utils.message.thread.invalidate({ matchId });
|
||||
void utils.job.mine.invalidate();
|
||||
void utils.job.mineForPro.invalidate();
|
||||
void utils.job.matches.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const messages = thread.data?.messages ?? [];
|
||||
const match = thread.data?.match;
|
||||
const newestId = messages[messages.length - 1]?.id;
|
||||
|
||||
// Read receipts follow what is actually on screen: mark on open, and again
|
||||
// whenever a new message arrives while the thread is in front of the reader.
|
||||
const unreadFromPeer = messages.some((m) => !m.isMine && m.readAt === null);
|
||||
useEffect(() => {
|
||||
if (!unreadFromPeer || markRead.isPending) return;
|
||||
markRead.mutate({ matchId });
|
||||
// `newestId` is the trigger: re-running on every render would loop.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [matchId, newestId, unreadFromPeer]);
|
||||
|
||||
// Stick to the bottom as messages land. `auto` rather than `smooth` on first
|
||||
// paint, or the thread visibly scrolls itself on open.
|
||||
useEffect(() => {
|
||||
bottom.current?.scrollIntoView({ block: 'end' });
|
||||
}, [newestId]);
|
||||
|
||||
// A photo with no caption is a message. The schema agrees — see sendMessageSchema.
|
||||
const hasContent = draft.trim().length > 0 || attachments.length > 0;
|
||||
const canSend = Boolean(match?.canReply) && hasContent && !send.isPending;
|
||||
|
||||
const submit = () => {
|
||||
if (!canSend) return;
|
||||
send.mutate({ matchId, body: draft, attachments: attachments.map((a) => a.url) });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{/* Header. Pinned, because "who am I talking to, about what" is the one
|
||||
thing you must be able to check mid-scroll. */}
|
||||
<div className="flex shrink-0 items-center gap-1 border-b border-hairline px-2 pb-2 pt-[3.25rem]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
aria-label="Back"
|
||||
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-pill text-accent"
|
||||
>
|
||||
<ChevronLeft className="h-6 w-6" aria-hidden />
|
||||
</button>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-display text-h4 text-strong">
|
||||
{match?.peer?.name ?? 'Conversation'}
|
||||
</span>
|
||||
<span className="block truncate text-meta text-muted">{match?.jobTitle ?? ''}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
|
||||
{thread.isLoading ? (
|
||||
<div className="flex flex-col gap-3" aria-busy>
|
||||
<div className="h-10 w-2/3 animate-pulse rounded-card bg-inset" />
|
||||
<div className="h-10 w-1/2 animate-pulse self-end rounded-card bg-inset" />
|
||||
<div className="h-10 w-3/5 animate-pulse rounded-card bg-inset" />
|
||||
</div>
|
||||
) : thread.error ? (
|
||||
<p className="mt-8 text-center text-body-sm text-muted">
|
||||
This conversation is not available.
|
||||
</p>
|
||||
) : messages.length === 0 ? (
|
||||
<p className="mt-8 text-center text-body-sm text-muted">
|
||||
No messages yet. Say hello — agree what the job involves and when.
|
||||
</p>
|
||||
) : (
|
||||
<ol className="flex flex-col gap-2">
|
||||
{messages.map((message, i) => (
|
||||
<Bubble
|
||||
key={message.id}
|
||||
body={message.body}
|
||||
attachments={message.attachments}
|
||||
createdAt={message.createdAt}
|
||||
isMine={message.isMine}
|
||||
// The read marker belongs on the last thing I said, not on
|
||||
// every bubble — twenty ticks down a thread is noise.
|
||||
showRead={
|
||||
message.isMine &&
|
||||
message.readAt !== null &&
|
||||
!messages.slice(i + 1).some((m) => m.isMine)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
<div ref={bottom} />
|
||||
</div>
|
||||
|
||||
{/* The deal, above the composer: quoting and booking happen in the
|
||||
conversation they are being discussed in, not on a screen of their own. */}
|
||||
{match && (
|
||||
<DealStrip
|
||||
matchId={matchId}
|
||||
// The peer's role, inverted — if I am talking to a pro, I am the client.
|
||||
isPro={match.peer?.role !== 'pro'}
|
||||
canAct={match.canReply}
|
||||
/>
|
||||
)}
|
||||
|
||||
{match && !match.canReply ? (
|
||||
<p className="shrink-0 border-t border-hairline px-4 py-4 text-center text-body-sm text-muted">
|
||||
{match.jobStatus === 'cancelled'
|
||||
? 'This job was cancelled. The conversation is closed.'
|
||||
: 'This job is finished. The conversation is closed.'}
|
||||
</p>
|
||||
) : (
|
||||
<div className="shrink-0 border-t border-hairline px-4 pb-2 pt-2">
|
||||
<AttachmentPreviews attachments={attachments} state={attach} />
|
||||
<div className="flex items-end gap-1">
|
||||
<label className="sr-only" htmlFor="chat-composer">
|
||||
Message
|
||||
</label>
|
||||
<AttachmentButton state={attach} disabled={send.isPending} />
|
||||
<textarea
|
||||
id="chat-composer"
|
||||
rows={1}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// Enter sends, Shift+Enter breaks the line. On a phone the
|
||||
// on-screen keyboard sends a plain Enter, which is what we want.
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
}}
|
||||
placeholder="Message"
|
||||
className={cn(
|
||||
'max-h-28 min-h-12 flex-1 resize-none rounded-lg border-[1.5px] border-hairline bg-raised',
|
||||
'px-4 py-3 text-base text-strong placeholder:text-faint',
|
||||
'focus:border-brand-500 focus:outline-none focus:ring-[3px] focus:ring-brand-200',
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={!canSend}
|
||||
aria-label="Send"
|
||||
className={cn(
|
||||
'flex h-12 w-12 shrink-0 items-center justify-center rounded-pill bg-brand-500 text-white',
|
||||
'transition-[opacity,background-color] duration-[120ms] ease-standard',
|
||||
'hover:bg-brand-600 disabled:pointer-events-none disabled:opacity-45',
|
||||
)}
|
||||
>
|
||||
<SendHorizontal className="h-5 w-5" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
{send.error && (
|
||||
<p role="alert" className="mt-2 text-meta text-stop-500">
|
||||
{send.error.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Bubble({
|
||||
body,
|
||||
attachments,
|
||||
createdAt,
|
||||
isMine,
|
||||
showRead,
|
||||
}: {
|
||||
body: string;
|
||||
attachments: readonly string[];
|
||||
createdAt: Date;
|
||||
isMine: boolean;
|
||||
showRead: boolean;
|
||||
}) {
|
||||
return (
|
||||
<li className={cn('flex flex-col', isMine ? 'items-end' : 'items-start')}>
|
||||
{/* An attachment with no caption gets no empty bubble above it. */}
|
||||
{body.length > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
'max-w-[80%] whitespace-pre-wrap break-words rounded-card px-4 py-2.5 text-body-sm',
|
||||
isMine ? 'bg-brand-500 text-white' : 'bg-inset text-strong',
|
||||
)}
|
||||
>
|
||||
{body}
|
||||
</div>
|
||||
)}
|
||||
<SentAttachments urls={attachments} isMine={isMine} />
|
||||
<span className="mt-0.5 px-1 text-meta text-faint tabular-nums">
|
||||
{formatRelativeTime(createdAt)}
|
||||
{showRead && ' · Read'}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { CalendarClock, CheckCircle2, FileText } from 'lucide-react';
|
||||
import { formatCents } from '@linkder/shared';
|
||||
import { api } from '@/lib/trpc';
|
||||
import { Banner, Button, Input, Sheet, Textarea } from '@/components/ui';
|
||||
import { cn, formatWhen } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* The commercial state of one conversation, above the composer.
|
||||
*
|
||||
* A thread is where the deal actually happens, so this is where quoting,
|
||||
* booking and confirming live rather than on a screen of their own — asking
|
||||
* someone to leave the conversation to accept the price they are discussing is
|
||||
* how a funnel loses people.
|
||||
*
|
||||
* It renders exactly one thing: the newest live quote, or the current booking,
|
||||
* or nothing. Two open offers on one thread would be a customer choosing between
|
||||
* two prices from the same person, and the server refuses to create that
|
||||
* (quote.create withdraws the previous), so the UI never has to represent it.
|
||||
*/
|
||||
export function DealStrip({
|
||||
matchId,
|
||||
/** Whose side the viewer is on. The peer's role, inverted. */
|
||||
isPro,
|
||||
canAct,
|
||||
}: {
|
||||
matchId: string;
|
||||
isPro: boolean;
|
||||
/** False once the job is history — the thread stays readable, nothing moves. */
|
||||
canAct: boolean;
|
||||
}) {
|
||||
const utils = api.useUtils();
|
||||
const [quoting, setQuoting] = useState(false);
|
||||
const [booking, setBooking] = useState<{ quoteId: string; amountCents: number } | null>(null);
|
||||
|
||||
const quotes = api.quote.forMatch.useQuery({ matchId }, { retry: false });
|
||||
const bookings = api.booking.forMatch.useQuery({ matchId }, { retry: false });
|
||||
|
||||
const refresh = () => {
|
||||
void utils.quote.forMatch.invalidate({ matchId });
|
||||
void utils.booking.forMatch.invalidate({ matchId });
|
||||
void utils.message.thread.invalidate({ matchId });
|
||||
void utils.job.mine.invalidate();
|
||||
void utils.job.mineForPro.invalidate();
|
||||
void utils.review.pending.invalidate();
|
||||
};
|
||||
|
||||
const decline = api.quote.decline.useMutation({ onSuccess: refresh });
|
||||
const start = api.booking.start.useMutation({ onSuccess: refresh });
|
||||
const markComplete = api.booking.markComplete.useMutation({ onSuccess: refresh });
|
||||
const confirm = api.booking.confirm.useMutation({ onSuccess: refresh });
|
||||
|
||||
// Newest first from the server. The live one is the only one worth showing.
|
||||
const liveQuote = quotes.data?.find((q) => q.isLive) ?? null;
|
||||
const activeBooking =
|
||||
bookings.data?.find((b) => b.status !== 'cancelled' && b.status !== 'completed') ?? null;
|
||||
|
||||
if (!canAct) return null;
|
||||
|
||||
/* ── a booking exists: the deal is done, this is progress ── */
|
||||
if (activeBooking) {
|
||||
return (
|
||||
<div className="shrink-0 border-t border-hairline px-4 py-3">
|
||||
<div className="flex items-center gap-2 text-body-sm text-strong">
|
||||
<CalendarClock className="h-4 w-4 shrink-0 text-accent" aria-hidden />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{formatWhen(activeBooking.scheduledStart)}
|
||||
</span>
|
||||
<StatusPill status={activeBooking.status} />
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex gap-2">
|
||||
{isPro && activeBooking.status === 'scheduled' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
block
|
||||
busy={start.isPending}
|
||||
onClick={() => start.mutate({ bookingId: activeBooking.id })}
|
||||
>
|
||||
I have started
|
||||
</Button>
|
||||
)}
|
||||
{isPro && activeBooking.status !== 'awaiting_confirmation' && (
|
||||
<Button
|
||||
size="sm"
|
||||
block
|
||||
busy={markComplete.isPending}
|
||||
onClick={() => markComplete.mutate({ bookingId: activeBooking.id })}
|
||||
>
|
||||
Mark as done
|
||||
</Button>
|
||||
)}
|
||||
{!isPro && activeBooking.status === 'awaiting_confirmation' && (
|
||||
<Button
|
||||
size="sm"
|
||||
block
|
||||
busy={confirm.isPending}
|
||||
onClick={() => confirm.mutate({ bookingId: activeBooking.id })}
|
||||
>
|
||||
Confirm it is done
|
||||
</Button>
|
||||
)}
|
||||
{!isPro && activeBooking.status !== 'awaiting_confirmation' && (
|
||||
<p className="py-1 text-meta text-muted">
|
||||
You will be asked to confirm once they mark it done.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── a live quote: the client's decision ── */
|
||||
if (liveQuote) {
|
||||
return (
|
||||
<>
|
||||
<div className="shrink-0 border-t border-hairline bg-inset px-4 py-3">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<FileText className="h-4 w-4 shrink-0 self-center text-accent" aria-hidden />
|
||||
<span className="font-display text-h4 text-strong tabular-nums">
|
||||
{formatCents(liveQuote.amountCents)}
|
||||
</span>
|
||||
<span className="text-meta text-muted">
|
||||
{liveQuote.kind === 'hourly' ? 'estimate' : 'fixed price'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 text-body-sm text-muted">{liveQuote.scope}</p>
|
||||
|
||||
<div className="mt-2 flex gap-2">
|
||||
{isPro ? (
|
||||
<p className="text-meta text-faint">Sent — waiting on them.</p>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
block
|
||||
onClick={() =>
|
||||
setBooking({ quoteId: liveQuote.id, amountCents: liveQuote.amountCents })
|
||||
}
|
||||
>
|
||||
Accept and book
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
block
|
||||
busy={decline.isPending}
|
||||
onClick={() => decline.mutate({ quoteId: liveQuote.id })}
|
||||
>
|
||||
No thanks
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BookSheet
|
||||
matchId={matchId}
|
||||
quote={booking}
|
||||
onClose={() => setBooking(null)}
|
||||
onBooked={refresh}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── nothing yet ── */
|
||||
if (!isPro) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="shrink-0 border-t border-hairline px-4 py-2">
|
||||
<Button variant="outline" size="sm" block onClick={() => setQuoting(true)}>
|
||||
<FileText className="h-4 w-4" aria-hidden />
|
||||
Send a quote
|
||||
</Button>
|
||||
</div>
|
||||
<QuoteSheet
|
||||
matchId={matchId}
|
||||
open={quoting}
|
||||
onClose={() => setQuoting(false)}
|
||||
onSent={refresh}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusPill({ status }: { status: string }) {
|
||||
const label =
|
||||
status === 'in_progress'
|
||||
? 'In progress'
|
||||
: status === 'awaiting_confirmation'
|
||||
? 'Waiting on you'
|
||||
: 'Booked';
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 rounded-pill border px-2.5 py-0.5 text-meta',
|
||||
status === 'awaiting_confirmation'
|
||||
? 'border-sun-100 bg-sun-50 text-sun-600'
|
||||
: 'border-go-100 bg-go-50 text-go-700',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** The pro's side: a price and what it covers. */
|
||||
function QuoteSheet({
|
||||
matchId,
|
||||
open,
|
||||
onClose,
|
||||
onSent,
|
||||
}: {
|
||||
matchId: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSent: () => void;
|
||||
}) {
|
||||
const [amount, setAmount] = useState('');
|
||||
const [scope, setScope] = useState('');
|
||||
|
||||
const create = api.quote.create.useMutation({
|
||||
onSuccess: () => {
|
||||
setAmount('');
|
||||
setScope('');
|
||||
onSent();
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
const cents = Math.round(Number(amount) * 100);
|
||||
const valid = Number.isFinite(cents) && cents >= 500 && scope.trim().length >= 10;
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Send a quote"
|
||||
body="One price at a time — sending a new one replaces whatever is on the table."
|
||||
actions={
|
||||
<div className="flex flex-col gap-2">
|
||||
{create.error && (
|
||||
<p role="alert" className="text-meta text-stop-500">
|
||||
{create.error.message}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
size="lg"
|
||||
block
|
||||
busy={create.isPending}
|
||||
disabled={!valid}
|
||||
onClick={() =>
|
||||
create.mutate({ matchId, kind: 'fixed', amountCents: cents, scope })
|
||||
}
|
||||
>
|
||||
Send it
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<label className="mb-4 flex flex-col gap-2">
|
||||
<span className="text-body-sm text-strong">Price (€)</span>
|
||||
<Input
|
||||
value={amount}
|
||||
inputMode="decimal"
|
||||
placeholder="120"
|
||||
onChange={(e) => setAmount(e.target.value.replace(/[^0-9.]/g, ''))}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-2">
|
||||
<span className="text-body-sm text-strong">What it covers</span>
|
||||
<Textarea
|
||||
rows={4}
|
||||
value={scope}
|
||||
maxLength={2000}
|
||||
onChange={(e) => setScope(e.target.value)}
|
||||
placeholder="Parts, labour, how long you expect it to take, anything not included."
|
||||
/>
|
||||
</label>
|
||||
<p className="mt-1 text-meta text-faint">
|
||||
This is what a dispute would be judged against, so be specific.
|
||||
</p>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
/** The client's side: pick when. Accepting is what creates the booking. */
|
||||
function BookSheet({
|
||||
matchId,
|
||||
quote,
|
||||
onClose,
|
||||
onBooked,
|
||||
}: {
|
||||
matchId: string;
|
||||
quote: { quoteId: string; amountCents: number } | null;
|
||||
onClose: () => void;
|
||||
onBooked: () => void;
|
||||
}) {
|
||||
const [when, setWhen] = useState('');
|
||||
const [hours, setHours] = useState('2');
|
||||
|
||||
const accept = api.quote.accept.useMutation({
|
||||
onSuccess: () => {
|
||||
setWhen('');
|
||||
onBooked();
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
if (!quote) return null;
|
||||
|
||||
const start = when ? new Date(when) : null;
|
||||
const valid = start !== null && !Number.isNaN(start.getTime()) && start.getTime() > Date.now();
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
open={quote !== null}
|
||||
onClose={onClose}
|
||||
title="When suits you?"
|
||||
body={`Booking ${formatCents(quote.amountCents)} of work.`}
|
||||
actions={
|
||||
<div className="flex flex-col gap-2">
|
||||
{accept.error && (
|
||||
<p role="alert" className="text-meta text-stop-500">
|
||||
{accept.error.message}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
size="lg"
|
||||
block
|
||||
busy={accept.isPending}
|
||||
disabled={!valid}
|
||||
onClick={() =>
|
||||
start &&
|
||||
accept.mutate({
|
||||
matchId,
|
||||
quoteId: quote.quoteId,
|
||||
scheduledStart: start,
|
||||
scheduledEnd: new Date(start.getTime() + Number(hours) * 3_600_000),
|
||||
})
|
||||
}
|
||||
>
|
||||
Confirm booking
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" block onClick={onClose}>
|
||||
Not yet
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<label className="mb-4 flex flex-col gap-2">
|
||||
<span className="text-body-sm text-strong">Date and time</span>
|
||||
<Input type="datetime-local" value={when} onChange={(e) => setWhen(e.target.value)} />
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-2">
|
||||
<span className="text-body-sm text-strong">Roughly how long?</span>
|
||||
<Input
|
||||
value={hours}
|
||||
inputMode="numeric"
|
||||
onChange={(e) => setHours(e.target.value.replace(/\D/g, '') || '1')}
|
||||
/>
|
||||
<span className="text-meta text-faint">Hours. A guide for their diary, not a limit.</span>
|
||||
</label>
|
||||
|
||||
<Banner tone="info" title="Booking closes the job" className="mt-4">
|
||||
<span className="flex items-start gap-1.5">
|
||||
<CheckCircle2 className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||
Any other pros still considering this job will be told it has gone.
|
||||
</span>
|
||||
</Banner>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { CalendarClock, ChevronLeft, ChevronRight, MessageSquare, Search, Star } from 'lucide-react';
|
||||
import { PAST_JOB_STATUSES } from '@linkder/shared';
|
||||
import { api, type RouterOutputs } from '@/lib/trpc';
|
||||
import { Banner, buttonClasses, EmptyState } from '@/components/ui';
|
||||
import { cn, formatRelativeTime, formatWhen } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* One job, and the conversations hanging off it.
|
||||
*
|
||||
* The middle screen of the jobs tab, and only the client has one: a job with
|
||||
* three interested pros is three private threads, and this is where you choose
|
||||
* which one you are talking to.
|
||||
*/
|
||||
export function JobDetail({
|
||||
jobId,
|
||||
onBack,
|
||||
onOpenThread,
|
||||
}: {
|
||||
jobId: string;
|
||||
onBack: () => void;
|
||||
onOpenThread: (matchId: string) => void;
|
||||
}) {
|
||||
const job = api.job.byId.useQuery({ id: jobId }, { retry: false });
|
||||
const matches = api.job.matches.useQuery({ jobId }, { retry: false, refetchInterval: 20_000 });
|
||||
|
||||
const isPast = job.data ? PAST_JOB_STATUSES.includes(job.data.status) : false;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4 pb-4 pt-[3.25rem]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="-ml-2 mb-2 flex h-11 items-center gap-1 self-start rounded-pill pl-1 pr-3 text-body-sm text-accent"
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5" aria-hidden />
|
||||
Jobs
|
||||
</button>
|
||||
|
||||
{job.isLoading ? (
|
||||
<div className="h-24 animate-pulse rounded-card bg-inset" aria-busy />
|
||||
) : job.error || !job.data ? (
|
||||
<EmptyState
|
||||
title="Job not found"
|
||||
body="It may have been removed, or it was never yours to see."
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<h1 className="text-h2">{job.data.title}</h1>
|
||||
<p className="mt-1 text-body-sm text-muted">
|
||||
{job.data.category.name} · posted {formatRelativeTime(job.data.createdAt)}
|
||||
</p>
|
||||
|
||||
{isPast && (
|
||||
<Banner
|
||||
tone={job.data.status === 'cancelled' ? 'error' : 'success'}
|
||||
title={job.data.status === 'cancelled' ? 'Cancelled' : 'Finished'}
|
||||
className="mt-4"
|
||||
>
|
||||
{job.data.status === 'cancelled'
|
||||
? 'Nobody can reply on this job any more. The conversations stay as a record.'
|
||||
: 'This job is done. The conversations stay as a record of what was agreed.'}
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
<p className="mt-4 whitespace-pre-line text-body-sm text-strong">
|
||||
{job.data.description}
|
||||
</p>
|
||||
|
||||
<h2 className="mb-3 mt-8 text-h4">
|
||||
{matches.data?.length
|
||||
? matches.data.length === 1
|
||||
? '1 pro accepted'
|
||||
: `${matches.data.length} pros accepted`
|
||||
: 'Pros'}
|
||||
</h2>
|
||||
|
||||
{matches.isLoading ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="h-[4.5rem] animate-pulse rounded-card bg-inset" aria-hidden />
|
||||
<div className="h-[4.5rem] animate-pulse rounded-card bg-inset" aria-hidden />
|
||||
</div>
|
||||
) : !matches.data?.length ? (
|
||||
<EmptyState
|
||||
title={job.data.pendingRequests > 0 ? 'Waiting on replies' : 'Nobody yet'}
|
||||
body={
|
||||
job.data.pendingRequests > 0
|
||||
? `${job.data.pendingRequests} ${
|
||||
job.data.pendingRequests === 1 ? 'pro has' : 'pros have'
|
||||
} your job and have not answered yet. We will tell you the moment one does.`
|
||||
: 'Swipe right on a pro to send them this job. As soon as one accepts, your conversation opens here.'
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-3">
|
||||
{matches.data.map((m) => (
|
||||
<li key={m.matchId}>
|
||||
<MatchRow match={m} onOpen={() => onOpenThread(m.matchId)} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* The way back to the deck for THIS job. It belongs here rather than
|
||||
on a list: you go looking for more pros from inside the job you are
|
||||
trying to fill, not from a screen showing all of them. */}
|
||||
{!isPast && (
|
||||
<Link
|
||||
href={`/deck/${jobId}`}
|
||||
className={cn(
|
||||
'mt-4',
|
||||
buttonClasses({ variant: 'outline', size: 'md', block: true }),
|
||||
)}
|
||||
>
|
||||
<Search className="h-4 w-4" aria-hidden />
|
||||
Find more pros
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type MatchRowData = RouterOutputs['job']['matches'][number];
|
||||
|
||||
function MatchRow({ match, onOpen }: { match: MatchRowData; onOpen: () => void }) {
|
||||
const n = match.lastMessageAttachments;
|
||||
const preview =
|
||||
match.lastMessage?.replace(/\s+/g, ' ').trim() ||
|
||||
(n > 0 ? (n === 1 ? 'Attachment' : `${n} attachments`) : '');
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-card border border-hairline bg-raised p-4 text-left',
|
||||
'transition-[border-color] duration-[120ms] ease-standard',
|
||||
'hover:border-brand-500 active:border-brand-500',
|
||||
)}
|
||||
>
|
||||
{match.photo ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- remote pro media, no loader configured
|
||||
<img
|
||||
src={match.photo}
|
||||
alt=""
|
||||
className="h-14 w-14 shrink-0 rounded-md object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex h-14 w-14 shrink-0 items-center justify-center rounded-md bg-inset font-display text-h4 text-muted"
|
||||
>
|
||||
{match.proName?.[0] ?? '?'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-baseline justify-between gap-2">
|
||||
<span className="truncate font-display text-h4 text-strong">{match.proName}</span>
|
||||
{match.ratingCount > 0 ? (
|
||||
<span className="flex shrink-0 items-center gap-1 text-meta text-muted tabular-nums">
|
||||
<Star className="h-3.5 w-3.5 fill-current text-sun-500" aria-hidden />
|
||||
{match.ratingAvg?.toFixed(1)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="shrink-0 text-meta font-semibold text-accent">New</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* The last thing said, or the headline if nothing has been. A row that
|
||||
says nothing until someone speaks is a row you cannot tell apart. */}
|
||||
<span className="mt-0.5 block truncate text-body-sm text-muted">
|
||||
{preview || match.headline}
|
||||
</span>
|
||||
|
||||
<span className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-meta">
|
||||
{match.unreadCount > 0 ? (
|
||||
<span className="flex items-center gap-1 font-semibold text-accent">
|
||||
<MessageSquare className="h-3.5 w-3.5" aria-hidden />
|
||||
{match.unreadCount} new
|
||||
</span>
|
||||
) : match.nextBookingAt ? (
|
||||
<span className="flex items-center gap-1 text-faint">
|
||||
<CalendarClock className="h-3.5 w-3.5" aria-hidden />
|
||||
{formatWhen(match.nextBookingAt)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-faint tabular-nums">
|
||||
{match.lastMessageAt
|
||||
? formatRelativeTime(match.lastMessageAt)
|
||||
: `accepted ${formatRelativeTime(match.acceptedAt)}`}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-accent" aria-hidden />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
'use client';
|
||||
|
||||
import { CalendarClock, ChevronRight, MessageSquare, Users } from 'lucide-react';
|
||||
import type { JobStatus } from '@linkder/shared';
|
||||
import { cn, formatRelativeTime, formatWhen } from '@/lib/utils';
|
||||
|
||||
export type Perspective = 'client' | 'pro';
|
||||
|
||||
/**
|
||||
* The status of a job, as a word and a tint. §8 — never colour alone.
|
||||
*
|
||||
* Two maps, because the same status means different things to the two sides.
|
||||
* `matched` is "somebody said yes" to a customer and "you said yes" to a pro;
|
||||
* one shared wording would fit neither, and a pro reading "Pros interested"
|
||||
* about their own accepted job would reasonably think it was somebody else's.
|
||||
*/
|
||||
const STATUS: Record<Perspective, Record<JobStatus, { label: string; className: string }>> = {
|
||||
client: {
|
||||
open: { label: 'Looking for pros', className: 'border-brand-200 bg-brand-100 text-ink-950' },
|
||||
matched: { label: 'Pros interested', className: 'border-go-100 bg-go-50 text-go-700' },
|
||||
booked: { label: 'Booked', className: 'border-go-100 bg-go-50 text-go-700' },
|
||||
completed: { label: 'Done', className: 'border-hairline bg-inset text-muted' },
|
||||
cancelled: { label: 'Cancelled', className: 'border-stop-100 bg-stop-50 text-stop-600' },
|
||||
},
|
||||
pro: {
|
||||
open: { label: 'Still open', className: 'border-brand-200 bg-brand-100 text-ink-950' },
|
||||
matched: { label: 'You accepted', className: 'border-go-100 bg-go-50 text-go-700' },
|
||||
booked: { label: 'Booked', className: 'border-go-100 bg-go-50 text-go-700' },
|
||||
completed: { label: 'Done', className: 'border-hairline bg-inset text-muted' },
|
||||
cancelled: { label: 'Cancelled', className: 'border-stop-100 bg-stop-50 text-stop-600' },
|
||||
},
|
||||
};
|
||||
|
||||
export interface JobRowData {
|
||||
id: string;
|
||||
title: string;
|
||||
status: JobStatus;
|
||||
/** The second line. The trade for a client, the customer's name for a pro. */
|
||||
subtitle: string;
|
||||
createdAt: Date;
|
||||
matchCount?: number;
|
||||
pendingCount?: number;
|
||||
unreadCount: number;
|
||||
lastMessageAt: Date | null;
|
||||
nextBookingAt: Date | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One line of live detail per row, chosen by priority rather than stacked.
|
||||
*
|
||||
* A row that shows unread messages AND a booking AND three pending requests is a
|
||||
* row nobody reads. The order is what the person has to act on soonest: someone
|
||||
* is waiting for a reply, then something is about to happen, then someone is
|
||||
* waiting for a decision.
|
||||
*/
|
||||
function liveDetail(job: JobRowData): { icon: typeof Users; text: string; urgent: boolean } | null {
|
||||
if (job.unreadCount > 0) {
|
||||
return {
|
||||
icon: MessageSquare,
|
||||
text: job.unreadCount === 1 ? '1 new message' : `${job.unreadCount} new messages`,
|
||||
urgent: true,
|
||||
};
|
||||
}
|
||||
if (job.nextBookingAt) {
|
||||
return { icon: CalendarClock, text: formatWhen(job.nextBookingAt), urgent: false };
|
||||
}
|
||||
if (job.matchCount) {
|
||||
return {
|
||||
icon: Users,
|
||||
text: job.matchCount === 1 ? '1 pro accepted' : `${job.matchCount} pros accepted`,
|
||||
urgent: false,
|
||||
};
|
||||
}
|
||||
if (job.pendingCount) {
|
||||
return {
|
||||
icon: Users,
|
||||
text: job.pendingCount === 1 ? '1 pro deciding' : `${job.pendingCount} pros deciding`,
|
||||
urgent: false,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* DESIGN.md §6.10, applied to a job rather than a pro.
|
||||
*
|
||||
* No thumbnail: a job's photos are of a broken boiler, and a 56px crop of one
|
||||
* says nothing at a glance. The trade and the status carry the row instead.
|
||||
*/
|
||||
export function JobRow({
|
||||
job,
|
||||
perspective,
|
||||
onOpen,
|
||||
}: {
|
||||
job: JobRowData;
|
||||
perspective: Perspective;
|
||||
onOpen: (jobId: string) => void;
|
||||
}) {
|
||||
const status = STATUS[perspective][job.status];
|
||||
const detail = liveDetail(job);
|
||||
const timestamp = job.lastMessageAt ?? job.createdAt;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(job.id)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-card border border-hairline bg-raised p-4 text-left',
|
||||
'transition-[border-color] duration-[120ms] ease-standard',
|
||||
'hover:border-brand-500 active:border-brand-500',
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-baseline justify-between gap-2">
|
||||
<span className="truncate font-display text-h4 text-strong">{job.title}</span>
|
||||
<span className="shrink-0 text-meta text-faint tabular-nums">
|
||||
{formatRelativeTime(timestamp)}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-pill border px-3 py-1 text-meta',
|
||||
status.className,
|
||||
)}
|
||||
>
|
||||
{status.label}
|
||||
</span>
|
||||
<span className="truncate text-body-sm text-muted">{job.subtitle}</span>
|
||||
</span>
|
||||
|
||||
{detail && (
|
||||
<span
|
||||
className={cn(
|
||||
'mt-1.5 flex items-center gap-1.5 text-meta',
|
||||
detail.urgent ? 'font-semibold text-accent' : 'text-faint',
|
||||
)}
|
||||
>
|
||||
<detail.icon className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||
{detail.text}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-accent" aria-hidden />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Placeholder at the row's own height, so the list does not jump when it lands. */
|
||||
export function JobRowSkeleton() {
|
||||
return <div className="h-[6.5rem] animate-pulse rounded-card bg-inset" aria-hidden />;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
'use client';
|
||||
|
||||
import { Star } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* "You have not reviewed this yet."
|
||||
*
|
||||
* A prompt, not a list row — it asks for something rather than navigating
|
||||
* somewhere, so it reads as an outstanding task and sits above the list rather
|
||||
* than inside it. Tinted `sun`, the same warning tone the rest of the product
|
||||
* uses for "this is waiting on you".
|
||||
*/
|
||||
export function ReviewPrompt({
|
||||
subjectName,
|
||||
jobTitle,
|
||||
onOpen,
|
||||
}: {
|
||||
subjectName: string;
|
||||
jobTitle: string;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-card border border-sun-100 bg-sun-50 p-4 text-left',
|
||||
'transition-[border-color] duration-[120ms] ease-standard hover:border-sun-500',
|
||||
)}
|
||||
>
|
||||
<Star className="h-5 w-5 shrink-0 text-sun-500" aria-hidden />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block font-display text-h4 text-ink-950">
|
||||
Rate {subjectName}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-body-sm text-ink-800">{jobTitle}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Star } from 'lucide-react';
|
||||
import { api } from '@/lib/trpc';
|
||||
import { Banner, Button, Sheet, Textarea } from '@/components/ui';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Leave a review on finished work.
|
||||
*
|
||||
* Held back until the other side has had their say — so the sheet says so
|
||||
* plainly rather than letting someone press Send and wonder why nothing
|
||||
* appeared. Silence about the embargo would read as a bug the first time
|
||||
* somebody checked the profile they had just reviewed.
|
||||
*/
|
||||
export function ReviewSheet({
|
||||
bookingId,
|
||||
subjectName,
|
||||
jobTitle,
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
bookingId: string | null;
|
||||
subjectName: string;
|
||||
jobTitle: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const utils = api.useUtils();
|
||||
const [rating, setRating] = useState(0);
|
||||
const [body, setBody] = useState('');
|
||||
const [done, setDone] = useState<{ published: boolean } | null>(null);
|
||||
|
||||
const create = api.review.create.useMutation({
|
||||
onSuccess: (result) => {
|
||||
setDone({ published: result.published });
|
||||
void utils.review.pending.invalidate();
|
||||
void utils.job.mine.invalidate();
|
||||
void utils.job.mineForPro.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
function close() {
|
||||
setRating(0);
|
||||
setBody('');
|
||||
setDone(null);
|
||||
create.reset();
|
||||
onClose();
|
||||
}
|
||||
|
||||
if (!bookingId) return null;
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<Sheet
|
||||
open={open}
|
||||
onClose={close}
|
||||
title="Thanks"
|
||||
actions={
|
||||
<Button size="lg" block onClick={close}>
|
||||
Done
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Banner
|
||||
tone={done.published ? 'success' : 'info'}
|
||||
title={done.published ? 'Both reviews are live' : 'Held until they reply'}
|
||||
>
|
||||
{done.published
|
||||
? `${subjectName} reviewed you too, so both are now on your profiles.`
|
||||
: `Neither review shows until ${subjectName} writes theirs — that way nobody can read yours and answer in kind. If they never do, yours publishes on its own in two weeks.`}
|
||||
</Banner>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
// 10 characters is the schema's floor; saying so up front beats a red message
|
||||
// after they press the button.
|
||||
const canSend = rating > 0 && body.trim().length >= 10 && !create.isPending;
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
open={open}
|
||||
onClose={close}
|
||||
title={`How did ${subjectName} do?`}
|
||||
body={jobTitle}
|
||||
actions={
|
||||
<div className="flex flex-col gap-2">
|
||||
{create.error && (
|
||||
<p role="alert" className="text-meta text-stop-500">
|
||||
{create.error.message}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
size="lg"
|
||||
block
|
||||
busy={create.isPending}
|
||||
disabled={!canSend}
|
||||
onClick={() => create.mutate({ bookingId, rating, body })}
|
||||
>
|
||||
Send review
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" block onClick={close}>
|
||||
Not now
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Rating"
|
||||
className="mb-4 flex items-center justify-center gap-2"
|
||||
>
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={rating === n}
|
||||
aria-label={`${n} out of 5`}
|
||||
onClick={() => setRating(n)}
|
||||
className="flex h-11 w-11 items-center justify-center rounded-pill"
|
||||
>
|
||||
<Star
|
||||
className={cn(
|
||||
'h-8 w-8 transition-colors duration-[120ms] ease-standard',
|
||||
n <= rating ? 'fill-current text-sun-500' : 'text-faint',
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<label className="flex flex-col gap-2">
|
||||
<span className="text-body-sm text-strong">What happened?</span>
|
||||
<Textarea
|
||||
rows={4}
|
||||
value={body}
|
||||
maxLength={1500}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
placeholder="What they did, whether they turned up when they said, how it was left."
|
||||
/>
|
||||
</label>
|
||||
<p className="mt-1 text-meta text-faint">
|
||||
{body.trim().length < 10
|
||||
? 'A sentence at least — a rating with no words helps nobody.'
|
||||
: `${body.length}/1500`}
|
||||
</p>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
'use client';
|
||||
|
||||
import { ChevronLeft, MapPin, ShieldCheck, Star } from 'lucide-react';
|
||||
import type { DeckCard } from '@linkder/db';
|
||||
import { api } from '@/lib/trpc';
|
||||
import { Button, EmptyState, ScrollStrip, Tag } from '@/components/ui';
|
||||
import { ReviewList } from './review-list';
|
||||
import { formatDistance, formatResponseTime } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* One pro, in full — what a search result opens onto.
|
||||
*
|
||||
* A result row is a scanning surface: a name, a rating and two lines of figures,
|
||||
* which is enough to choose between twenty people and not enough to choose one.
|
||||
* This is the other half of that decision — their trades, what they say they
|
||||
* specialise in, their work, and what customers wrote afterwards.
|
||||
*
|
||||
* It renders immediately from the row that was tapped rather than showing a
|
||||
* spinner over data the user is already looking at; `pro.publicProfile` then
|
||||
* fills in the parts a row does not carry (every photo, the full bio) and is the
|
||||
* authority once it lands. That query also re-checks eligibility, so a pro who
|
||||
* went on holiday between the search and the tap resolves to a dead end here
|
||||
* instead of to a hire button that would fail on send.
|
||||
*/
|
||||
export function ProProfilePanel({
|
||||
pro,
|
||||
onBack,
|
||||
onHire,
|
||||
}: {
|
||||
/** The row that was tapped. Paints the screen before the query resolves. */
|
||||
pro: DeckCard;
|
||||
onBack: () => void;
|
||||
/** Hands the pro up to the one SendJobSheet that lives at the phone root. */
|
||||
onHire: (pro: DeckCard) => void;
|
||||
}) {
|
||||
const profile = api.pro.publicProfile.useQuery(
|
||||
{ proId: pro.proId },
|
||||
{ staleTime: 60_000, retry: false },
|
||||
);
|
||||
|
||||
const p = profile.data;
|
||||
const name = pro.name ?? p?.name ?? 'This pro';
|
||||
|
||||
// Row first, query second: both describe the same pro, and the row is already
|
||||
// on screen. `media` is the one field a DeckCard flattens, so prefer it once
|
||||
// it arrives — a profile is where the rest of someone's photos belong.
|
||||
const photos = p?.media.length ? p.media.map((m) => m.url) : pro.photos;
|
||||
const categories = p?.categories ?? pro.categories;
|
||||
const skills = p?.skills ?? pro.skills;
|
||||
const bio = p?.bio ?? pro.bio;
|
||||
const ratingCount = p?.ratingCount ?? pro.ratingCount;
|
||||
const ratingAvg = p?.ratingAvg ?? pro.ratingAvg;
|
||||
const completedJobs = p?.completedJobs ?? pro.completedJobs;
|
||||
const responseTime = formatResponseTime(p?.avgResponseMinutes ?? pro.avgResponseMinutes);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-4 pt-[3.25rem]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="-ml-2 mb-2 flex h-11 items-center gap-1 self-start rounded-pill pl-1 pr-3 text-body-sm text-accent"
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5" aria-hidden />
|
||||
Search
|
||||
</button>
|
||||
|
||||
{/* A 404 here means the pro stopped being bookable since the search ran.
|
||||
Saying so beats a hire button that throws on the way out. */}
|
||||
{profile.error ? (
|
||||
<EmptyState
|
||||
title={`${name} is not available`}
|
||||
body="They may have paused new work or left the platform. The rest of your search results are still there."
|
||||
action={
|
||||
<Button variant="ghost" size="md" onClick={onBack}>
|
||||
Back to search
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{photos[0] && (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- remote pro media, no loader configured
|
||||
<img
|
||||
src={photos[0]}
|
||||
alt=""
|
||||
className="mb-4 h-52 w-full rounded-card object-cover"
|
||||
/>
|
||||
)}
|
||||
|
||||
<h1 className="text-h2">{name}</h1>
|
||||
<p className="mt-1 text-body-sm text-muted">{p?.headline ?? pro.headline}</p>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-meta text-faint tabular-nums">
|
||||
{ratingCount > 0 ? (
|
||||
<span className="flex items-center gap-1 text-muted">
|
||||
<Star className="h-3.5 w-3.5 fill-current text-sun-500" aria-hidden />
|
||||
{ratingAvg?.toFixed(1)}
|
||||
<span className="text-faint">({ratingCount})</span>
|
||||
</span>
|
||||
) : (
|
||||
// §8 — never "0.0 ★" for someone unrated; that reads as a bad score.
|
||||
<span className="font-semibold text-accent">New</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="h-3.5 w-3.5" aria-hidden />
|
||||
{formatDistance(pro.distanceM)}
|
||||
</span>
|
||||
<span>€{((p?.hourlyRateCents ?? pro.hourlyRateCents) / 100).toFixed(0)}/hr</span>
|
||||
<span>{p?.yearsExperience ?? pro.yearsExperience} yrs experience</span>
|
||||
{completedJobs > 0 && <span>{completedJobs} jobs done</span>}
|
||||
</div>
|
||||
|
||||
{responseTime && <p className="mt-1 text-meta text-faint">{responseTime}</p>}
|
||||
|
||||
{/* The one claim this marketplace is actually selling. */}
|
||||
<p className="mt-3 flex items-center gap-1.5 text-meta font-semibold text-go-700">
|
||||
<ShieldCheck className="h-4 w-4" aria-hidden />
|
||||
ID, licence and insurance checked
|
||||
</p>
|
||||
|
||||
{categories.length > 0 && (
|
||||
<Section title="Trades">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{categories.map((c) => (
|
||||
<Tag key={c}>{c}</Tag>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{skills.length > 0 && (
|
||||
<Section title="Specialises in">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{skills.map((s) => (
|
||||
<Tag key={s}>{s}</Tag>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{bio && (
|
||||
<Section title="About">
|
||||
<p className="whitespace-pre-line text-body-sm text-strong">{bio}</p>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{photos.length > 1 && (
|
||||
<Section title="Their work">
|
||||
{/* Drag-scrollable: this app is a phone mock people use with a
|
||||
mouse, and a row that will not move reads as broken. */}
|
||||
<ScrollStrip className="-mx-4 gap-2 px-4">
|
||||
{photos.slice(1).map((url) => (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- remote pro media, no loader configured
|
||||
<img
|
||||
key={url}
|
||||
src={url}
|
||||
alt=""
|
||||
className="h-40 w-32 shrink-0 rounded-md object-cover"
|
||||
loading="lazy"
|
||||
draggable={false}
|
||||
/>
|
||||
))}
|
||||
</ScrollStrip>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section
|
||||
title="Reviews"
|
||||
hint={
|
||||
ratingCount > 0
|
||||
? `Showing the most recent of ${ratingCount}.`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<ReviewList proId={pro.proId} ratingCount={ratingCount} />
|
||||
</Section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/*
|
||||
The primary action stays in thumb reach rather than below a page of
|
||||
reviews (§9). It opens the same SendJobSheet a right swipe opens — the
|
||||
sheet owns signing in, picking a job and posting one, so there is exactly
|
||||
one path from "I want this person" to a request.
|
||||
*/}
|
||||
{!profile.error && (
|
||||
<div className="shrink-0 border-t border-hairline bg-page/95 px-4 pb-[calc(0.75rem+env(safe-area-inset-bottom))] pt-3 backdrop-blur-[12px]">
|
||||
<Button size="lg" block onClick={() => onHire(pro)}>
|
||||
Send {name} a job
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A titled block. Local to this screen — `ui/page.tsx` Section has no top margin. */
|
||||
function Section({
|
||||
title,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
hint?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="mt-7">
|
||||
<h2 className="text-h4">{title}</h2>
|
||||
{hint && <p className="mb-3 mt-1 text-meta text-faint">{hint}</p>}
|
||||
<div className={hint ? undefined : 'mt-3'}>{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
'use client';
|
||||
|
||||
import { Star } from 'lucide-react';
|
||||
import { api } from '@/lib/trpc';
|
||||
import { Button, EmptyState } from '@/components/ui';
|
||||
import { cn, formatRelativeTime } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Five stars, filled to the rating.
|
||||
*
|
||||
* The number goes in the accessible name rather than being inferred from a row
|
||||
* of glyphs — §8, meaning is never carried by shape alone. The stars themselves
|
||||
* are decorative once the label says "4 out of 5".
|
||||
*/
|
||||
function Stars({ rating, className }: { rating: number; className?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn('flex items-center gap-0.5 text-sun-500', className)}
|
||||
role="img"
|
||||
aria-label={`${rating} out of 5`}
|
||||
>
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<Star
|
||||
key={n}
|
||||
className={cn('h-3.5 w-3.5', n <= rating ? 'fill-current' : 'text-faint')}
|
||||
aria-hidden
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* What people wrote about this pro.
|
||||
*
|
||||
* A page, never the whole history: `ratingCount` in the header counts every
|
||||
* rating this pro has ever had, and a profile with sixty of them would push the
|
||||
* hire button somewhere nobody scrolls. The heading says which it is showing so
|
||||
* the two numbers cannot be read as a contradiction.
|
||||
*
|
||||
* Only published reviews exist as far as this is concerned — `pro.reviews`
|
||||
* enforces that server-side, because the publication gate is what stops a pro
|
||||
* retaliating against a bad review before it is visible.
|
||||
*/
|
||||
export function ReviewList({ proId, ratingCount }: { proId: string; ratingCount: number }) {
|
||||
const reviews = api.pro.reviews.useInfiniteQuery(
|
||||
{ proId },
|
||||
{ getNextPageParam: (last) => last.nextCursor, staleTime: 60_000, retry: false },
|
||||
);
|
||||
|
||||
const rows = reviews.data?.pages.flatMap((page) => page.reviews) ?? [];
|
||||
|
||||
if (reviews.isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3" aria-busy>
|
||||
{/* §6.11 — three is enough to say "loading"; twenty is a lie about what
|
||||
is coming. */}
|
||||
{[0, 1, 2].map((n) => (
|
||||
<div key={n} className="h-24 animate-pulse rounded-card bg-inset" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="No written reviews yet"
|
||||
body={
|
||||
ratingCount > 0
|
||||
? 'This pro has been rated, but nobody has left written feedback that is public yet.'
|
||||
: 'Nobody has reviewed this pro yet. Reviews appear once a booking is finished.'
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<ul className="flex flex-col gap-3">
|
||||
{rows.map((review) => (
|
||||
<li
|
||||
key={review.id}
|
||||
className="rounded-card border border-hairline bg-raised p-4"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{review.authorImage ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- remote avatar, no loader configured
|
||||
<img
|
||||
src={review.authorImage}
|
||||
alt=""
|
||||
className="h-9 w-9 shrink-0 rounded-pill object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-pill bg-inset font-display text-body-sm text-muted"
|
||||
>
|
||||
{review.authorName?.[0] ?? '?'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-body-sm font-semibold text-strong">
|
||||
{review.authorName ?? 'A customer'}
|
||||
</span>
|
||||
<span className="mt-0.5 flex items-center gap-2">
|
||||
<Stars rating={review.rating} />
|
||||
<span className="text-meta text-faint">
|
||||
{formatRelativeTime(review.publishedAt)}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-body-sm text-strong">{review.body}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{reviews.hasNextPage && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="md"
|
||||
block
|
||||
busy={reviews.isFetchingNextPage}
|
||||
onClick={() => void reviews.fetchNextPage()}
|
||||
>
|
||||
Show more reviews
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
'use client';
|
||||
|
||||
import { ChevronRight, Star } from 'lucide-react';
|
||||
import type { DeckCard } from '@linkder/db';
|
||||
import { formatDistance } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* One pro, in a list.
|
||||
*
|
||||
* Not the deck <Card>: that one is `absolute inset-0` with `touch-action: none`,
|
||||
* so a column of them would have no height and would eat the vertical scroll.
|
||||
* A results list is a different job — scan twenty in a second, tap one.
|
||||
*
|
||||
* Follows the row shape already used by the jobs list (app/jobs/page.tsx):
|
||||
* bordered card, title, two lines of meta, trailing chevron.
|
||||
*/
|
||||
export function ResultRow({ pro, onOpen }: { pro: DeckCard; onOpen: (proId: string) => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(pro.proId)}
|
||||
className="flex w-full items-center gap-3 rounded-card border border-hairline bg-raised p-4 text-left transition-[border-color] duration-[120ms] ease-standard hover:border-brand-500 active:border-brand-500"
|
||||
>
|
||||
{/* Photo, or the initial. An empty grey square reads as a broken image. */}
|
||||
{pro.photos[0] ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- remote pro media, no loader configured
|
||||
<img
|
||||
src={pro.photos[0]}
|
||||
alt=""
|
||||
className="h-14 w-14 shrink-0 rounded-md object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex h-14 w-14 shrink-0 items-center justify-center rounded-md bg-inset font-display text-h4 text-muted"
|
||||
>
|
||||
{pro.name?.[0] ?? '?'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-baseline justify-between gap-2">
|
||||
<span className="truncate font-display text-h4 text-strong">{pro.name}</span>
|
||||
{pro.ratingCount > 0 ? (
|
||||
<span className="flex shrink-0 items-center gap-1 text-meta text-muted tabular-nums">
|
||||
<Star className="h-3.5 w-3.5 fill-current text-sun-500" aria-hidden />
|
||||
{pro.ratingAvg?.toFixed(1)}
|
||||
<span className="text-faint">({pro.ratingCount})</span>
|
||||
</span>
|
||||
) : (
|
||||
// §8 — never colour alone, and never "0.0 stars" for someone unrated.
|
||||
<span className="shrink-0 text-meta font-semibold text-accent">New</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<span className="mt-0.5 block truncate text-body-sm text-muted">{pro.headline}</span>
|
||||
|
||||
<span className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-meta text-faint tabular-nums">
|
||||
<span>{formatDistance(pro.distanceM)}</span>
|
||||
<span aria-hidden>·</span>
|
||||
<span>€{(pro.hourlyRateCents / 100).toFixed(0)}/hr</span>
|
||||
{pro.categories[0] && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<span className="truncate">{pro.categories[0]}</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-accent" aria-hidden />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Placeholder at the row's own height, so the list does not jump when it lands. */
|
||||
export function ResultRowSkeleton() {
|
||||
return <div className="h-[5.75rem] animate-pulse rounded-card bg-inset" aria-hidden />;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
'use client';
|
||||
|
||||
import { Search, X } from 'lucide-react';
|
||||
import { MAX_SEARCH_QUERY_LENGTH } from '@linkder/shared';
|
||||
import { Input } from '@/components/ui';
|
||||
|
||||
/**
|
||||
* The search box. DESIGN.md §6.9.
|
||||
*
|
||||
* The label is visible, not a placeholder: §6.2 forbids placeholder-as-label,
|
||||
* and a placeholder vanishes exactly when someone needs to remember what the
|
||||
* field searches. The placeholder carries an example instead.
|
||||
*/
|
||||
export function SearchField({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (next: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex flex-col gap-2">
|
||||
<span className="text-body-sm font-semibold text-strong">Search pros</span>
|
||||
<span className="relative block">
|
||||
<Search
|
||||
className="pointer-events-none absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2 text-faint"
|
||||
aria-hidden
|
||||
/>
|
||||
<Input
|
||||
type="search"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
maxLength={MAX_SEARCH_QUERY_LENGTH}
|
||||
placeholder="Boiler repair, rewiring, Marta…"
|
||||
className="pl-12 pr-12 [&::-webkit-search-cancel-button]:hidden"
|
||||
/>
|
||||
{value && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange('')}
|
||||
aria-label="Clear search"
|
||||
className="absolute right-1 top-1/2 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-pill text-faint transition-colors duration-[120ms] ease-standard hover:text-strong"
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
'use client';
|
||||
|
||||
import { SlidersHorizontal } from 'lucide-react';
|
||||
import type { SearchSort } from '@linkder/shared';
|
||||
import { MAX_SERVICE_RADIUS_M, MIN_SERVICE_RADIUS_M } from '@linkder/shared';
|
||||
import { Chip } from '@/components/ui';
|
||||
import type { Category } from '@/app/showcase-deck';
|
||||
|
||||
const SORTS: { value: SearchSort; label: string }[] = [
|
||||
{ value: 'best', label: 'Best match' },
|
||||
{ value: 'nearest', label: 'Nearest' },
|
||||
{ value: 'rating', label: 'Top rated' },
|
||||
{ value: 'price', label: 'Lowest price' },
|
||||
];
|
||||
|
||||
export interface SearchFilterState {
|
||||
categoryId: string | null;
|
||||
radiusKm: number;
|
||||
sort: SearchSort;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trade, distance and sort.
|
||||
*
|
||||
* The trade strip is the same horizontal scroller the entry screen uses — 50
|
||||
* categories will not fit on a 390px phone any other way, and two different
|
||||
* pickers for the same taxonomy would be two things to keep in step.
|
||||
*
|
||||
* Distance and sort live behind a toggle: on a phone, three stacked filters
|
||||
* above the results push the first result off the screen, and the first result
|
||||
* is the whole point.
|
||||
*/
|
||||
export function SearchFilters({
|
||||
categories,
|
||||
state,
|
||||
onChange,
|
||||
expanded,
|
||||
onToggleExpanded,
|
||||
}: {
|
||||
categories: Category[];
|
||||
state: SearchFilterState;
|
||||
onChange: (next: SearchFilterState) => void;
|
||||
expanded: boolean;
|
||||
onToggleExpanded: () => void;
|
||||
}) {
|
||||
const selected = categories.find((c) => c.id === state.categoryId) ?? null;
|
||||
const activeCount = (state.categoryId ? 1 : 0) + (state.sort === 'best' ? 0 : 1);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="relative -mx-4">
|
||||
<div
|
||||
className="flex gap-1.5 overflow-x-auto px-4 pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
role="group"
|
||||
aria-label="Filter by trade"
|
||||
>
|
||||
<Chip
|
||||
size="sm"
|
||||
selected={expanded}
|
||||
className="shrink-0"
|
||||
onClick={onToggleExpanded}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<SlidersHorizontal className="h-4 w-4" aria-hidden />
|
||||
Filters
|
||||
{activeCount > 0 && <span className="tabular-nums">({activeCount})</span>}
|
||||
</Chip>
|
||||
|
||||
{/* The chosen trade stays first so it never scrolls out of view. */}
|
||||
{selected && (
|
||||
<Chip
|
||||
size="sm"
|
||||
selected
|
||||
className="shrink-0"
|
||||
onClick={() => onChange({ ...state, categoryId: null })}
|
||||
>
|
||||
{selected.name}
|
||||
<span className="sr-only">Remove trade filter</span>
|
||||
</Chip>
|
||||
)}
|
||||
|
||||
{categories
|
||||
.filter((c) => c.id !== state.categoryId)
|
||||
.map((c) => (
|
||||
<Chip
|
||||
key={c.id}
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={() => onChange({ ...state, categoryId: c.id })}
|
||||
>
|
||||
{c.name}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-page to-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="flex flex-col gap-4 rounded-card border border-hairline bg-raised p-4">
|
||||
<label className="flex flex-col gap-2">
|
||||
<span className="text-body-sm font-semibold text-strong">
|
||||
Within <span className="text-muted tabular-nums">{state.radiusKm} km</span>
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min={MIN_SERVICE_RADIUS_M / 1000}
|
||||
max={MAX_SERVICE_RADIUS_M / 1000}
|
||||
value={state.radiusKm}
|
||||
onChange={(e) => onChange({ ...state, radiusKm: 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>
|
||||
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<legend className="mb-2 text-body-sm font-semibold text-strong">Sort by</legend>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{SORTS.map((s) => (
|
||||
<Chip
|
||||
key={s.value}
|
||||
size="sm"
|
||||
selected={state.sort === s.value}
|
||||
onClick={() => onChange({ ...state, sort: s.value })}
|
||||
>
|
||||
{s.label}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,20 @@
|
||||
'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';
|
||||
import {
|
||||
AddressField,
|
||||
Button,
|
||||
EMPTY_ADDRESS,
|
||||
FormError,
|
||||
SettingsGroup,
|
||||
type AddressValue,
|
||||
} from '@/components/ui';
|
||||
|
||||
/**
|
||||
* Where you are, and how far you will go.
|
||||
@@ -24,9 +28,8 @@ export function LocationGroup({ isPro }: { isPro: boolean }) {
|
||||
const utils = api.useUtils();
|
||||
const saved = api.user.location.useQuery();
|
||||
|
||||
const [addressText, setAddressText] = useState('');
|
||||
const [address, setAddress] = useState<AddressValue>(EMPTY_ADDRESS);
|
||||
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
|
||||
@@ -35,9 +38,14 @@ export function LocationGroup({ isPro }: { isPro: boolean }) {
|
||||
|
||||
useEffect(() => {
|
||||
if (!saved.data || dirty) return;
|
||||
setAddressText(saved.data.addressText ?? '');
|
||||
// The label only. Re-picking is what moves the pin — carrying stale
|
||||
// coordinates under an editable label is the drift this replaced.
|
||||
setAddress(
|
||||
saved.data.addressText
|
||||
? { text: saved.data.addressText, place: { source: 'none', label: saved.data.addressText } }
|
||||
: EMPTY_ADDRESS,
|
||||
);
|
||||
setRadiusKm(Math.round(saved.data.radiusM / 1000));
|
||||
setPin(saved.data.location);
|
||||
}, [saved.data, dirty]);
|
||||
|
||||
const update = api.user.updateLocation.useMutation({
|
||||
@@ -95,42 +103,16 @@ export function LocationGroup({ isPro }: { isPro: boolean }) {
|
||||
}
|
||||
>
|
||||
<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>
|
||||
<AddressField
|
||||
label={isPro ? 'Base address' : 'Your address'}
|
||||
hint={
|
||||
isPro
|
||||
? 'Matching measures from here, never from the text.'
|
||||
: 'Your deck is centred here. Only shared with a pro once you book.'
|
||||
}
|
||||
value={address}
|
||||
onChange={(next) => edit(setAddress)(next)}
|
||||
/>
|
||||
|
||||
<label className="flex flex-col gap-2">
|
||||
<span className="text-body-sm text-strong">
|
||||
@@ -161,9 +143,10 @@ export function LocationGroup({ isPro }: { isPro: boolean }) {
|
||||
busy={update.isPending}
|
||||
onClick={() =>
|
||||
update.mutate({
|
||||
addressText,
|
||||
radiusM: radiusKm * 1000,
|
||||
...(pin ? { location: pin } : {}),
|
||||
// Only send a place when one was actually chosen this session —
|
||||
// otherwise saving a radius would rewrite the pin to `city`.
|
||||
...(address.place.source === 'none' ? {} : { place: address.place }),
|
||||
})
|
||||
}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { AlertTriangle, Check, LocateFixed, MapPin } from 'lucide-react';
|
||||
import type { LocationInput } from '@linkder/shared';
|
||||
import { api } from '@/lib/trpc';
|
||||
import { useDebouncedValue } from '@/lib/use-debounced-value';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from './button';
|
||||
import { Field } from './field';
|
||||
|
||||
const CITY_NAME = process.env.NEXT_PUBLIC_CITY_NAME ?? 'the city centre';
|
||||
|
||||
/** What the parent form holds: the text on screen, plus what it resolved to. */
|
||||
export interface AddressValue {
|
||||
text: string;
|
||||
place: LocationInput;
|
||||
}
|
||||
|
||||
export const EMPTY_ADDRESS: AddressValue = { text: '', place: { source: 'none' } };
|
||||
|
||||
/**
|
||||
* DESIGN.md §6.16. The one address input.
|
||||
*
|
||||
* An address is the only field in this product that has to become something
|
||||
* real: `ST_Distance(p.base_location, j.location)` ranks every deck, so a line
|
||||
* of text that never resolved is not an answer. Before this component all three
|
||||
* address surfaces were a bare `<Input>` next to a geolocation button, and a
|
||||
* user who typed a street and pressed save stored the city centre while the row
|
||||
* claimed to be their address.
|
||||
*
|
||||
* The precision line below the field is the point. "We found something" and "we
|
||||
* found the right thing" are different claims, and rendering them identically is
|
||||
* exactly how a placeholder gets stored as a location.
|
||||
*/
|
||||
export function AddressField({
|
||||
label,
|
||||
hint,
|
||||
value,
|
||||
onChange,
|
||||
required,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
value: AddressValue;
|
||||
onChange: (next: AddressValue) => void;
|
||||
required?: boolean;
|
||||
}) {
|
||||
// Suggestions are hidden once something is chosen, so picking one does not
|
||||
// leave the list sitting open over the rest of the form.
|
||||
const [open, setOpen] = useState(false);
|
||||
const [locating, setLocating] = useState(false);
|
||||
|
||||
// Per pause, not per keystroke — the same 250ms the search tab uses, and here
|
||||
// it is also a spend control: every call is a billed geocode.
|
||||
const q = useDebouncedValue(value.text, 250);
|
||||
|
||||
const suggest = api.geocode.suggest.useQuery(
|
||||
{ q: q.trim(), proximity: undefined },
|
||||
{
|
||||
enabled: open && q.trim().length >= 3,
|
||||
// Keep the list on screen while the next one loads, rather than blinking
|
||||
// empty between keystrokes.
|
||||
placeholderData: (previous) => previous,
|
||||
staleTime: 60_000,
|
||||
retry: false,
|
||||
},
|
||||
);
|
||||
|
||||
const reverse = api.geocode.reverse.useMutation();
|
||||
|
||||
const results = suggest.data?.results ?? [];
|
||||
const showList = open && results.length > 0;
|
||||
|
||||
function useDevicePosition() {
|
||||
if (!navigator.geolocation) return;
|
||||
setLocating(true);
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
async (pos) => {
|
||||
const point = { lat: pos.coords.latitude, lng: pos.coords.longitude };
|
||||
// Give the coordinates a name before storing them. A button that sets an
|
||||
// invisible pin leaves the user nothing to check.
|
||||
const named = await reverse.mutateAsync(point).catch(() => null);
|
||||
const text = named?.result?.label ?? 'Current location';
|
||||
|
||||
onChange({ text, place: { source: 'device', ...point, label: text } });
|
||||
setOpen(false);
|
||||
setLocating(false);
|
||||
},
|
||||
() => {
|
||||
setLocating(false);
|
||||
// Not an error state: typing an address is the primary path, and this
|
||||
// button is the shortcut.
|
||||
setOpen(true);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Field label={label} hint={hint}>
|
||||
<div className="relative">
|
||||
<MapPin
|
||||
className="pointer-events-none absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2 text-faint"
|
||||
aria-hidden
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={value.text}
|
||||
required={required}
|
||||
maxLength={255}
|
||||
autoComplete="off"
|
||||
placeholder="Start typing a street and number"
|
||||
onChange={(e) => {
|
||||
// Editing the text invalidates whatever was resolved. Keeping the
|
||||
// old coordinates under new text is the exact drift this
|
||||
// component exists to stop.
|
||||
onChange({ text: e.target.value, place: { source: 'none', label: e.target.value } });
|
||||
setOpen(true);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
className={cn(
|
||||
'h-12 w-full rounded-lg border-[1.5px] border-hairline bg-raised pl-12 pr-4',
|
||||
'text-base text-strong placeholder:text-faint',
|
||||
'focus:border-brand-500 focus:outline-none focus:ring-[3px] focus:ring-brand-200',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{showList && (
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{results.slice(0, 5).map((r) => (
|
||||
<li key={r.providerId}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange({
|
||||
text: r.label,
|
||||
place: { source: 'place', placeId: r.providerId, label: r.label },
|
||||
});
|
||||
setOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-lg border border-hairline bg-raised p-3 text-left',
|
||||
'transition-[border-color] duration-[120ms] ease-standard hover:border-brand-500',
|
||||
)}
|
||||
>
|
||||
<MapPin className="h-4 w-4 shrink-0 text-muted" aria-hidden />
|
||||
<span className="min-w-0 flex-1 truncate text-body-sm text-strong">{r.label}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<PrecisionLine value={value} configured={suggest.data?.configured ?? true} />
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="self-start"
|
||||
busy={locating}
|
||||
onClick={useDevicePosition}
|
||||
>
|
||||
<LocateFixed className="h-4 w-4" aria-hidden />
|
||||
Use my current location
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* What we actually know about this point, in words.
|
||||
*
|
||||
* Never a bare tick. The three states are three different promises about how
|
||||
* well this job or profile will match, and the user is the only one who can tell
|
||||
* us the middle one is not good enough.
|
||||
*/
|
||||
function PrecisionLine({ value, configured }: { value: AddressValue; configured: boolean }) {
|
||||
if (value.place.source === 'place') {
|
||||
return (
|
||||
<p className="flex items-start gap-1.5 text-meta text-go-600">
|
||||
<Check className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||
<span>Matched to {value.text}</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (value.place.source === 'device') {
|
||||
return (
|
||||
<p className="flex items-start gap-1.5 text-meta text-sun-500">
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||
<span>Approximate — from your phone, not a confirmed address.</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<p className="text-meta text-muted">
|
||||
{value.text.trim().length === 0
|
||||
? `No address yet — we will match from ${CITY_NAME}.`
|
||||
: configured
|
||||
? `Not matched yet — pick a suggestion, or we will match from ${CITY_NAME}.`
|
||||
: `Address lookup is unavailable — we will match from ${CITY_NAME}.`}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -28,9 +28,10 @@ export function Chip({
|
||||
aria-pressed={selected}
|
||||
{...props}
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-pill border-[1.5px]',
|
||||
'inline-flex items-center rounded-pill',
|
||||
size === 'sm' ? 'border' : 'border-[1.5px]',
|
||||
'transition-[color,background-color,border-color] duration-[120ms] ease-standard',
|
||||
size === 'sm' ? 'gap-1 px-3 py-1.5 text-meta' : 'gap-1.5 px-4 py-3 text-body-sm',
|
||||
size === 'sm' ? 'gap-1 px-2.5 py-1 text-[0.6875rem] leading-tight' : 'gap-1.5 px-4 py-3 text-body-sm',
|
||||
'disabled:pointer-events-none disabled:opacity-45',
|
||||
selected
|
||||
? 'border-brand-500 bg-brand-100 font-semibold text-ink-950'
|
||||
@@ -38,7 +39,7 @@ export function Chip({
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{selected && <Check className={size === 'sm' ? 'h-3.5 w-3.5' : 'h-4 w-4'} aria-hidden />}
|
||||
{selected && <Check className={size === 'sm' ? 'h-3 w-3' : 'h-4 w-4'} aria-hidden />}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { Button, IconButton, buttonClasses } from './button';
|
||||
export { AddressField, EMPTY_ADDRESS, type AddressValue } from './address-field';
|
||||
export { Banner, FormError } from './banner';
|
||||
export { Card, EmptyState, Stat } from './card';
|
||||
export { Chip, OptionCard, Tag } from './chip';
|
||||
@@ -6,3 +7,6 @@ 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';
|
||||
export { ScrollStrip } from './scroll-strip';
|
||||
export { Segmented, type Segment } from './segmented';
|
||||
export { Sheet } from './sheet';
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
'use client';
|
||||
|
||||
import { useRef } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* A horizontally scrolling row that can also be DRAGGED.
|
||||
*
|
||||
* `overflow-x: auto` alone is only half a control. A touch device flicks it
|
||||
* happily, but with a mouse a browser will not drag-scroll an overflow
|
||||
* container, and a vertical wheel does not move it sideways — so on a desktop
|
||||
* the row looks scrollable and refuses to move. Since this app is a phone
|
||||
* mockup that people use with a mouse, that reads as broken.
|
||||
*
|
||||
* Two additions:
|
||||
* - pointer drag, via setPointerCapture so the gesture survives leaving the
|
||||
* element;
|
||||
* - vertical wheel mapped to horizontal scroll.
|
||||
*
|
||||
* A drag must not fire the pill underneath it, so past DRAG_THRESHOLD the next
|
||||
* click is swallowed in the capture phase.
|
||||
*/
|
||||
const DRAG_THRESHOLD = 4;
|
||||
|
||||
export function ScrollStrip({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const start = useRef({ x: 0, scrollLeft: 0, dragging: false, moved: false, captured: false });
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
|
||||
// Let the browser own vertical panning so a touch drag can still scroll
|
||||
// the page, while we take the horizontal axis.
|
||||
'touch-pan-y',
|
||||
start.current.dragging ? 'cursor-grabbing' : 'cursor-grab',
|
||||
className,
|
||||
)}
|
||||
onPointerDown={(e) => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
// Ignore secondary buttons: a right-click drag is not a scroll.
|
||||
if (e.button !== 0) return;
|
||||
// NOTE: do NOT capture the pointer yet. Capturing here retargets the
|
||||
// whole gesture — including the click that follows — at this element, so
|
||||
// a plain tap would never reach the pill underneath. Capture only once
|
||||
// the pointer has actually moved far enough to be a drag.
|
||||
start.current = {
|
||||
x: e.clientX,
|
||||
scrollLeft: el.scrollLeft,
|
||||
dragging: true,
|
||||
moved: false,
|
||||
captured: false,
|
||||
};
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
const el = ref.current;
|
||||
if (!el || !start.current.dragging) return;
|
||||
const dx = e.clientX - start.current.x;
|
||||
if (Math.abs(dx) > DRAG_THRESHOLD) {
|
||||
start.current.moved = true;
|
||||
if (!start.current.captured) {
|
||||
el.setPointerCapture(e.pointerId);
|
||||
start.current.captured = true;
|
||||
}
|
||||
}
|
||||
if (!start.current.moved) return;
|
||||
el.scrollLeft = start.current.scrollLeft - dx;
|
||||
}}
|
||||
onPointerUp={(e) => {
|
||||
const el = ref.current;
|
||||
if (start.current.captured && el?.hasPointerCapture(e.pointerId)) {
|
||||
el.releasePointerCapture(e.pointerId);
|
||||
}
|
||||
start.current.dragging = false;
|
||||
start.current.captured = false;
|
||||
}}
|
||||
onPointerCancel={() => {
|
||||
start.current.dragging = false;
|
||||
start.current.captured = false;
|
||||
}}
|
||||
onClickCapture={(e) => {
|
||||
// The pointerup that ends a drag is followed by a click on whichever
|
||||
// pill is under the cursor. Swallow it, or every drag also picks a trade.
|
||||
if (start.current.moved) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
start.current.moved = false;
|
||||
}
|
||||
}}
|
||||
onWheel={(e) => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
// A mouse only produces deltaY; map it onto the axis this row actually
|
||||
// has. Trackpads already send deltaX, so prefer that when present.
|
||||
const delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
|
||||
if (delta === 0) return;
|
||||
el.scrollLeft += delta;
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface Segment<T extends string> {
|
||||
id: T;
|
||||
label: string;
|
||||
/** Rendered after the label. A zero is shown, not hidden — see DESIGN.md §6.13. */
|
||||
count?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* DESIGN.md §6.13. Two or three lenses onto one list.
|
||||
*
|
||||
* A `radiogroup` rather than a row of buttons: these are one choice with several
|
||||
* options, and a screen reader should hear "Current, 1 of 2" instead of two
|
||||
* unrelated controls. Selection carries in weight as well as fill, because §8
|
||||
* forbids colour as the only signal.
|
||||
*/
|
||||
export function Segmented<T extends string>({
|
||||
segments,
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
className,
|
||||
}: {
|
||||
segments: readonly Segment<T>[];
|
||||
value: T;
|
||||
onChange: (next: T) => void;
|
||||
/** Names the group for assistive tech — "Job list view", not "Segmented control". */
|
||||
label: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={label}
|
||||
className={cn('flex w-full gap-1 rounded-pill bg-inset p-1', className)}
|
||||
>
|
||||
{segments.map((segment) => {
|
||||
const isSelected = segment.id === value;
|
||||
return (
|
||||
<button
|
||||
key={segment.id}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={isSelected}
|
||||
onClick={() => onChange(segment.id)}
|
||||
className={cn(
|
||||
'flex h-10 flex-1 items-center justify-center gap-1.5 rounded-pill px-3 text-body-sm',
|
||||
'transition-[color,background-color,box-shadow] duration-[120ms] ease-standard',
|
||||
isSelected
|
||||
? 'bg-raised font-semibold text-strong shadow-sm'
|
||||
: 'text-muted hover:text-strong',
|
||||
)}
|
||||
>
|
||||
{segment.label}
|
||||
{segment.count !== undefined && (
|
||||
<span className={cn('tabular-nums', isSelected ? 'text-muted' : 'text-faint')}>
|
||||
{segment.count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* DESIGN.md §6.14. The only modal this product has.
|
||||
*
|
||||
* Rises from the bottom edge because that is where the thumb already is — a
|
||||
* centred dialog on a 390px screen is just a card with the page greyed out.
|
||||
*
|
||||
* Scrim tap, Escape and the grab handle all mean the same thing: no. A sheet
|
||||
* whose scrim tap silently confirms is a trap, so `onClose` is never a decision
|
||||
* — anything irreversible needs a button inside.
|
||||
*/
|
||||
export function Sheet({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
body,
|
||||
actions,
|
||||
children,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
body?: string;
|
||||
/** Pinned at the bottom. Never scrolls out of reach. */
|
||||
actions?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const panel = useRef<HTMLDivElement>(null);
|
||||
const restoreTo = useRef<Element | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
restoreTo.current = document.activeElement;
|
||||
// Focus the panel itself rather than the first control: the sheet's job is
|
||||
// to be read before it is answered.
|
||||
panel.current?.focus();
|
||||
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
if (restoreTo.current instanceof HTMLElement) restoreTo.current.focus();
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<div className="absolute inset-0 z-50 flex flex-col justify-end">
|
||||
<motion.div
|
||||
aria-hidden
|
||||
onClick={onClose}
|
||||
className="absolute inset-0 bg-[rgb(0_6_36_/_0.45)]"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
ref={panel}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
tabIndex={-1}
|
||||
className={cn(
|
||||
'relative flex max-h-[85%] flex-col rounded-t-card bg-page shadow-lg outline-none',
|
||||
'pb-[calc(0.5rem+env(safe-area-inset-bottom))]',
|
||||
)}
|
||||
initial={{ y: '100%' }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: '100%' }}
|
||||
transition={{ type: 'spring', damping: 30, stiffness: 320 }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="mx-auto flex h-8 w-full shrink-0 items-center justify-center"
|
||||
>
|
||||
<span aria-hidden className="h-1 w-9 rounded-pill bg-ink-200" />
|
||||
</button>
|
||||
|
||||
<div className="shrink-0 px-5 pb-3">
|
||||
<h2 className="text-h3">{title}</h2>
|
||||
{body && <p className="mt-1 text-body-sm text-muted">{body}</p>}
|
||||
</div>
|
||||
|
||||
{children && <div className="min-h-0 flex-1 overflow-y-auto px-5">{children}</div>}
|
||||
|
||||
{actions && <div className="shrink-0 px-5 pt-4">{actions}</div>}
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
import { baseOptions } from '@/lib/observability';
|
||||
|
||||
/**
|
||||
* Browser error reporting for Bugsink.
|
||||
*
|
||||
* The DSN must be NEXT_PUBLIC_ to exist in the client bundle. That is fine — a
|
||||
* Sentry DSN is a write-only ingest key by design, not a secret. Everything
|
||||
* sensitive is stripped in beforeSend; see @/lib/observability.
|
||||
*/
|
||||
Sentry.init({
|
||||
...baseOptions(process.env.NEXT_PUBLIC_SENTRY_DSN),
|
||||
// No session replay: Bugsink cannot ingest it, and a replay of this app would
|
||||
// record someone typing their login OTP.
|
||||
replaysOnErrorSampleRate: 0,
|
||||
replaysSessionSampleRate: 0,
|
||||
// No release-health sessions either. The SDK sends a session envelope on every
|
||||
// page load by default; Bugsink tracks errors only, so those are requests that
|
||||
// cost the user bandwidth and produce nothing readable at the other end.
|
||||
// v10 removed the `autoSessionTracking` flag — it is an integration now.
|
||||
integrations: (defaults) => defaults.filter((i) => i.name !== 'BrowserSession'),
|
||||
});
|
||||
|
||||
/** Lets Next report client-side navigation failures. */
|
||||
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
import { baseOptions } from '@/lib/observability';
|
||||
|
||||
/**
|
||||
* Server and edge error reporting for Bugsink.
|
||||
*
|
||||
* Next calls register() once per runtime, so the DSN is read here rather than at
|
||||
* module scope — the edge runtime has a different env surface from Node.
|
||||
*/
|
||||
export async function register() {
|
||||
Sentry.init(baseOptions(process.env.NEXT_PUBLIC_SENTRY_DSN));
|
||||
}
|
||||
|
||||
/**
|
||||
* Required by Next 15 to report errors thrown inside a React Server Component.
|
||||
* Without it those surface only as a generic 500 with nothing attached.
|
||||
*/
|
||||
export const onRequestError = Sentry.captureRequestError;
|
||||
+42
-11
@@ -55,15 +55,32 @@ 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.
|
||||
* Social sign-in is optional, per provider and independently.
|
||||
*
|
||||
* 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 a provider; see `socialProviders`.
|
||||
*/
|
||||
const googleId = process.env.AUTH_GOOGLE_ID;
|
||||
const googleSecret = process.env.AUTH_GOOGLE_SECRET;
|
||||
|
||||
const microsoftId = process.env.AUTH_MICROSOFT_ID;
|
||||
const microsoftSecret = process.env.AUTH_MICROSOFT_SECRET;
|
||||
/**
|
||||
* Which Microsoft accounts may sign in. `common` is both work/school and
|
||||
* personal accounts, which is what a consumer marketplace wants; a single
|
||||
* tenant GUID restricts it to one organisation.
|
||||
*
|
||||
* better-auth defaults this to `common` itself, but naming it here keeps the
|
||||
* decision visible — the difference between "anyone with a Microsoft account"
|
||||
* and "my company only" is not something to discover from a library default.
|
||||
*/
|
||||
const microsoftTenant = process.env.AUTH_MICROSOFT_TENANT_ID ?? 'common';
|
||||
|
||||
const githubId = process.env.AUTH_GITHUB_ID;
|
||||
const githubSecret = process.env.AUTH_GITHUB_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
|
||||
@@ -140,20 +157,34 @@ export const auth = betterAuth({
|
||||
emailAndPassword: { enabled: false },
|
||||
|
||||
/**
|
||||
* Google is registered only when it can actually work.
|
||||
* Each provider 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
|
||||
* return 404 PROVIDER_NOT_FOUND, which <SocialButton> 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.
|
||||
* The buttons themselves are NOT conditional. See components/auth/social-sign-in.
|
||||
*/
|
||||
socialProviders: googleId && googleSecret
|
||||
? { google: { clientId: googleId, clientSecret: googleSecret } }
|
||||
: {},
|
||||
socialProviders: {
|
||||
...(googleId && googleSecret
|
||||
? { google: { clientId: googleId, clientSecret: googleSecret } }
|
||||
: {}),
|
||||
...(microsoftId && microsoftSecret
|
||||
? {
|
||||
microsoft: {
|
||||
clientId: microsoftId,
|
||||
clientSecret: microsoftSecret,
|
||||
tenantId: microsoftTenant,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(githubId && githubSecret
|
||||
? { github: { clientId: githubId, clientSecret: githubSecret } }
|
||||
: {}),
|
||||
},
|
||||
|
||||
plugins: [
|
||||
phoneNumber({
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { ErrorEvent } from '@sentry/nextjs';
|
||||
|
||||
/**
|
||||
* Shared error-reporting policy for Bugsink.
|
||||
*
|
||||
* Bugsink speaks the Sentry wire protocol, so the Sentry SDK is the client. Two
|
||||
* things follow from what it is and what this app holds:
|
||||
*
|
||||
* 1. Bugsink is an ERROR tracker, not an APM. It has no tracing, profiling or
|
||||
* session-replay ingest, so those are all off — sending them would burn
|
||||
* bandwidth and the user's battery to produce payloads nothing reads.
|
||||
*
|
||||
* 2. On this platform the phone number IS the credential. A login OTP, a
|
||||
* session token or a phone number reaching a crash report turns the error
|
||||
* tracker into a place where someone's account can be taken over. Scrubbing
|
||||
* is therefore not optional hygiene here; it is part of the auth boundary.
|
||||
*/
|
||||
|
||||
/** Fields that must never leave the process, whatever nests them. */
|
||||
const SECRET_KEYS = [
|
||||
'phone',
|
||||
'phonenumber',
|
||||
'code',
|
||||
'otp',
|
||||
'token',
|
||||
'accesstoken',
|
||||
'refreshtoken',
|
||||
'password',
|
||||
'secret',
|
||||
'authorization',
|
||||
'cookie',
|
||||
'session',
|
||||
'email',
|
||||
];
|
||||
|
||||
/** E.164 anywhere in free text — a message, a URL, a stack frame. */
|
||||
const PHONE_PATTERN = /\+\d{8,15}/g;
|
||||
/** A bare 6-digit run, which is the shape of our OTP. */
|
||||
const OTP_PATTERN = /\b\d{6}\b/g;
|
||||
|
||||
export const REDACTED = '[redacted]';
|
||||
|
||||
function scrubString(value: string): string {
|
||||
return value.replace(PHONE_PATTERN, REDACTED).replace(OTP_PATTERN, REDACTED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk anything and redact by key name and by value shape.
|
||||
*
|
||||
* Depth-limited and cycle-safe: an event is arbitrary user data, and a crash
|
||||
* reporter that itself crashes on a circular reference loses the very report
|
||||
* that mattered.
|
||||
*/
|
||||
export function scrub(value: unknown, seen = new WeakSet<object>(), depth = 0): unknown {
|
||||
if (depth > 8) return REDACTED;
|
||||
if (typeof value === 'string') return scrubString(value);
|
||||
if (value === null || typeof value !== 'object') return value;
|
||||
|
||||
if (seen.has(value as object)) return REDACTED;
|
||||
seen.add(value as object);
|
||||
|
||||
if (Array.isArray(value)) return value.map((v) => scrub(v, seen, depth + 1));
|
||||
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
out[key] = SECRET_KEYS.includes(key.toLowerCase()) ? REDACTED : scrub(v, seen, depth + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Last gate before an event leaves the process.
|
||||
*
|
||||
* Returning null drops the event entirely — used for noise that is not a bug.
|
||||
*/
|
||||
export function beforeSend(event: ErrorEvent): ErrorEvent | null {
|
||||
// Never ship headers or cookies: the session cookie is a live credential.
|
||||
if (event.request) {
|
||||
delete event.request.cookies;
|
||||
delete event.request.headers;
|
||||
if (event.request.url) event.request.url = scrubString(event.request.url);
|
||||
if (event.request.query_string && typeof event.request.query_string === 'string') {
|
||||
event.request.query_string = scrubString(event.request.query_string);
|
||||
}
|
||||
if (event.request.data) event.request.data = scrub(event.request.data);
|
||||
}
|
||||
|
||||
// Identify the user by id only. A phone number or email here would make the
|
||||
// error tracker a directory of everyone who has ever hit a bug.
|
||||
if (event.user) {
|
||||
event.user = { id: event.user.id };
|
||||
}
|
||||
|
||||
if (event.extra) event.extra = scrub(event.extra) as Record<string, unknown>;
|
||||
if (event.message) event.message = scrubString(event.message);
|
||||
|
||||
for (const exception of event.exception?.values ?? []) {
|
||||
if (exception.value) exception.value = scrubString(exception.value);
|
||||
}
|
||||
|
||||
event.breadcrumbs = event.breadcrumbs?.map((crumb) => ({
|
||||
...crumb,
|
||||
message: crumb.message ? scrubString(crumb.message) : crumb.message,
|
||||
data: crumb.data ? (scrub(crumb.data) as Record<string, unknown>) : crumb.data,
|
||||
}));
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options every runtime shares.
|
||||
*
|
||||
* `enabled` is gated on the DSN rather than on NODE_ENV: a staging deploy with a
|
||||
* DSN should report, and a local run without one should stay silent instead of
|
||||
* throwing at boot.
|
||||
*/
|
||||
export function baseOptions(dsn: string | undefined) {
|
||||
return {
|
||||
dsn,
|
||||
enabled: Boolean(dsn),
|
||||
environment: process.env.NODE_ENV,
|
||||
// Bugsink ingests errors only — no tracing, no profiling, no replay.
|
||||
tracesSampleRate: 0,
|
||||
// Phone numbers, IPs and headers are exactly what must not be collected here.
|
||||
sendDefaultPii: false,
|
||||
beforeSend,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* The pro someone picked, held across a round trip.
|
||||
*
|
||||
* Swiping right is where this product's funnel actually starts, and answering
|
||||
* "which job?" often means leaving the page first — to sign in, or to post the
|
||||
* job there is nothing to send yet. Without somewhere to park the choice, the
|
||||
* person comes back to a fresh deck and the pro they wanted is gone.
|
||||
*
|
||||
* `sessionStorage`, not a URL param: a pro id in the address bar survives being
|
||||
* pasted into a chat, and this is nobody else's business. Not `localStorage`
|
||||
* either — an intent from last Tuesday is not an intent.
|
||||
*/
|
||||
const KEY = 'linkder:pending-hire';
|
||||
|
||||
export interface PendingHire {
|
||||
proId: string;
|
||||
/** For the copy on the way back — "Post a job for Marc". */
|
||||
name: string;
|
||||
}
|
||||
|
||||
export function setPendingHire(hire: PendingHire): void {
|
||||
try {
|
||||
sessionStorage.setItem(KEY, JSON.stringify(hire));
|
||||
} catch {
|
||||
// Private mode, or storage disabled. The flow still works, it just cannot
|
||||
// resume — which is why nothing downstream treats this as load-bearing.
|
||||
}
|
||||
}
|
||||
|
||||
export function readPendingHire(): PendingHire | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(KEY);
|
||||
if (!raw) return null;
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (
|
||||
typeof parsed === 'object' &&
|
||||
parsed !== null &&
|
||||
typeof (parsed as PendingHire).proId === 'string' &&
|
||||
typeof (parsed as PendingHire).name === 'string'
|
||||
) {
|
||||
return parsed as PendingHire;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearPendingHire(): void {
|
||||
try {
|
||||
sessionStorage.removeItem(KEY);
|
||||
} catch {
|
||||
/* nothing to clear if it could never be written */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Trail a fast-changing value by `delayMs`.
|
||||
*
|
||||
* Used by search so a query fires per pause, not per keystroke: at ~5 keystrokes
|
||||
* a second an undebounced box is five round trips for a word nobody finished
|
||||
* typing.
|
||||
*/
|
||||
export function useDebouncedValue<T>(value: T, delayMs = 250): T {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebounced(value), delayMs);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, delayMs]);
|
||||
|
||||
return debounced;
|
||||
}
|
||||
@@ -5,12 +5,67 @@ export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
/** "1.2 km away" / "800 m away" — pros are local, so precision matters up close. */
|
||||
/**
|
||||
* "1.2 km away" / "750 m away" — pros are local, so precision matters up close.
|
||||
*
|
||||
* Coarsened to 250m under a kilometre, not 50m.
|
||||
*
|
||||
* While every coordinate was the city centre the fine figure was harmless. With
|
||||
* real geocoding it is not: a job's street address is deliberately withheld from
|
||||
* a pro until a booking exists (schema/jobs.ts, job.mineForPro), and a distance
|
||||
* accurate to 50m read from two or three cards triangulates it back. Ranking
|
||||
* still uses full precision server-side — this blunts only the rendered number.
|
||||
*/
|
||||
export function formatDistance(metres: number): string {
|
||||
if (metres < 1000) return `${Math.round(metres / 50) * 50} m away`;
|
||||
if (metres < 1000) return `${Math.round(metres / 250) * 250} m away`;
|
||||
return `${(metres / 1000).toFixed(1)} km away`;
|
||||
}
|
||||
|
||||
const MINUTE = 60_000;
|
||||
const HOUR = 60 * MINUTE;
|
||||
const DAY = 24 * HOUR;
|
||||
|
||||
/**
|
||||
* How long ago, for a message or a list row: "now", "12 min", "3 h", "Tue",
|
||||
* "12 Mar".
|
||||
*
|
||||
* Deliberately terse and unitless past a week — this sits at the end of a row
|
||||
* that has already spent its width on the thing that matters, and "last Tuesday
|
||||
* at 14:32" is not what someone scanning a list is reading for.
|
||||
*/
|
||||
export function formatRelativeTime(value: Date, now: Date = new Date()): string {
|
||||
const elapsed = now.getTime() - value.getTime();
|
||||
|
||||
// Clock skew and optimistic rows can both put a timestamp slightly ahead.
|
||||
if (elapsed < MINUTE) return 'now';
|
||||
if (elapsed < HOUR) return `${Math.floor(elapsed / MINUTE)} min`;
|
||||
if (elapsed < DAY) return `${Math.floor(elapsed / HOUR)} h`;
|
||||
if (elapsed < 7 * DAY) return value.toLocaleDateString(undefined, { weekday: 'short' });
|
||||
return value.toLocaleDateString(undefined, { day: 'numeric', month: 'short' });
|
||||
}
|
||||
|
||||
/**
|
||||
* When something is happening: "Today 14:00", "Tomorrow 09:00", "Thu 14:00",
|
||||
* "12 Mar 14:00".
|
||||
*
|
||||
* Compared on calendar days rather than elapsed hours — 23:00 tonight and 01:00
|
||||
* tomorrow are two hours apart and must not both read as "Today".
|
||||
*/
|
||||
export function formatWhen(value: Date, now: Date = new Date()): string {
|
||||
const time = value.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||
|
||||
const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
||||
const days = Math.round((startOfDay(value) - startOfDay(now)) / DAY);
|
||||
|
||||
if (days === 0) return `Today ${time}`;
|
||||
if (days === 1) return `Tomorrow ${time}`;
|
||||
if (days === -1) return `Yesterday ${time}`;
|
||||
if (days > 1 && days < 7) {
|
||||
return `${value.toLocaleDateString(undefined, { weekday: 'short' })} ${time}`;
|
||||
}
|
||||
return `${value.toLocaleDateString(undefined, { day: 'numeric', month: 'short' })} ${time}`;
|
||||
}
|
||||
|
||||
/** "usually replies in 25 min" */
|
||||
export function formatResponseTime(minutes: number | null): string | null {
|
||||
if (minutes === null) return null;
|
||||
|
||||
+12
-41
@@ -1,48 +1,19 @@
|
||||
import { sendSms } from '@linkder/notify';
|
||||
|
||||
/**
|
||||
* SMS delivery for one-time codes.
|
||||
*
|
||||
* In development there is no provider and no spend: the code is logged to the
|
||||
* server console so you can sign in. That path is hard-gated on NODE_ENV so a
|
||||
* production deploy without Twilio credentials FAILS rather than silently
|
||||
* printing login codes into a log aggregator.
|
||||
* The transport itself now lives in @linkder/notify, so the API package can
|
||||
* reach it too — a tRPC procedure cannot import from `apps/web`, and the sign-in
|
||||
* code and a "somebody wants to hire you" text have no business going out
|
||||
* through two different Twilio clients with two different failure policies.
|
||||
*
|
||||
* The copy stays here. This is the one message that is part of the auth flow
|
||||
* rather than part of the product, and it says things the others must not.
|
||||
*/
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
export async function sendVerificationSms(to: string, code: string): Promise<void> {
|
||||
const sid = process.env.TWILIO_ACCOUNT_SID;
|
||||
const token = process.env.TWILIO_AUTH_TOKEN;
|
||||
const from = process.env.TWILIO_FROM_NUMBER;
|
||||
|
||||
if (!sid || !token || !from) {
|
||||
if (isProduction) {
|
||||
throw new Error(
|
||||
'SMS is not configured (TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_FROM_NUMBER). ' +
|
||||
'Refusing to fall back to console logging in production.',
|
||||
);
|
||||
}
|
||||
console.info(`\n [dev SMS] verification code for ${to}: ${code}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`https://api.twilio.com/2010-04-01/Accounts/${sid}/Messages.json`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Basic ${Buffer.from(`${sid}:${token}`).toString('base64')}`,
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
To: to,
|
||||
From: from,
|
||||
Body: `${code} is your Linkder code. It expires in 5 minutes. We will never ask you for it.`,
|
||||
}),
|
||||
},
|
||||
await sendSms(
|
||||
to,
|
||||
`${code} is your Linkder code. It expires in 5 minutes. We will never ask you for it.`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
// Never log the code itself in production.
|
||||
const detail = await response.text().catch(() => '<no body>');
|
||||
throw new Error(`Twilio rejected the message (${response.status}): ${detail}`);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user