'use client'; import { useCallback, useMemo, useRef, useState } from 'react'; import { AnimatePresence, motion, useMotionValue, useTransform } from 'motion/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'; /** 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; /** * 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, onRewind, rewindSignal = 0, onWatch, onAsk, watchedProIds, }: 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); // 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; 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); 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; } }, [cards, onDecide], ); if (remaining.length === 0) { return ; } // Only the top three are mounted — the rest are just a visual stack. const visible = remaining.slice(0, 3); return (
{visible .map((card, i) => ( )) .reverse()}
{/* DESIGN.md §6.8. rewind · pass · watch · hire · ask. Wider gaps around the two decisions than between them and the helpers, so the pair reads as a pair rather than as items 2 and 4 of five. */}
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)} />
); } /** * One card. * * Exported so the profile screen can render a pro their OWN card, byte for byte * what a client sees. A lookalike would drift the moment either side changed. * Pass no `onDecide` to get a static, non-draggable card. */ export function Card({ card, depth = 0, onDecide, }: { card: DeckCard; depth?: number; onDecide?: (proId: string, direction: 'left' | 'right') => void; }) { const x = useMotionValue(0); const rotate = useTransform(x, [-300, 0, 300], [-14, 0, 14]); const hireOpacity = useTransform(x, [40, COMMIT_PX], [0, 1]); const passOpacity = useTransform(x, [-COMMIT_PX, -40], [1, 0]); const interactive = Boolean(onDecide); return ( 0 ? 400 : -400, opacity: 0, transition: { duration: 0.2 }, }} drag={interactive ? 'x' : false} dragConstraints={{ left: 0, right: 0 }} dragElastic={0.6} onDragEnd={(_, info) => { if (!onDecide) return; if (info.offset.x > COMMIT_PX) onDecide(card.proId, 'right'); else if (info.offset.x < -COMMIT_PX) onDecide(card.proId, 'left'); }} > {/* eslint-disable-next-line @next/next/no-img-element */}
{interactive && ( <> SEND JOB PASS )}
{/* text-white must be on the h2 itself, not inherited from the wrapper: globals.css sets `h1..h6 { color: var(--text-strong) }` in @layer base, and that rule beats the parent's colour. Without this the name renders ink-950 navy on a dark photo and is unreadable. */}

{card.name}

{card.ratingCount > 0 ? ( {card.ratingAvg?.toFixed(1)} ({card.ratingCount}) ) : ( New )}

{card.headline}

{formatDistance(card.distanceM)} €{(card.hourlyRateCents / 100).toFixed(0)}/hr {card.completedJobs > 0 && {card.completedJobs} jobs done}
{formatResponseTime(card.avgResponseMinutes) && (

{formatResponseTime(card.avgResponseMinutes)}

)}

{card.bio}

); } /** * DESIGN.md §6.8. One of the five circles under the card. * * The row used to be five different colours and five drop shadows — a rainbow * of outlined rings, each shouting as loudly as the next. Three rules replaced * that, and they are the whole design: * * 1. TWO colours, not five. Only the two actions that decide the card are * coloured, and they borrow the exact hues of the drag stamps they mirror * (`PASS` in stop, `SEND JOB` in go). Rewind, watch and ask are ink: they * are helpers, and a helper competing with the decision is noise. * 2. NO elevation. The old row put a drop shadow under all five. §5 already * lists three shadows on one screen as a Don't, and five discs each casting * their own is worse than untidy — it muddies the colour underneath. Flat, * and let size and hue carry the hierarchy instead. * 3. SOLID, not ring and not tint. A filled disc with a white glyph is * unambiguous and holds its weight above a full-bleed photo. An outlined * ring reads as a diagram of a button; a pastel tint reads as a sticker. * * `active` is for the one control whose state persists (watch) and inverts it to * a solid accent — with the label changing too, because §8 forbids fill as the * only signal. */ function ActionButton({ label, icon: Icon, tone, size = 'sm', active = false, disabled = false, onClick, }: { label: string; icon: typeof Check; tone: 'pass' | 'hire' | 'neutral'; size?: 'sm' | 'lg'; active?: boolean; disabled?: boolean; onClick: () => void; }) { const isLarge = size === 'lg'; return ( ); } function EmptyDeck() { return (

That’s everyone nearby

You’ve seen every verified pro who covers your area for this trade. We’ll notify you the moment a new one joins.

); }