A page to present this to a client. Left column is the real app in the phone frame — live, swipeable, the same deck and the same data, not a screenshot. Right column is what somebody needs in order to judge it: how to get in, what it does, what it is built on. Credentials, both sides - A one-sided demo of a two-sided marketplace shows half a product, so the panel carries a customer AND a tradesperson, each with a copy button. Reading a phone number off a screen into a form while a client watches is a small humiliation; mistyping one is a worse one. - dev-login grows from one pinned number to a named DEMO_ACCOUNTS list. The three guards are unchanged — not production, explicitly enabled, and the number must be on the list. Both accounts map to seeded users that already own jobs, conversations and a completed booking, so the screens have something in them rather than five empty states. - The page SAYS SO when sign-in is unavailable rather than letting somebody discover it mid-meeting. A fixed passcode is a login bypass and must never ship live; the honest fix for demoing against production is a real account and a real SMS, not a fourth flag. Content lives in arrays at the top of demo-panel, so adding a feature or swapping a dependency is one line and the layout is untouched. Four labelled placeholder slots — roadmap, pricing, metrics, case study — reserve the space and make it obvious what belongs where. A deliberate exception to DESIGN.md §1.0 and §4, which ban desktop layouts and marketing pages, and it is noted as one in the file. This is a frame around the running app for a laptop, not a product screen; the app inside is untouched and still mobile-only. Below `lg` the panel stacks under the phone, because a client who opens the link on their own phone should still be able to read it. Verified: both demo accounts complete the real OTP path end to end. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
373 lines
13 KiB
TypeScript
373 lines
13 KiB
TypeScript
'use client';
|
|
|
|
import { useCallback, useMemo, useRef, useState } from 'react';
|
|
import { AnimatePresence, motion, useMotionValue, useTransform } from 'motion/react';
|
|
import { Check, Eye, MapPin, MessageCircle, Star, Undo2, 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;
|
|
|
|
/**
|
|
* What a handler can say about a swipe it was given.
|
|
*
|
|
* `revert` puts the card back. It exists because a right swipe does not always
|
|
* complete on its own: on the entry deck it opens a sheet asking which job to
|
|
* send, and someone who closes that sheet must not lose the pro they just
|
|
* picked. Returning nothing means "committed", which is what the per-job deck
|
|
* does — there the swipe IS the send.
|
|
*/
|
|
export type SwipeVerdict = 'commit' | 'revert';
|
|
|
|
export interface DeckProps {
|
|
cards: DeckCard[];
|
|
onDecide: (
|
|
proId: string,
|
|
direction: 'left' | 'right',
|
|
) => void | Promise<void | SwipeVerdict>;
|
|
/**
|
|
* The three secondary actions. All optional — a deck rendered without them
|
|
* shows the buttons disabled rather than missing, so the row keeps its shape
|
|
* and the two big buttons never move.
|
|
*/
|
|
onRewind?: () => void;
|
|
/**
|
|
* Incremented by the parent to step the stack back one.
|
|
*
|
|
* A signal rather than a callback returning state: the index lives in the
|
|
* Deck, and handing it out so a parent could decrement it would give two
|
|
* owners to the one thing that must stay consistent with the card on screen.
|
|
*/
|
|
rewindSignal?: number;
|
|
onWatch?: (proId: string) => void;
|
|
onAsk?: (proId: string) => void;
|
|
/** Which pros the viewer already watches, for the filled state. */
|
|
watchedProIds?: ReadonlySet<string>;
|
|
}
|
|
|
|
export function Deck({
|
|
cards,
|
|
onDecide,
|
|
onRewind,
|
|
rewindSignal = 0,
|
|
onWatch,
|
|
onAsk,
|
|
watchedProIds,
|
|
}: DeckProps) {
|
|
const [index, setIndex] = useState(0);
|
|
const remaining = useMemo(() => cards.slice(index), [cards, index]);
|
|
|
|
// One decision at a time. Without this, a second swipe landing while a sheet
|
|
// is open would advance past a card nobody ever saw.
|
|
const inFlight = useRef(false);
|
|
|
|
// Step back when the parent asks. Guarded on the previous value so a re-render
|
|
// for any other reason does not rewind again.
|
|
const lastRewind = useRef(rewindSignal);
|
|
if (rewindSignal !== lastRewind.current) {
|
|
lastRewind.current = rewindSignal;
|
|
if (index > 0) setIndex((i) => Math.max(0, i - 1));
|
|
}
|
|
|
|
const decide = useCallback(
|
|
async (proId: string, direction: 'left' | 'right') => {
|
|
if (inFlight.current) return;
|
|
inFlight.current = true;
|
|
|
|
// The card leaves first and comes back only if refused. Waiting for the
|
|
// handler before animating would make every swipe feel like it stuck.
|
|
setIndex((i) => i + 1);
|
|
try {
|
|
const verdict = await onDecide(proId, direction);
|
|
if (verdict !== 'revert') return;
|
|
|
|
// Restore exactly that card rather than stepping the index back — a
|
|
// blind decrement would put back whichever card happened to be behind.
|
|
const at = cards.findIndex((c) => c.proId === proId);
|
|
if (at >= 0) setIndex((i) => Math.min(i, at));
|
|
} finally {
|
|
inFlight.current = false;
|
|
}
|
|
},
|
|
[cards, 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 h-full min-h-0 flex-col items-center gap-5">
|
|
<div className="relative w-full min-h-0 flex-1">
|
|
<AnimatePresence initial={false}>
|
|
{visible
|
|
.map((card, i) => (
|
|
<Card
|
|
key={card.proId}
|
|
card={card}
|
|
depth={i}
|
|
onDecide={i === 0 ? decide : undefined}
|
|
/>
|
|
))
|
|
.reverse()}
|
|
</AnimatePresence>
|
|
</div>
|
|
|
|
{/* DESIGN.md §6.8. rewind · pass · watch · hire · ask.
|
|
Wider gaps around the two decisions than between them and the helpers,
|
|
so the pair reads as a pair rather than as items 2 and 4 of five. */}
|
|
<div className="flex shrink-0 items-center gap-5 pb-2">
|
|
<ActionButton
|
|
label="Bring back the last one"
|
|
icon={Undo2}
|
|
tone="neutral"
|
|
// Disabled rather than hidden: a row that changes length as you swipe
|
|
// moves the two big buttons out from under your thumb.
|
|
disabled={index === 0 || !onRewind}
|
|
onClick={() => onRewind?.()}
|
|
/>
|
|
<ActionButton
|
|
label="Not this one"
|
|
icon={X}
|
|
tone="pass"
|
|
size="lg"
|
|
onClick={() => visible[0] && decide(visible[0].proId, 'left')}
|
|
/>
|
|
<ActionButton
|
|
label={watchedProIds?.has(visible[0]?.proId ?? '') ? 'Stop watching' : 'Tell me when they are free'}
|
|
icon={Eye}
|
|
tone="neutral"
|
|
active={watchedProIds?.has(visible[0]?.proId ?? '') ?? false}
|
|
disabled={!onWatch}
|
|
onClick={() => visible[0] && onWatch?.(visible[0].proId)}
|
|
/>
|
|
<ActionButton
|
|
label="Send this job"
|
|
icon={Check}
|
|
tone="hire"
|
|
size="lg"
|
|
onClick={() => visible[0] && decide(visible[0].proId, 'right')}
|
|
/>
|
|
<ActionButton
|
|
label="Ask a question"
|
|
icon={MessageCircle}
|
|
tone="neutral"
|
|
disabled={!onAsk}
|
|
onClick={() => visible[0] && onAsk?.(visible[0].proId)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* One card.
|
|
*
|
|
* Exported so the profile screen can render a pro their OWN card, byte for byte
|
|
* what a client sees. A lookalike would drift the moment either side changed.
|
|
* Pass no `onDecide` to get a static, non-draggable card.
|
|
*/
|
|
export function Card({
|
|
card,
|
|
depth = 0,
|
|
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-deck border shadow-lg',
|
|
'border-hairline bg-raised',
|
|
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-600)] px-3 py-1 text-2xl font-black tracking-wide text-[var(--color-go-600)]"
|
|
>
|
|
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">
|
|
{/*
|
|
text-white must be on the h2 itself, not inherited from the wrapper:
|
|
globals.css sets `h1..h6 { color: var(--text-strong) }` in @layer
|
|
base, and that rule beats the parent's colour. Without this the name
|
|
renders ink-950 navy on a dark photo and is unreadable.
|
|
*/}
|
|
<h2 className="text-2xl font-semibold text-white">{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>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* DESIGN.md §6.8. One of the five circles under the card.
|
|
*
|
|
* The row used to be five different colours and five drop shadows — a rainbow
|
|
* of outlined rings, each shouting as loudly as the next. Three rules replaced
|
|
* that, and they are the whole design:
|
|
*
|
|
* 1. TWO colours, not five. Only the two actions that decide the card are
|
|
* coloured, and they borrow the exact hues of the drag stamps they mirror
|
|
* (`PASS` in stop, `SEND JOB` in go). Rewind, watch and ask are ink: they
|
|
* are helpers, and a helper competing with the decision is noise.
|
|
* 2. NO elevation. The old row put a drop shadow under all five. §5 already
|
|
* lists three shadows on one screen as a Don't, and five discs each casting
|
|
* their own is worse than untidy — it muddies the colour underneath. Flat,
|
|
* and let size and hue carry the hierarchy instead.
|
|
* 3. SOLID, not ring and not tint. A filled disc with a white glyph is
|
|
* unambiguous and holds its weight above a full-bleed photo. An outlined
|
|
* ring reads as a diagram of a button; a pastel tint reads as a sticker.
|
|
*
|
|
* `active` is for the one control whose state persists (watch) and inverts it to
|
|
* a solid accent — with the label changing too, because §8 forbids fill as the
|
|
* only signal.
|
|
*/
|
|
function ActionButton({
|
|
label,
|
|
icon: Icon,
|
|
tone,
|
|
size = 'sm',
|
|
active = false,
|
|
disabled = false,
|
|
onClick,
|
|
}: {
|
|
label: string;
|
|
icon: typeof Check;
|
|
tone: 'pass' | 'hire' | 'neutral';
|
|
size?: 'sm' | 'lg';
|
|
active?: boolean;
|
|
disabled?: boolean;
|
|
onClick: () => void;
|
|
}) {
|
|
const isLarge = size === 'lg';
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
disabled={disabled}
|
|
aria-label={label}
|
|
aria-pressed={active || undefined}
|
|
title={label}
|
|
className={cn(
|
|
'flex shrink-0 items-center justify-center rounded-full',
|
|
'transition-[transform,background-color,color] duration-[120ms] ease-standard',
|
|
'active:scale-90 disabled:pointer-events-none disabled:opacity-30',
|
|
// Focus is visible on a circle with no border, per §8.
|
|
'focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-brand-200',
|
|
isLarge ? 'h-16 w-16' : 'h-11 w-11',
|
|
|
|
// Helpers: flat, ink, quiet. They recede on purpose.
|
|
tone === 'neutral' && !active && 'bg-inset text-muted hover:text-strong',
|
|
tone === 'neutral' && active && 'bg-accent text-white',
|
|
|
|
// Decisions: solid. Not a tint, not a ring — a filled disc with a white
|
|
// glyph is unambiguous at a glance and holds up on top of a photo. The
|
|
// pastel version this replaced read as a sticker; the outlined version
|
|
// before it read as a diagram of a button.
|
|
tone === 'pass' && 'bg-stop-500 text-white hover:bg-stop-600',
|
|
tone === 'hire' && 'bg-go-600 text-white hover:bg-go-700',
|
|
)}
|
|
>
|
|
<Icon
|
|
className={isLarge ? 'h-7 w-7' : 'h-[1.15rem] w-[1.15rem]'}
|
|
strokeWidth={isLarge ? 2.75 : 2.25}
|
|
aria-hidden
|
|
/>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function EmptyDeck() {
|
|
return (
|
|
<div className="mx-auto flex h-full flex-col items-center justify-center gap-3 rounded-deck border border-dashed border-hairline 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>
|
|
);
|
|
}
|