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>
216 lines
7.0 KiB
TypeScript
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’s everyone nearby</h2>
|
|
<p className="text-sm text-[var(--muted)]">
|
|
You’ve seen every verified pro who covers your area for this trade. We’ll notify
|
|
you the moment a new one joins.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|