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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user