From 5086a238ea5c8ee8750185493e03da6aff81e199 Mon Sep 17 00:00:00 2001 From: serfa Date: Fri, 21 Aug 2026 07:32:41 -0400 Subject: [PATCH] Revert "Demo page: the running product beside the case for it" This reverts commit 7567aa4e7502ade8feaf5a13d2cea004bd718b04. --- DESIGN.md | 43 ++--- apps/web/src/app/demo/demo-panel.tsx | 192 -------------------- apps/web/src/app/demo/page.tsx | 56 ------ apps/web/src/components/deck.tsx | 76 ++++---- apps/web/src/components/demo/copy-field.tsx | 59 ------ apps/web/src/server/dev-login.ts | 71 ++------ 6 files changed, 59 insertions(+), 438 deletions(-) delete mode 100644 apps/web/src/app/demo/demo-panel.tsx delete mode 100644 apps/web/src/app/demo/page.tsx delete mode 100644 apps/web/src/components/demo/copy-field.tsx diff --git a/DESIGN.md b/DESIGN.md index 3792f36..c35be9b 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -300,38 +300,25 @@ 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 | 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` | +| | 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` | -Three rules make this row, and they are worth stating because the first version -broke all three — five hues, five shadows, five outlined rings: +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. -- **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). +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). Rewind is `disabled` with nothing to undo rather than hidden. A row that changes -length as you swipe moves the two big buttons out from under your thumb. +length as you swipe moves the two buttons underneath your thumb. --- diff --git a/apps/web/src/app/demo/demo-panel.tsx b/apps/web/src/app/demo/demo-panel.tsx deleted file mode 100644 index b754e84..0000000 --- a/apps/web/src/app/demo/demo-panel.tsx +++ /dev/null @@ -1,192 +0,0 @@ -'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. -

-
- - {/* ── credentials ── */} -
- Try it yourself -

- 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. -

-
- )} - -
- {accounts.map((account) => ( -
-
- {account.name} - - {account.role === 'pro' ? 'Tradesperson' : 'Customer'} - -
-

{account.blurb}

-
- - -
-
- ))} -
-
- - {/* ── features ── */} -
- What the platform does -
- {FEATURES.map((f) => ( -
-
{f.title}
-
{f.body}
-
- ))} -
-
- - {/* ── stack ── */} -
- Built with -
- {STACK.map((row) => ( -
- {row.group} - {row.items.map((item) => ( - - {item} - - ))} -
- ))} -
-
- - {/* ── reserved ── */} -
- More to come -
- {PLACEHOLDERS.map((p) => ( -
- - - {p.title} - - {p.hint} -
- ))} -
-
-
- ); -} - -function SectionTitle({ children }: { children: React.ReactNode }) { - return

{children}

; -} diff --git a/apps/web/src/app/demo/page.tsx b/apps/web/src/app/demo/page.tsx deleted file mode 100644 index 0d452b6..0000000 --- a/apps/web/src/app/demo/page.tsx +++ /dev/null @@ -1,56 +0,0 @@ -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. - */} -
- - ({ id: c.id, name: c.name }))} - initialCards={cards} - /> - -
- -
- -
-
-
- ); -} diff --git a/apps/web/src/components/deck.tsx b/apps/web/src/components/deck.tsx index f95ac33..14340fc 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. 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. */} -
+ {/* 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. */} +
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 deleted file mode 100644 index dd17f49..0000000 --- a/apps/web/src/components/demo/copy-field.tsx +++ /dev/null @@ -1,59 +0,0 @@ -'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 9348719..4a86c7e 100644 --- a/apps/web/src/server/dev-login.ts +++ b/apps/web/src/server/dev-login.ts @@ -1,74 +1,31 @@ -import { inArray } from 'drizzle-orm'; +import { eq } from 'drizzle-orm'; import { db, schema } from '@linkder/db'; /** - * Fixed sign-in for local development and for demonstrating the product. + * A fixed test account for local development. * * 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 — 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. + * 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. * - * THREE independent guards, unchanged by adding more accounts, because a login - * bypass reaching production is the worst bug this codebase could ship: + * THREE independent guards, 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 be one of the accounts listed below. + * 3. The phone number must match exactly. * - * 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. + * Any one of them failing falls straight back to the real OTP path. */ +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() && DEMO_PHONES.includes(phone); + return isDevLoginEnabled() && phone === DEV_PHONE; } /** @@ -86,11 +43,9 @@ export async function pinDevLoginCode(phone: string): Promise { await db .update(schema.verifications) .set({ value: `${DEV_CODE}:0` }) - .where(inArray(schema.verifications.identifier, [phone])) - .catch(() => {}); + .where(eq(schema.verifications.identifier, phone)); - console.info(`\n [dev login] ${phone} → code ${DEV_CODE}\n`); + console.info(`\n [dev login] ${DEV_PHONE} → code ${DEV_CODE}\n`); } -/** Kept for callers that only ever wanted the customer account. */ -export const DEV_LOGIN = { phone: DEMO_ACCOUNTS[0]!.phone, code: DEV_CODE } as const; +export const DEV_LOGIN = { phone: DEV_PHONE, code: DEV_CODE } as const;