Deck: five actions — rewind, watch and ask, either side of pass and send

The deck had two answers, which is a lot to hang on a swipe: send this pro a job
right now, or lose them. Three more, in one fixed row (DESIGN.md §6.8):

  rewind · ✗ · watch · ✓ · ask

Size is the hierarchy — the two that end the card stay 64px, the three that do
not are 44px, never below the §8 floor. Rewind is disabled rather than hidden
when there is nothing to undo, so the row never changes length and the big pair
never moves out from under a thumb.

Rewind is local. The entry deck writes no swipes — a `swipes` row is job-scoped
and there is no job there — so the card leaving was only ever an index move.

Watch: "tell me when this one is free"
- `pro_watches` snapshots the pro's availability AT WATCH TIME, because the
  trigger is a change, not a state. Without it a sweep would notify every
  watcher on every run, since "available" stays true for as long as they stay
  available.
- Deliberately the narrow version: a pro with is_accepting_jobs = false is
  invisible everywhere (eligibleProAtAnyDistance requires it), so a watch can
  only be placed on somebody already free and fires on the away-and-back cycle.
  "Free at a time that suits me" needs pro_availability — seeded since M1, read
  by nothing — to become a real calendar. Flagged rather than faked.

Ask: a question, before there is a job
- This is the first way to reach a pro who has not agreed to anything. Chat was
  gated behind message → match → accepted request → job, and that gate is what
  made a pro's inbox worth opening, so the cap is not decoration:
  MAX_OPEN_ENQUIRIES unanswered at a time, one thread per pair so it cannot be
  walked around, answered threads stop counting, stale ones fall out, and the
  pro can close one.
- `enquiries` is its own table, not a match with a null job: a match means a pro
  said yes to specific work, and collapsing the two would put rows in `matches`
  that no quote, booking or review could hang off.
- `messages` now belongs to a match OR an enquiry, with a CHECK making the
  illegal state unrepresentable. One message table, so one chat screen.

