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. + + ) : ( + <> +