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>
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user