From 0c49aa950206bbabdbf3955114fc362cd58a35d9 Mon Sep 17 00:00:00 2001 From: serfa Date: Fri, 21 Aug 2026 06:49:17 -0400 Subject: [PATCH] =?UTF-8?q?Deck:=20five=20actions=20=E2=80=94=20rewind,=20?= =?UTF-8?q?watch=20and=20ask,=20either=20side=20of=20pass=20and=20send?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- DESIGN.md | 25 +- apps/web/src/app/jobs-panel.tsx | 2 +- apps/web/src/app/showcase-deck.tsx | 48 + apps/web/src/components/deck.tsx | 128 +- apps/web/src/components/hire/ask-sheet.tsx | 152 + apps/web/src/components/jobs/chat-thread.tsx | 30 +- apps/web/src/components/jobs/deal-strip.tsx | 2 +- packages/api/src/root.ts | 4 + packages/api/src/routers/enquiry.ts | 238 ++ packages/api/src/routers/message.ts | 175 +- packages/api/src/routers/watch.ts | 145 + packages/api/test/discovery.router.test.ts | 296 ++ packages/api/test/message.router.test.ts | 58 +- .../db/drizzle/0007_medical_dark_beast.sql | 35 + packages/db/drizzle/meta/0007_snapshot.json | 3665 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/schema/discovery.ts | 110 + packages/db/src/schema/index.ts | 1 + packages/db/src/schema/messaging.ts | 29 +- packages/shared/src/constants.ts | 13 + packages/shared/src/schemas.ts | 6 +- 21 files changed, 5080 insertions(+), 89 deletions(-) create mode 100644 apps/web/src/components/hire/ask-sheet.tsx create mode 100644 packages/api/src/routers/enquiry.ts create mode 100644 packages/api/src/routers/watch.ts create mode 100644 packages/api/test/discovery.router.test.ts create mode 100644 packages/db/drizzle/0007_medical_dark_beast.sql create mode 100644 packages/db/drizzle/meta/0007_snapshot.json create mode 100644 packages/db/src/schema/discovery.ts diff --git a/DESIGN.md b/DESIGN.md index 8be2134..c35be9b 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -296,8 +296,29 @@ The tab bar is the app's only persistent navigation. There is no footer. The one place that breaks the flat rule. `radius-deck` (28px), `shadow-lg`, full-bleed photo, bottom scrim `linear-gradient(to top, rgb(0 6 36 / .92), rgb(0 6 36 / .35) 45%, transparent)`. -Overlay stamps: `SEND JOB` in `go-600`, `PASS` in `stop-500`, 4px border, ±12° rotation. Action -buttons are 64px circles, 2px border, `ink-0` fill. +Overlay stamps: `SEND JOB` in `go-600`, `PASS` in `stop-500`, 4px border, ±12° rotation. + +**The action row** is five circles, in one fixed order: + +| | Action | Size | Ink | +|---|---|---|---| +| 1 | Rewind — bring the last card back | 44px | `ink-600` | +| 2 | Pass | **64px** | `stop-500` | +| 3 | Watch — tell me when they are free | 44px | `brand-500` | +| 4 | Send this job | **64px** | `go-600` | +| 5 | Ask a question | 44px | `ink-950` | + +Two sizes, and the size *is* the hierarchy: the two decisions that end the card +are 64px and reach the thumb first; the three that do not are 44px — still the +minimum target from §8, never smaller. Gaps are 16px between a small and a large, +24px between the two larges, so the pair still reads as the pair. + +All five are 2px bordered circles on `ink-0`. A control whose state persists — +watch — fills with its own colour when active, and its label changes with it; +selection is never carried by fill alone (§8). + +Rewind is `disabled` with nothing to undo rather than hidden. A row that changes +length as you swipe moves the two buttons underneath your thumb. --- diff --git a/apps/web/src/app/jobs-panel.tsx b/apps/web/src/app/jobs-panel.tsx index 695c892..faa8da4 100644 --- a/apps/web/src/app/jobs-panel.tsx +++ b/apps/web/src/app/jobs-panel.tsx @@ -83,7 +83,7 @@ export function JobsPanel({ const { matchId, jobId } = state.view; return ( go(jobId ? { kind: 'job', jobId } : { kind: 'list' })} /> ); diff --git a/apps/web/src/app/showcase-deck.tsx b/apps/web/src/app/showcase-deck.tsx index 68cdd61..f21b404 100644 --- a/apps/web/src/app/showcase-deck.tsx +++ b/apps/web/src/app/showcase-deck.tsx @@ -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(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 => { @@ -105,6 +109,31 @@ export function ShowcaseDeck({ [cards], ); + /* ── the three secondary deck actions ── */ + + const [asking, setAsking] = useState(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); + }} /> )} @@ -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. */} + + setAsking(null)} + onSent={() => { + // Straight to the conversation they just started. + setJobs({ ...jobs, segment: 'current', view: { kind: 'list' } }); + setTab('jobs'); + }} + /> ); } diff --git a/apps/web/src/components/deck.tsx b/apps/web/src/components/deck.tsx index e547818..14340fc 100644 --- a/apps/web/src/components/deck.tsx +++ b/apps/web/src/components/deck.tsx @@ -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; + /** + * 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; } -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) { -
+ {/* 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. */} +
+ onRewind?.()} + /> visible[0] && decide(visible[0].proId, 'left')} /> + visible[0] && onWatch?.(visible[0].proId)} + /> visible[0] && decide(visible[0].proId, 'right')} /> + visible[0] && onAsk?.(visible[0].proId)} + />
); @@ -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 ( ); } diff --git a/apps/web/src/components/hire/ask-sheet.tsx b/apps/web/src/components/hire/ask-sheet.tsx new file mode 100644 index 0000000..72a2892 --- /dev/null +++ b/apps/web/src/components/hire/ask-sheet.tsx @@ -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 ( + { + setPendingHire({ proId: pro.proId, name }); + router.push('/sign-in?next=/'); + }} + > + Continue with phone + + } + /> + ); + } + + if (me.data?.role === 'pro') { + return ( + + Back to the deck + + } + /> + ); + } + + const remaining = allowance.data?.remaining ?? null; + const outOfRoom = remaining === 0; + const canSend = body.trim().length >= 10 && !create.isPending && !outOfRoom; + + return ( + + {create.error && ( +

+ {create.error.message} +

+ )} + + + + } + > + {outOfRoom ? ( + + 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. + + ) : ( + <> +