diff --git a/DESIGN.md b/DESIGN.md
index c35be9b..3792f36 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -300,25 +300,38 @@ Overlay stamps: `SEND JOB` in `go-600`, `PASS` in `stop-500`, 4px border, ±12°
**The action row** is five circles, in one fixed order:
-| | Action | Size | Ink |
-|---|---|---|---|
-| 1 | Rewind — bring the last card back | 44px | `ink-600` |
-| 2 | Pass | **64px** | `stop-500` |
-| 3 | Watch — tell me when they are free | 44px | `brand-500` |
-| 4 | Send this job | **64px** | `go-600` |
-| 5 | Ask a question | 44px | `ink-950` |
+| | Action | Size | Fill | Icon |
+|---|---|---|---|---|
+| 1 | Rewind — bring the last card back | 44px | `inset` | `ink-600` |
+| 2 | Pass | **64px** | `stop-500` | white |
+| 3 | Watch — tell me when they are free | 44px | `inset` | `ink-600` |
+| 4 | Send this job | **64px** | `go-600` | white |
+| 5 | Ask a question | 44px | `inset` | `ink-600` |
-Two sizes, and the size *is* the hierarchy: the two decisions that end the card
-are 64px and reach the thumb first; the three that do not are 44px — still the
-minimum target from §8, never smaller. Gaps are 16px between a small and a large,
-24px between the two larges, so the pair still reads as the pair.
+Three rules make this row, and they are worth stating because the first version
+broke all three — five hues, five shadows, five outlined rings:
-All five are 2px bordered circles on `ink-0`. A control whose state persists —
-watch — fills with its own colour when active, and its label changes with it;
-selection is never carried by fill alone (§8).
+- **Two colours, not five.** Only the two actions that decide the card carry
+ colour, and they take the exact hues of the drag stamps they mirror. The three
+ helpers are ink. A helper competing with a decision is noise.
+- **No elevation.** §5 already lists three shadows on a screen as a Don't, and
+ the first version gave all five their own. Flat discs, every one — a shadow
+ under a tinted circle muddies the colour it is meant to sit on. Size and hue
+ carry the hierarchy; nothing needs to float.
+- **Solid, not ring and not tint.** A filled disc with a white glyph is
+ unambiguous at a glance 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.
+
+Size is the hierarchy: 64px for the two decisions, 44px for the helpers — the
+§8 floor, never smaller. Gaps are 20px, widest around the pair so it reads as a
+pair rather than as items 2 and 4 of five.
+
+Watch is the one control whose state persists, and inverts to a solid `accent`
+disc when on. Its label changes with it — selection is never carried by fill
+alone (§8).
Rewind is `disabled` with nothing to undo rather than hidden. A row that changes
-length as you swipe moves the two buttons underneath your thumb.
+length as you swipe moves the two big buttons out from under your thumb.
---
diff --git a/apps/web/src/app/demo/demo-panel.tsx b/apps/web/src/app/demo/demo-panel.tsx
new file mode 100644
index 0000000..b754e84
--- /dev/null
+++ b/apps/web/src/app/demo/demo-panel.tsx
@@ -0,0 +1,192 @@
+'use client';
+
+import { AlertTriangle, Plus } from 'lucide-react';
+import type { DemoAccount } from '@/server/dev-login';
+import { CopyField } from '@/components/demo/copy-field';
+import { cn } from '@/lib/utils';
+
+/**
+ * The right-hand column of the pitch page: everything a client needs to judge
+ * the product while the product itself runs beside them.
+ *
+ * Content lives in the arrays below rather than in the markup, so adding a
+ * feature or swapping a dependency is one line and the layout is untouched.
+ */
+
+const STACK: { group: string; items: string[] }[] = [
+ { group: 'App', items: ['Next.js 15', 'React 19', 'TypeScript', 'Tailwind v4', 'Motion'] },
+ { group: 'API', items: ['tRPC v11', 'Zod', 'better-auth', 'superjson'] },
+ { group: 'Data', items: ['PostgreSQL', 'PostGIS', 'Drizzle ORM', 'Redis'] },
+ {
+ group: 'Services',
+ items: ['Mapbox Geocoding', 'Cloudflare R2', 'Twilio', 'Resend', 'Sentry'],
+ },
+ { group: 'Tooling', items: ['Turborepo', 'pnpm workspaces', 'Vitest', 'ESLint'] },
+];
+
+const FEATURES: { title: string; body: string }[] = [
+ {
+ title: 'Swipe to hire',
+ body: 'Customers browse a deck of verified local pros and send a job with one gesture. Right-swipe opens the job picker; left is a tombstone that keeps that pro off the deck.',
+ },
+ {
+ title: 'Real geographic matching',
+ body: 'Addresses geocode to actual coordinates. PostGIS ranks every deck by distance and filters by the radius each pro says they will travel — on a GiST index, not a scan.',
+ },
+ {
+ title: 'Verified tradespeople',
+ body: 'ID, insurance and trade licence are checked before anyone reaches a customer. A material profile edit sends a live pro back for re-review automatically.',
+ },
+ {
+ title: 'Two-sided messaging',
+ body: 'Private chat per job with photo and document attachments, read receipts and unread badges. Pre-job enquiries are capped so pros cannot be cold-messaged at scale.',
+ },
+ {
+ title: 'Quotes, bookings, completion',
+ body: 'A pro quotes a price and scope; accepting it books the slot and closes the job to everyone else. The pro marks it done, the customer confirms.',
+ },
+ {
+ title: 'Double-blind reviews',
+ body: 'Neither side sees the other’s review until both are in, so nobody can answer in kind. An unanswered review publishes itself after the embargo.',
+ },
+ {
+ title: 'Search and watchlists',
+ body: 'A second door onto the same supply, filterable by trade, distance, rating and price — plus the ability to watch a pro and be told when they free up.',
+ },
+ {
+ title: 'Admin and moderation',
+ body: 'A verification queue with a full audit trail, suspension, and an append-only log of every action that can affect somebody’s livelihood.',
+ },
+];
+
+/** Reserved, and labelled so it is obvious what belongs where. */
+const PLACEHOLDERS: { title: string; hint: string }[] = [
+ { title: 'Roadmap', hint: 'What ships next, and when' },
+ { title: 'Pricing', hint: 'Commission model and plan tiers' },
+ { title: 'Metrics', hint: 'Traction, conversion, cohort retention' },
+ { title: 'Case study', hint: 'A real job, start to finish' },
+];
+
+export function DemoPanel({
+ accounts,
+ loginEnabled,
+}: {
+ accounts: readonly DemoAccount[];
+ loginEnabled: boolean;
+}) {
+ return (
+
+
+
Linkder
+
Hire a tradesperson the way you swipe
+
+ A mobile marketplace connecting customers with verified local trades. Post a job, swipe
+ through pros who actually cover your street, agree a price in chat, book the slot and
+ review each other afterwards — end to end, on one screen.
+
+ Both sides of the marketplace. Sign in on the phone to the left with either account —
+ the code is the same for both.
+
+
+ {!loginEnabled && (
+
+
+
+ Demo sign-in is off in this environment.{' '}
+ It is deliberately disabled in production — a fixed passcode is a login bypass, and
+ one must never ship live. Present from a preview deploy, or set{' '}
+ ALLOW_DEV_LOGIN=true locally.
+
;
+}
diff --git a/apps/web/src/app/demo/page.tsx b/apps/web/src/app/demo/page.tsx
new file mode 100644
index 0000000..0d452b6
--- /dev/null
+++ b/apps/web/src/app/demo/page.tsx
@@ -0,0 +1,56 @@
+import { getApi } from '@/server/caller';
+import { PhoneFrame } from '@/components/chrome/phone-frame';
+import { ShowcaseDeck } from '../showcase-deck';
+import { DEMO_ACCOUNTS, isDevLoginEnabled } from '@/server/dev-login';
+import { DemoPanel } from './demo-panel';
+
+export const metadata = {
+ title: 'Linkder — product demo',
+ description: 'A mobile marketplace connecting customers with verified local tradespeople.',
+};
+export const dynamic = 'force-dynamic';
+
+/**
+ * The pitch page. Product on the left, the case for it on the right.
+ *
+ * A DELIBERATE exception to DESIGN.md §1.0 and §4, which ban desktop layouts and
+ * marketing pages. This is neither a product screen nor a marketing site: it is
+ * a frame around the running app for somebody presenting it on a laptop, and the
+ * rule it breaks exists to stop product screens sprouting desktop variants. The
+ * app inside the frame is unchanged and still mobile-only — everything to the
+ * left of the fold is the real thing, not a screenshot.
+ *
+ * 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.
+ */
+export default async function DemoPage() {
+ const api = await getApi();
+ const [{ cards }, categories] = await Promise.all([
+ api.deck.showcase(),
+ api.job.categories(),
+ ]);
+
+ return (
+
+
+ {/*
+ The live product. Sticky on a wide screen so it stays put while the
+ right-hand column scrolls — the whole point is that the client can keep
+ swiping while you talk through the feature list.
+ */}
+
+
+ );
+}
diff --git a/apps/web/src/components/deck.tsx b/apps/web/src/components/deck.tsx
index 14340fc..f95ac33 100644
--- a/apps/web/src/components/deck.tsx
+++ b/apps/web/src/components/deck.tsx
@@ -117,14 +117,14 @@ export function Deck({
- {/* DESIGN.md §6.8. Fixed order, two sizes: rewind · pass · watch · hire · ask.
- Gaps are tighter around the small ones so the middle pair still reads
- as the pair. */}
-
+ {/* 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. */}
+
visible[0] && onWatch?.(visible[0].proId)}
@@ -155,7 +155,7 @@ export function Deck({
visible[0] && onAsk?.(visible[0].proId)}
/>
@@ -190,7 +190,7 @@ export function Card({
return (
void;
}) {
const isLarge = size === 'lg';
- const colour = {
- pass: 'var(--color-stop-500)',
- hire: 'var(--color-go-600)',
- accent: 'var(--color-brand-500)',
- ink: 'var(--color-ink-950)',
- }[tone];
return (
diff --git a/apps/web/src/components/demo/copy-field.tsx b/apps/web/src/components/demo/copy-field.tsx
new file mode 100644
index 0000000..dd17f49
--- /dev/null
+++ b/apps/web/src/components/demo/copy-field.tsx
@@ -0,0 +1,59 @@
+'use client';
+
+import { useState } from 'react';
+import { Check, Copy } from 'lucide-react';
+import { cn } from '@/lib/utils';
+
+/**
+ * A value the presenter needs to hand over verbatim, with one tap to copy.
+ *
+ * Reading a phone number off a screen into a form while somebody watches is a
+ * small humiliation, and mistyping one in front of a client is a worse one.
+ *
+ * The confirmation lives in the button's own label as well as its icon, because
+ * §8 forbids colour or iconography as the only carrier — and because a screen
+ * reader that only announces "Copy" after a successful copy is lying.
+ */
+export function CopyField({ label, value }: { label: string; value: string }) {
+ const [copied, setCopied] = useState(false);
+
+ async function copy() {
+ try {
+ await navigator.clipboard.writeText(value);
+ setCopied(true);
+ // Long enough to register, short enough that the row is ready again
+ // before anybody reaches for it twice.
+ setTimeout(() => setCopied(false), 1600);
+ } catch {
+ // Clipboard access can be refused (insecure origin, permissions). Silent
+ // rather than an error banner: the value is on screen and readable, which
+ // is the fallback that always works.
+ }
+ }
+
+ return (
+
+ {label}
+
+ {value}
+
+
+
+ );
+}
diff --git a/apps/web/src/server/dev-login.ts b/apps/web/src/server/dev-login.ts
index 4a86c7e..9348719 100644
--- a/apps/web/src/server/dev-login.ts
+++ b/apps/web/src/server/dev-login.ts
@@ -1,31 +1,74 @@
-import { eq } from 'drizzle-orm';
+import { inArray } from 'drizzle-orm';
import { db, schema } from '@linkder/db';
/**
- * A fixed test account for local development.
+ * Fixed sign-in for local development and for demonstrating the product.
*
* Signing in normally needs a real handset to receive a real SMS, which makes
- * the whole app untestable without a phone in your hand and Twilio credits. This
- * pins one number to one known code so `pnpm dev` is usable.
+ * the whole app untestable without a phone in your hand and Twilio credits — and
+ * makes it impossible to show somebody both sides of a two-sided marketplace in
+ * a meeting. These accounts pin known numbers to one known code.
*
- * THREE independent guards, because a login bypass reaching production is the
- * worst bug this codebase could ship:
+ * THREE independent guards, unchanged by adding more accounts, because a login
+ * bypass reaching production is the worst bug this codebase could ship:
*
* 1. NODE_ENV must not be 'production'.
* 2. ALLOW_DEV_LOGIN must be explicitly 'true' — being in dev is not enough.
- * 3. The phone number must match exactly.
+ * 3. The phone number must be one of the accounts listed below.
*
- * Any one of them failing falls straight back to the real OTP path.
+ * Any one of them failing falls straight back to the real OTP path. That is why
+ * `/demo` says out loud that it only works off production: the honest fix for
+ * demoing against a live deployment is a real account and a real SMS, not a
+ * fourth flag that unlocks a bypass in production.
*/
-const DEV_PHONE = '+34600000000';
const DEV_CODE = '000000';
+export interface DemoAccount {
+ phone: string;
+ code: string;
+ /** Which side of the marketplace this account shows. */
+ role: 'client' | 'pro';
+ name: string;
+ /** What the person presenting should point at once they are in. */
+ blurb: string;
+}
+
+/**
+ * Both sides of the market.
+ *
+ * A one-sided demo of a two-sided marketplace shows half a product: the
+ * customer's deck is only interesting because a real tradesperson is on the
+ * other end of it. These map to seeded accounts (`pnpm db:seed`) that already
+ * own jobs, conversations and a completed booking, so the screens have something
+ * in them rather than five empty states.
+ */
+export const DEMO_ACCOUNTS: readonly DemoAccount[] = [
+ {
+ phone: '+34600000000',
+ code: DEV_CODE,
+ role: 'client',
+ name: 'Sofia Grau',
+ blurb:
+ 'The customer. Has jobs in flight, a live conversation with a plumber, and one finished job waiting on a review.',
+ },
+ {
+ phone: '+34610000001',
+ code: DEV_CODE,
+ role: 'pro',
+ name: 'Marc Oliveras',
+ blurb:
+ 'The tradesperson. Verified plumber — shows the card customers swipe, the verification state and the job side of chat.',
+ },
+];
+
+const DEMO_PHONES = DEMO_ACCOUNTS.map((a) => a.phone);
+
export function isDevLoginEnabled(): boolean {
return process.env.NODE_ENV !== 'production' && process.env.ALLOW_DEV_LOGIN === 'true';
}
export function isDevLoginPhone(phone: string): boolean {
- return isDevLoginEnabled() && phone === DEV_PHONE;
+ return isDevLoginEnabled() && DEMO_PHONES.includes(phone);
}
/**
@@ -43,9 +86,11 @@ export async function pinDevLoginCode(phone: string): Promise {
await db
.update(schema.verifications)
.set({ value: `${DEV_CODE}:0` })
- .where(eq(schema.verifications.identifier, phone));
+ .where(inArray(schema.verifications.identifier, [phone]))
+ .catch(() => {});
- console.info(`\n [dev login] ${DEV_PHONE} → code ${DEV_CODE}\n`);
+ console.info(`\n [dev login] ${phone} → code ${DEV_CODE}\n`);
}
-export const DEV_LOGIN = { phone: DEV_PHONE, code: DEV_CODE } as const;
+/** Kept for callers that only ever wanted the customer account. */
+export const DEV_LOGIN = { phone: DEMO_ACCOUNTS[0]!.phone, code: DEV_CODE } as const;