283 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:
serfa
2026-08-21 06:49:17 -04:00
co-authored by Claude Opus 5
parent 974e312534
commit 0c49aa9502
21 changed files with 5080 additions and 89 deletions
+1 -1
View File
@@ -83,7 +83,7 @@ export function JobsPanel({
const { matchId, jobId } = state.view;
return (
<ChatThread
matchId={matchId}
threadRef={{ matchId }}
onBack={() => go(jobId ? { kind: 'job', jobId } : { kind: 'list' })}
/>
);
+48
View File
@@ -12,6 +12,7 @@ import { INITIAL_SEARCH_STATE, SearchPanel, type SearchState } from './search-pa
import { ProfilePanel } from './profile-panel';
import { INITIAL_JOBS_STATE, JobsPanel, type JobsState } from './jobs-panel';
import { SendJobSheet } from '@/components/hire/send-job-sheet';
import { AskSheet } from '@/components/hire/ask-sheet';
import { clearPendingHire, readPendingHire } from '@/lib/pending-hire';
import { api } from '@/lib/trpc';
@@ -88,6 +89,9 @@ export function ShowcaseDeck({
*/
const [hiring, setHiring] = useState<DeckCard | null>(null);
const resolve = useRef<((verdict: SwipeVerdict) => void) | null>(null);
// Bumped by rewind; the Deck reads it to step its own index back.
const [rewindAt, setRewindAt] = useState(0);
const utils = api.useUtils();
const decide = useCallback(
(proId: string, direction: 'left' | 'right'): Promise<SwipeVerdict> => {
@@ -105,6 +109,31 @@ export function ShowcaseDeck({
[cards],
);
/* ── the three secondary deck actions ── */
const [asking, setAsking] = useState<DeckCard | null>(null);
// Errors for an anonymous visitor, same as the unread badge — no session,
// nothing watched.
const watched = api.watch.mine.useQuery(undefined, { retry: false });
const watchedIds = useMemo(
() => new Set((watched.data ?? []).map((w) => w.proId)),
[watched.data],
);
const toggleWatch = api.watch.toggle.useMutation({
onSettled: () => void utils.watch.mine.invalidate(),
});
/**
* Rewind steps the local stack back one.
*
* Nothing to undo server-side: the entry deck writes no swipes (a `swipes` row
* is job-scoped and there is no job here), so the card leaving was only ever a
* local index move. `deck.undo` is for the per-job deck, where a swipe is real.
*/
const rewind = useCallback(() => setRewindAt((n) => n + 1), []);
const onResolved = useCallback((outcome: 'sent' | 'dismissed') => {
setHiring(null);
resolve.current?.(outcome === 'sent' ? 'commit' : 'revert');
@@ -197,6 +226,14 @@ export function ShowcaseDeck({
key={categoryId ?? 'all'}
cards={cards}
onDecide={decide}
onRewind={rewind}
rewindSignal={rewindAt}
onWatch={(proId) => toggleWatch.mutate({ proId })}
watchedProIds={watchedIds}
onAsk={(proId) => {
const card = cards.find((c) => c.proId === proId);
if (card) setAsking(card);
}}
/>
)}
</div>
@@ -215,6 +252,17 @@ export function ShowcaseDeck({
{/* Inside the phone frame, not the page — the sheet belongs to this
screen and must not cover the browser chrome around the mock. */}
<SendJobSheet pro={hiring} open={hiring !== null} onResolved={onResolved} />
<AskSheet
pro={asking}
open={asking !== null}
onClose={() => setAsking(null)}
onSent={() => {
// Straight to the conversation they just started.
setJobs({ ...jobs, segment: 'current', view: { kind: 'list' } });
setTab('jobs');
}}
/>
</div>
);
}
+114 -14
View File
@@ -2,7 +2,7 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { AnimatePresence, motion, useMotionValue, useTransform } from 'motion/react';
import { Check, MapPin, Star, X } from 'lucide-react';
import { Check, Eye, MapPin, MessageCircle, Star, Undo2, X } from 'lucide-react';
import type { DeckCard } from '@linkder/db';
import { cn, formatDistance, formatResponseTime } from '@/lib/utils';
@@ -26,9 +26,35 @@ export interface DeckProps {
proId: string,
direction: 'left' | 'right',
) => void | Promise<void | SwipeVerdict>;
/**
* The three secondary actions. All optional — a deck rendered without them
* shows the buttons disabled rather than missing, so the row keeps its shape
* and the two big buttons never move.
*/
onRewind?: () => void;
/**
* Incremented by the parent to step the stack back one.
*
* A signal rather than a callback returning state: the index lives in the
* Deck, and handing it out so a parent could decrement it would give two
* owners to the one thing that must stay consistent with the card on screen.
*/
rewindSignal?: number;
onWatch?: (proId: string) => void;
onAsk?: (proId: string) => void;
/** Which pros the viewer already watches, for the filled state. */
watchedProIds?: ReadonlySet<string>;
}
export function Deck({ cards, onDecide }: DeckProps) {
export function Deck({
cards,
onDecide,
onRewind,
rewindSignal = 0,
onWatch,
onAsk,
watchedProIds,
}: DeckProps) {
const [index, setIndex] = useState(0);
const remaining = useMemo(() => cards.slice(index), [cards, index]);
@@ -36,6 +62,14 @@ export function Deck({ cards, onDecide }: DeckProps) {
// is open would advance past a card nobody ever saw.
const inFlight = useRef(false);
// Step back when the parent asks. Guarded on the previous value so a re-render
// for any other reason does not rewind again.
const lastRewind = useRef(rewindSignal);
if (rewindSignal !== lastRewind.current) {
lastRewind.current = rewindSignal;
if (index > 0) setIndex((i) => Math.max(0, i - 1));
}
const decide = useCallback(
async (proId: string, direction: 'left' | 'right') => {
if (inFlight.current) return;
@@ -83,17 +117,48 @@ export function Deck({ cards, onDecide }: DeckProps) {
</AnimatePresence>
</div>
<div className="flex shrink-0 items-center gap-12 pb-2">
{/* DESIGN.md §6.8. Fixed order, two sizes: rewind · pass · watch · hire · ask.
Gaps are tighter around the small ones so the middle pair still reads
as the pair. */}
<div className="flex shrink-0 items-center gap-4 pb-2">
<ActionButton
label="Bring back the last one"
icon={Undo2}
tone="ink"
// Disabled rather than hidden: a row that changes length as you swipe
// moves the two big buttons out from under your thumb.
disabled={index === 0 || !onRewind}
onClick={() => onRewind?.()}
/>
<ActionButton
label="Not this one"
variant="pass"
icon={X}
tone="pass"
size="lg"
onClick={() => visible[0] && decide(visible[0].proId, 'left')}
/>
<ActionButton
label={watchedProIds?.has(visible[0]?.proId ?? '') ? 'Stop watching' : 'Tell me when they are free'}
icon={Eye}
tone="accent"
active={watchedProIds?.has(visible[0]?.proId ?? '') ?? false}
disabled={!onWatch}
onClick={() => visible[0] && onWatch?.(visible[0].proId)}
/>
<ActionButton
label="Send this job"
variant="hire"
icon={Check}
tone="hire"
size="lg"
onClick={() => visible[0] && decide(visible[0].proId, 'right')}
/>
<ActionButton
label="Ask a question"
icon={MessageCircle}
tone="ink"
disabled={!onAsk}
onClick={() => visible[0] && onAsk?.(visible[0].proId)}
/>
</div>
</div>
);
@@ -215,32 +280,67 @@ export function Card({
);
}
/**
* DESIGN.md §6.8. One of the five circles under the card.
*
* `size` IS the hierarchy, not decoration: the two actions that end the card are
* large and meet the thumb first; the three that do not are small — but never
* below 44px, which is the floor in §8.
*
* `active` fills the circle for the one control whose state persists (watch),
* and its label changes with it, because §8 forbids fill as the only signal.
*/
function ActionButton({
label,
variant,
icon: Icon,
tone,
size = 'sm',
active = false,
disabled = false,
onClick,
}: {
label: string;
variant: 'pass' | 'hire';
icon: typeof Check;
tone: 'pass' | 'hire' | 'accent' | 'ink';
size?: 'sm' | 'lg';
active?: boolean;
disabled?: boolean;
onClick: () => void;
}) {
const isHire = variant === 'hire';
const Icon = isHire ? Check : X;
const isLarge = size === 'lg';
const colour = {
pass: 'var(--color-stop-500)',
hire: 'var(--color-go-600)',
accent: 'var(--color-brand-500)',
ink: 'var(--color-ink-950)',
}[tone];
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={label}
aria-pressed={active || undefined}
title={label}
style={{
borderColor: colour,
color: active ? undefined : colour,
backgroundColor: active ? colour : undefined,
}}
className={cn(
'flex h-16 w-16 items-center justify-center rounded-full border-2 bg-raised shadow-lg',
'flex items-center justify-center rounded-full border-2 shadow-lg',
'transition hover:scale-105 active:scale-95',
isHire
? 'border-[var(--color-go-600)] text-[var(--color-go-600)]'
: 'border-[var(--color-stop-500)] text-[var(--color-stop-500)]',
'disabled:pointer-events-none disabled:opacity-35 disabled:shadow-none',
isLarge ? 'h-16 w-16' : 'h-11 w-11',
active ? 'text-white' : 'bg-raised',
)}
>
<Icon className="h-7 w-7" strokeWidth={3} aria-hidden />
<Icon
className={isLarge ? 'h-7 w-7' : 'h-5 w-5'}
strokeWidth={isLarge ? 3 : 2.5}
aria-hidden
/>
</button>
);
}
+152
View File
@@ -0,0 +1,152 @@
'use client';
import { useState } from 'react';
import type { DeckCard } from '@linkder/db';
import { api } from '@/lib/trpc';
import { Banner, Button, Sheet, Textarea } from '@/components/ui';
import { setPendingHire } from '@/lib/pending-hire';
import { useRouter } from 'next/navigation';
/**
* Ask a pro a question, before there is a job.
*
* This is the one place in the product that reaches somebody who has not agreed
* to anything, so the sheet is deliberately honest about the deal on both sides:
* the customer is told the pro can end it, and the remaining allowance is shown
* before they type rather than after they press send.
*/
export function AskSheet({
pro,
open,
onClose,
onSent,
}: {
pro: DeckCard | null;
open: boolean;
onClose: () => void;
onSent: (enquiryId: string) => void;
}) {
const router = useRouter();
const [body, setBody] = useState('');
const me = api.user.me.useQuery(undefined, { retry: false });
const allowance = api.enquiry.allowance.useQuery(undefined, {
enabled: open && me.data?.role === 'client',
retry: false,
});
const utils = api.useUtils();
const create = api.enquiry.create.useMutation({
onSuccess: ({ enquiryId }) => {
setBody('');
void utils.enquiry.allowance.invalidate();
void utils.enquiry.mineAsClient.invalidate();
onSent(enquiryId);
onClose();
},
});
if (!pro) return null;
const name = pro.name ?? 'this pro';
/* ── anonymous ── */
if (!me.isLoading && (me.error || !me.data)) {
return (
<Sheet
open={open}
onClose={onClose}
title={`Ask ${name} a question`}
body="Sign in first — they need to know who is asking."
actions={
<Button
size="lg"
block
onClick={() => {
setPendingHire({ proId: pro.proId, name });
router.push('/sign-in?next=/');
}}
>
Continue with phone
</Button>
}
/>
);
}
if (me.data?.role === 'pro') {
return (
<Sheet
open={open}
onClose={onClose}
title="You are signed in as a pro"
body="Asking questions is a customer thing. You can still browse who else is on here."
actions={
<Button variant="outline" size="lg" block onClick={onClose}>
Back to the deck
</Button>
}
/>
);
}
const remaining = allowance.data?.remaining ?? null;
const outOfRoom = remaining === 0;
const canSend = body.trim().length >= 10 && !create.isPending && !outOfRoom;
return (
<Sheet
open={open}
onClose={onClose}
title={`Ask ${name} a question`}
body="No job needed. They can reply, or close it."
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({ proId: pro.proId, body })}
>
Send question
</Button>
<Button variant="ghost" size="sm" block onClick={onClose}>
Not now
</Button>
</div>
}
>
{outOfRoom ? (
<Banner tone="warning" title="Give them a chance to reply">
You have {allowance.data?.cap} questions still waiting on an answer. Once somebody
replies you can ask again or post a job, which reaches pros a different way.
</Banner>
) : (
<>
<label className="flex flex-col gap-2">
<span className="text-body-sm text-strong">Your question</span>
<Textarea
rows={4}
value={body}
maxLength={2000}
onChange={(e) => setBody(e.target.value)}
placeholder="Do you cover this kind of work? Roughly what would it cost?"
/>
</label>
<p className="mt-1 text-meta text-faint">
{body.trim().length < 10
? 'A sentence at least — "hi" is not a question anybody can answer.'
: remaining !== null
? `${remaining} more question${remaining === 1 ? '' : 's'} while these are unanswered.`
: `${body.length}/2000`}
</p>
</>
)}
</Sheet>
);
}
+19 -11
View File
@@ -20,7 +20,9 @@ import { cn, formatRelativeTime } from '@/lib/utils';
* 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 }) {
export type ThreadRef = { matchId: string } | { enquiryId: string };
export function ChatThread({ threadRef, onBack }: { threadRef: ThreadRef; onBack: () => void }) {
const utils = api.useUtils();
const [draft, setDraft] = useState('');
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
@@ -28,7 +30,7 @@ export function ChatThread({ matchId, onBack }: { matchId: string; onBack: () =>
const bottom = useRef<HTMLDivElement>(null);
const thread = api.message.thread.useQuery(
{ matchId },
{ ref: threadRef },
{
// 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.
@@ -56,13 +58,17 @@ export function ChatThread({ matchId, onBack }: { matchId: string; onBack: () =>
onSuccess: () => {
setDraft('');
setAttachments([]);
void utils.message.thread.invalidate({ matchId });
void utils.message.thread.invalidate({ ref: threadRef });
void utils.job.mine.invalidate();
void utils.job.mineForPro.invalidate();
void utils.job.matches.invalidate();
},
});
// A stable primitive for effect deps — an object literal would be a new
// reference every render and re-fire the read receipt on a loop.
const threadKey = 'matchId' in threadRef ? threadRef.matchId : threadRef.enquiryId;
const messages = thread.data?.messages ?? [];
const match = thread.data?.match;
const newestId = messages[messages.length - 1]?.id;
@@ -72,10 +78,10 @@ export function ChatThread({ matchId, onBack }: { matchId: string; onBack: () =>
const unreadFromPeer = messages.some((m) => !m.isMine && m.readAt === null);
useEffect(() => {
if (!unreadFromPeer || markRead.isPending) return;
markRead.mutate({ matchId });
markRead.mutate({ ref: threadRef });
// `newestId` is the trigger: re-running on every render would loop.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [matchId, newestId, unreadFromPeer]);
}, [threadKey, newestId, unreadFromPeer]);
// Stick to the bottom as messages land. `auto` rather than `smooth` on first
// paint, or the thread visibly scrolls itself on open.
@@ -89,7 +95,7 @@ export function ChatThread({ matchId, onBack }: { matchId: string; onBack: () =>
const submit = () => {
if (!canSend) return;
send.mutate({ matchId, body: draft, attachments: attachments.map((a) => a.url) });
send.mutate({ ref: threadRef, body: draft, attachments: attachments.map((a) => a.url) });
};
return (
@@ -153,9 +159,9 @@ export function ChatThread({ matchId, onBack }: { matchId: string; onBack: () =>
{/* 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 && (
{match?.matchId && (
<DealStrip
matchId={matchId}
matchId={match.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}
@@ -164,9 +170,11 @@ export function ChatThread({ matchId, onBack }: { matchId: string; onBack: () =>
{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.'}
{match.kind === 'enquiry'
? 'This enquiry was closed. The messages stay as a record.'
: 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">
+1 -1
View File
@@ -41,7 +41,7 @@ export function DealStrip({
const refresh = () => {
void utils.quote.forMatch.invalidate({ matchId });
void utils.booking.forMatch.invalidate({ matchId });
void utils.message.thread.invalidate({ matchId });
void utils.message.thread.invalidate({ ref: { matchId } });
void utils.job.mine.invalidate();
void utils.job.mineForPro.invalidate();
void utils.review.pending.invalidate();