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:
serfowi
2026-08-20 13:32:35 -04:00
co-authored by Claude Opus 5
commit 19623bcccb
66 changed files with 12412 additions and 0 deletions
+82
View File
@@ -0,0 +1,82 @@
'use server';
import { and, count, eq } from 'drizzle-orm';
import { db, schema } from '@linkder/db';
import { MAX_OPEN_REQUESTS_PER_JOB, REQUEST_TTL_HOURS, swipeSchema } from '@linkder/shared';
import { revalidatePath } from 'next/cache';
export interface SwipeResult {
ok: boolean;
/** Set when a right swipe actually created a request. */
requested?: boolean;
error?: string;
}
/**
* Record a swipe.
*
* A left swipe is just a tombstone that keeps the pro off this job's deck.
* A right swipe additionally sends the job to that pro as a pending request,
* subject to the open-request cap — that cap is what stops one client from
* spraying every plumber in the city and burning the supply side's goodwill.
*
* TODO(M1): derive the client from the session and verify they own this job.
* Until auth lands this trusts the caller, which is fine for local seeded data
* and must not ship.
*/
export async function recordSwipe(input: {
jobId: string;
proId: string;
direction: 'left' | 'right';
}): Promise<SwipeResult> {
const parsed = swipeSchema.safeParse(input);
if (!parsed.success) {
return { ok: false, error: parsed.error.issues[0]?.message ?? 'Invalid swipe' };
}
const { jobId, proId, direction } = parsed.data;
const job = await db.query.jobs.findFirst({ where: eq(schema.jobs.id, jobId) });
if (!job) return { ok: false, error: 'Job not found' };
if (job.status !== 'open' && job.status !== 'matched') {
return { ok: false, error: 'This job is no longer taking offers' };
}
// The unique index on (job_id, pro_id) is the real guard against double-swipes
// from a double-tap or a replayed request.
await db
.insert(schema.swipes)
.values({ jobId, proId, direction })
.onConflictDoNothing({ target: [schema.swipes.jobId, schema.swipes.proId] });
if (direction === 'left') {
revalidatePath(`/deck/${jobId}`);
return { ok: true, requested: false };
}
const [open] = await db
.select({ n: count() })
.from(schema.requests)
.where(and(eq(schema.requests.jobId, jobId), eq(schema.requests.status, 'pending')));
if ((open?.n ?? 0) >= MAX_OPEN_REQUESTS_PER_JOB) {
return {
ok: false,
error: `You already have ${MAX_OPEN_REQUESTS_PER_JOB} pros considering this job. Wait for one to reply before sending more.`,
};
}
const ttlHours = REQUEST_TTL_HOURS[job.urgency];
await db
.insert(schema.requests)
.values({
jobId,
proId,
expiresAt: new Date(Date.now() + ttlHours * 3_600_000),
})
.onConflictDoNothing({ target: [schema.requests.jobId, schema.requests.proId] });
// TODO(M3): notify the pro — web push + email, via the BullMQ queue.
revalidatePath(`/deck/${jobId}`);
return { ok: true, requested: true };
}
@@ -0,0 +1,55 @@
'use client';
import { useCallback, useState } from 'react';
import type { DeckCard } from '@linkder/db';
import { Deck } from '@/components/deck';
import { recordSwipe } from './actions';
/**
* Bridges the server-rendered deck to the swipe action.
*
* Swipes are optimistic: the card leaves immediately and the write happens in
* the background. A failed right-swipe (usually the open-request cap) surfaces
* as a banner rather than snapping the card back — the client has moved on, and
* re-inserting a card they already dismissed is more confusing than a message.
*/
export function DeckClient({ jobId, cards }: { jobId: string; cards: DeckCard[] }) {
const [notice, setNotice] = useState<{ kind: 'sent' | 'error'; text: string } | null>(null);
const onDecide = useCallback(
async (proId: string, direction: 'left' | 'right') => {
const card = cards.find((c) => c.proId === proId);
const result = await recordSwipe({ jobId, proId, direction });
if (!result.ok) {
setNotice({ kind: 'error', text: result.error ?? 'Something went wrong' });
return;
}
if (result.requested) {
setNotice({
kind: 'sent',
text: `Job sent to ${card?.name ?? 'the pro'}. You'll hear back once they accept.`,
});
}
},
[cards, jobId],
);
return (
<div className="flex flex-col gap-4">
{notice && (
<div
role="status"
className={
notice.kind === 'sent'
? 'rounded-xl border border-[var(--color-go-500)]/30 bg-[var(--color-go-500)]/10 px-4 py-3 text-sm'
: 'rounded-xl border border-[var(--color-stop-500)]/30 bg-[var(--color-stop-500)]/10 px-4 py-3 text-sm'
}
>
{notice.text}
</div>
)}
<Deck cards={cards} onDecide={onDecide} />
</div>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { eq } from 'drizzle-orm';
import { notFound } from 'next/navigation';
import Link from 'next/link';
import { ArrowLeft } from 'lucide-react';
import { db, getDeck, schema } from '@linkder/db';
import { DeckClient } from './deck-client';
export const dynamic = 'force-dynamic';
export default async function DeckPage({ params }: { params: Promise<{ jobId: string }> }) {
const { jobId } = await params;
const job = await db.query.jobs.findFirst({
where: eq(schema.jobs.id, jobId),
with: { category: true },
});
if (!job) notFound();
const cards = await getDeck(db, { jobId });
return (
<main className="mx-auto flex min-h-dvh max-w-2xl flex-col px-4 py-6">
<header className="mb-6">
<Link
href="/"
className="mb-4 inline-flex items-center gap-1.5 text-sm text-[var(--muted)] hover:text-[var(--fg)]"
>
<ArrowLeft className="h-4 w-4" aria-hidden />
Back
</Link>
<p className="text-sm text-[var(--muted)]">
{job.category.name} · {job.addressText}
</p>
<h1 className="text-xl font-semibold">{job.title}</h1>
</header>
<DeckClient jobId={jobId} cards={cards} />
<p className="mt-8 text-center text-xs text-[var(--muted)]">
Swipe right to send this job to a pro, left to pass. Drag the card or use the buttons.
</p>
</main>
);
}
+30
View File
@@ -0,0 +1,30 @@
import type { Metadata, Viewport } from 'next';
import '@/styles/globals.css';
export const metadata: Metadata = {
title: {
default: 'Linkder — hire a verified local pro',
template: '%s · Linkder',
},
description:
'Describe the job once, then swipe through verified local plumbers, electricians and handymen. Quote, book and pay in one place.',
};
export const viewport: Viewport = {
themeColor: [
{ media: '(prefers-color-scheme: light)', color: '#fbfbfd' },
{ media: '(prefers-color-scheme: dark)', color: '#121319' },
],
width: 'device-width',
initialScale: 1,
// The deck is a drag surface — double-tap zoom fights it.
maximumScale: 1,
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className="min-h-dvh antialiased">{children}</body>
</html>
);
}
+76
View File
@@ -0,0 +1,76 @@
import Link from 'next/link';
import { desc } from 'drizzle-orm';
import { ArrowRight } from 'lucide-react';
import { db, schema } from '@linkder/db';
export const dynamic = 'force-dynamic';
/**
* M0 landing page. It doubles as a smoke test: if the categories and the seeded
* job render, then Next → Drizzle → PostGIS is wired correctly end to end.
*/
export default async function Home() {
const [categories, jobs] = await Promise.all([
db.select().from(schema.categories).orderBy(schema.categories.position),
db.select().from(schema.jobs).orderBy(desc(schema.jobs.createdAt)).limit(5),
]);
return (
<main className="mx-auto max-w-2xl px-6 py-16">
<p className="text-sm font-medium text-[var(--color-brand-500)]">Linkder</p>
<h1 className="mt-2 text-4xl font-semibold tracking-tight text-balance">
Describe the job once. Swipe through verified local pros.
</h1>
<p className="mt-4 text-lg text-[var(--muted)] text-pretty">
Every pro on the deck has had their ID, trade licence and insurance checked. Quote, book and
pay in one place your money is held until the work is done.
</p>
<section className="mt-12">
<h2 className="text-sm font-medium text-[var(--muted)]">Trades we cover</h2>
<ul className="mt-3 flex flex-wrap gap-2">
{categories.map((c) => (
<li
key={c.id}
className="rounded-full border border-[var(--border)] px-3 py-1.5 text-sm"
>
{c.name}
</li>
))}
</ul>
</section>
<section className="mt-12">
<h2 className="text-sm font-medium text-[var(--muted)]">Open jobs (seed data)</h2>
{jobs.length === 0 ? (
<p className="mt-3 text-sm text-[var(--muted)]">
No jobs yet run <code className="font-mono">pnpm db:seed</code>.
</p>
) : (
<ul className="mt-3 space-y-2">
{jobs.map((job) => (
<li key={job.id}>
<Link
href={`/deck/${job.id}`}
className="group flex items-center justify-between gap-4 rounded-xl border border-[var(--border)] bg-[var(--card)] px-4 py-3 transition hover:border-[var(--color-brand-500)]"
>
<span>
<span className="block font-medium">{job.title}</span>
<span className="block text-sm text-[var(--muted)]">{job.addressText}</span>
</span>
<span className="flex shrink-0 items-center gap-1 text-sm text-[var(--color-brand-500)]">
Open deck
<ArrowRight
className="h-4 w-4 transition group-hover:translate-x-0.5"
aria-hidden
/>
</span>
</Link>
</li>
))}
</ul>
)}
</section>
</main>
);
}
+215
View File
@@ -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&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>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/** "1.2 km away" / "800 m away" — pros are local, so precision matters up close. */
export function formatDistance(metres: number): string {
if (metres < 1000) return `${Math.round(metres / 50) * 50} m away`;
return `${(metres / 1000).toFixed(1)} km away`;
}
/** "usually replies in 25 min" */
export function formatResponseTime(minutes: number | null): string | null {
if (minutes === null) return null;
if (minutes < 60) return `usually replies in ${minutes} min`;
const hours = Math.round(minutes / 60);
return `usually replies in ${hours} h`;
}
+53
View File
@@ -0,0 +1,53 @@
@import 'tailwindcss';
@theme {
--color-ink-50: oklch(0.98 0.005 260);
--color-ink-100: oklch(0.95 0.008 260);
--color-ink-200: oklch(0.89 0.012 260);
--color-ink-400: oklch(0.65 0.02 260);
--color-ink-600: oklch(0.45 0.025 260);
--color-ink-800: oklch(0.26 0.03 260);
--color-ink-950: oklch(0.15 0.03 260);
--color-brand-400: oklch(0.72 0.15 25);
--color-brand-500: oklch(0.64 0.19 25);
--color-brand-600: oklch(0.56 0.2 25);
--color-go-500: oklch(0.7 0.17 150);
--color-stop-500: oklch(0.64 0.2 20);
}
:root {
--bg: var(--color-ink-50);
--fg: var(--color-ink-950);
--card: white;
--muted: var(--color-ink-600);
--border: var(--color-ink-200);
}
@media (prefers-color-scheme: dark) {
:root {
--bg: var(--color-ink-950);
--fg: var(--color-ink-50);
--card: var(--color-ink-800);
--muted: var(--color-ink-400);
--border: color-mix(in oklch, var(--color-ink-400) 25%, transparent);
}
}
html,
body {
background: var(--bg);
color: var(--fg);
}
body {
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
/* The deck is drag-driven; stop the browser from hijacking the gesture. */
.deck-card {
touch-action: none;
user-select: none;
}