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,14 @@
|
||||
import { dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { FlatCompat } from '@eslint/eslintrc';
|
||||
|
||||
// eslint-config-next still ships a legacy config, so it needs the compat shim
|
||||
// to run under ESLint 9 flat config.
|
||||
const compat = new FlatCompat({ baseDirectory: dirname(fileURLToPath(import.meta.url)) });
|
||||
|
||||
const config = [
|
||||
{ ignores: ['.next/**', 'node_modules/**', 'next-env.d.ts'] },
|
||||
...compat.extends('next/core-web-vitals', 'next/typescript'),
|
||||
];
|
||||
|
||||
export default config;
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,21 @@
|
||||
import { config as loadEnv } from 'dotenv';
|
||||
import type { NextConfig } from 'next';
|
||||
|
||||
// The monorepo keeps one .env at the root; Next only looks in the app directory.
|
||||
loadEnv({ path: '../../.env' });
|
||||
|
||||
const config: NextConfig = {
|
||||
reactStrictMode: true,
|
||||
// The workspace packages ship TypeScript source, not build output.
|
||||
transpilePackages: ['@linkder/db', '@linkder/shared'],
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{ protocol: 'https', hostname: 'picsum.photos' },
|
||||
{ protocol: 'https', hostname: '**.r2.dev' },
|
||||
],
|
||||
},
|
||||
// postgres-js opens raw sockets; it must not be bundled into the server chunk.
|
||||
serverExternalPackages: ['postgres'],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@linkder/web",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --port 3000",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@linkder/db": "workspace:*",
|
||||
"@linkder/shared": "workspace:*",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-orm": "0.38.4",
|
||||
"lucide-react": "^0.469.0",
|
||||
"motion": "^11.15.0",
|
||||
"next": "^15.1.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"tailwind-merge": "^2.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "3.2.0",
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"@types/react": "^19.0.7",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"dotenv": "16.4.7",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-next": "^15.1.4",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
const config = {
|
||||
plugins: { '@tailwindcss/postcss': {} },
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||
"jsx": "preserve",
|
||||
"noEmit": true,
|
||||
"allowJs": true,
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": { "@/*": ["./src/*"] }
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user