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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user