'use client'; import { useCallback, useMemo, 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'; import { cn, formatDistance, formatResponseTime } from '@/lib/utils'; /** Horizontal drag past this many pixels commits the swipe. */ const COMMIT_PX = 110; export interface DeckProps { cards: DeckCard[]; onDecide: (proId: string, direction: 'left' | 'right') => void | Promise; } export function Deck({ cards, onDecide }: DeckProps) { const [index, setIndex] = useState(0); const remaining = useMemo(() => cards.slice(index), [cards, index]); const decide = useCallback( (proId: string, direction: 'left' | 'right') => { setIndex((i) => i + 1); void onDecide(proId, direction); }, [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()}
visible[0] && decide(visible[0].proId, 'left')} />

{remaining.length} left

visible[0] && decide(visible[0].proId, 'right')} />
); } function Card({ card, depth, 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 )}

{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}

); } function ActionButton({ label, variant, onClick, }: { label: string; variant: 'pass' | 'hire'; onClick: () => void; }) { const isHire = variant === 'hire'; const Icon = isHire ? Check : X; 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.

); }