Files
linkder/apps/web/src/components/deck.tsx
T
serfowiandClaude Opus 5 19623bcccb M0: foundation — monorepo, PostGIS schema, deck query, app shell
Greenfield scaffold for Linkder, a swipe-to-hire marketplace for local
professional services.

- pnpm/turbo monorepo: apps/web, packages/{shared,db}
- Postgres 16 + PostGIS via docker compose (ports 5442/6389 to avoid
  clashing with other local stacks)
- Drizzle schema, 23 tables, geography(Point,4326) with GiST indexes
- Domain core in packages/shared: integer-cent money, status transition
  graphs, deck ranking weights, cancellation policy — 46 unit tests
- Deck query: filtering in Postgres on the GiST index, ranking in JS so
  the weights stay tunable — 18 integration tests against a seeded DB
- Deterministic seed placing pros at known distances, including three
  that must NOT appear on a deck (out of radius, unverified, away)
- Next.js 15 app shell with a working swipe deck
- CI: typecheck, lint, test, build against live postgres+redis

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 13:32:35 -04:00

216 lines
7.0 KiB
TypeScript

'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<void>;
}
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 <EmptyDeck />;
}
// Only the top three are mounted — the rest are just a visual stack.
const visible = remaining.slice(0, 3);
return (
<div className="flex flex-col items-center gap-6">
<div className="relative h-[560px] w-full max-w-sm">
<AnimatePresence initial={false}>
{visible
.map((card, i) => (
<Card
key={card.proId}
card={card}
depth={i}
onDecide={i === 0 ? decide : undefined}
/>
))
.reverse()}
</AnimatePresence>
</div>
<div className="flex items-center gap-5">
<ActionButton
label="Not this one"
variant="pass"
onClick={() => visible[0] && decide(visible[0].proId, 'left')}
/>
<p className="w-28 text-center text-sm text-[var(--muted)] tabular-nums">
{remaining.length} left
</p>
<ActionButton
label="Send this job"
variant="hire"
onClick={() => visible[0] && decide(visible[0].proId, 'right')}
/>
</div>
</div>
);
}
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 (
<motion.article
className={cn(
'deck-card absolute inset-0 overflow-hidden rounded-3xl border shadow-xl',
'border-[var(--border)] bg-[var(--card)]',
interactive ? 'cursor-grab active:cursor-grabbing' : 'pointer-events-none',
)}
style={{ x, rotate, zIndex: 10 - depth }}
initial={{ scale: 0.94, y: 14 * depth, opacity: depth === 2 ? 0 : 1 }}
animate={{ scale: 1 - depth * 0.04, y: 14 * depth, opacity: 1 }}
exit={{
x: x.get() > 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 */}
<img
src={card.photos[0] ?? '/placeholder-pro.jpg'}
alt=""
className="absolute inset-0 h-full w-full object-cover"
draggable={false}
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/25 to-transparent" />
{interactive && (
<>
<motion.div
style={{ opacity: hireOpacity }}
className="absolute left-6 top-6 rotate-[-12deg] rounded-lg border-4 border-[var(--color-go-500)] px-3 py-1 text-2xl font-black tracking-wide text-[var(--color-go-500)]"
>
SEND JOB
</motion.div>
<motion.div
style={{ opacity: passOpacity }}
className="absolute right-6 top-6 rotate-[12deg] rounded-lg border-4 border-[var(--color-stop-500)] px-3 py-1 text-2xl font-black tracking-wide text-[var(--color-stop-500)]"
>
PASS
</motion.div>
</>
)}
<div className="absolute inset-x-0 bottom-0 p-6 text-white">
<div className="mb-1 flex items-baseline gap-2">
<h2 className="text-2xl font-semibold">{card.name}</h2>
{card.ratingCount > 0 ? (
<span className="flex items-center gap-1 text-sm">
<Star className="h-4 w-4 fill-current" aria-hidden />
{card.ratingAvg?.toFixed(1)}
<span className="text-white/60">({card.ratingCount})</span>
</span>
) : (
<span className="rounded-full bg-white/20 px-2 py-0.5 text-xs font-medium">New</span>
)}
</div>
<p className="text-sm text-white/80">{card.headline}</p>
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-white/70">
<span className="flex items-center gap-1">
<MapPin className="h-4 w-4" aria-hidden />
{formatDistance(card.distanceM)}
</span>
<span>{(card.hourlyRateCents / 100).toFixed(0)}/hr</span>
{card.completedJobs > 0 && <span>{card.completedJobs} jobs done</span>}
</div>
{formatResponseTime(card.avgResponseMinutes) && (
<p className="mt-1 text-xs text-white/60">
{formatResponseTime(card.avgResponseMinutes)}
</p>
)}
<p className="mt-3 line-clamp-2 text-sm text-white/75">{card.bio}</p>
</div>
</motion.article>
);
}
function ActionButton({
label,
variant,
onClick,
}: {
label: string;
variant: 'pass' | 'hire';
onClick: () => void;
}) {
const isHire = variant === 'hire';
const Icon = isHire ? Check : X;
return (
<button
type="button"
onClick={onClick}
aria-label={label}
title={label}
className={cn(
'flex h-16 w-16 items-center justify-center rounded-full border-2 bg-[var(--card)] shadow-lg',
'transition hover:scale-105 active:scale-95',
isHire
? 'border-[var(--color-go-500)] text-[var(--color-go-500)]'
: 'border-[var(--color-stop-500)] text-[var(--color-stop-500)]',
)}
>
<Icon className="h-7 w-7" strokeWidth={3} aria-hidden />
</button>
);
}
function EmptyDeck() {
return (
<div className="mx-auto flex h-[560px] max-w-sm flex-col items-center justify-center gap-3 rounded-3xl border border-dashed border-[var(--border)] p-8 text-center">
<h2 className="text-lg font-semibold">That&rsquo;s everyone nearby</h2>
<p className="text-sm text-[var(--muted)]">
You&rsquo;ve seen every verified pro who covers your area for this trade. We&rsquo;ll notify
you the moment a new one joins.
</p>
</div>
);
}