Revert "Demo page: the running product beside the case for it"
This reverts commit 7567aa4e75.
This commit is contained in:
@@ -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:
|
**The action row** is five circles, in one fixed order:
|
||||||
|
|
||||||
| | Action | Size | Fill | Icon |
|
| | Action | Size | Ink |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 1 | Rewind — bring the last card back | 44px | `inset` | `ink-600` |
|
| 1 | Rewind — bring the last card back | 44px | `ink-600` |
|
||||||
| 2 | Pass | **64px** | `stop-500` | white |
|
| 2 | Pass | **64px** | `stop-500` |
|
||||||
| 3 | Watch — tell me when they are free | 44px | `inset` | `ink-600` |
|
| 3 | Watch — tell me when they are free | 44px | `brand-500` |
|
||||||
| 4 | Send this job | **64px** | `go-600` | white |
|
| 4 | Send this job | **64px** | `go-600` |
|
||||||
| 5 | Ask a question | 44px | `inset` | `ink-600` |
|
| 5 | Ask a question | 44px | `ink-950` |
|
||||||
|
|
||||||
Three rules make this row, and they are worth stating because the first version
|
Two sizes, and the size *is* the hierarchy: the two decisions that end the card
|
||||||
broke all three — five hues, five shadows, five outlined rings:
|
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
|
All five are 2px bordered circles on `ink-0`. A control whose state persists —
|
||||||
colour, and they take the exact hues of the drag stamps they mirror. The three
|
watch — fills with its own colour when active, and its label changes with it;
|
||||||
helpers are ink. A helper competing with a decision is noise.
|
selection is never carried by fill alone (§8).
|
||||||
- **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
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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 (
|
|
||||||
<div className="flex h-full flex-col gap-10 overflow-y-auto px-6 py-10 lg:px-10">
|
|
||||||
<header>
|
|
||||||
<p className="text-overline uppercase text-accent">Linkder</p>
|
|
||||||
<h1 className="mt-2 text-h1">Hire a tradesperson the way you swipe</h1>
|
|
||||||
<p className="mt-3 max-w-[60ch] text-body text-muted text-pretty">
|
|
||||||
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.
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* ── credentials ── */}
|
|
||||||
<section>
|
|
||||||
<SectionTitle>Try it yourself</SectionTitle>
|
|
||||||
<p className="mb-4 max-w-[60ch] text-body-sm text-muted">
|
|
||||||
Both sides of the marketplace. Sign in on the phone to the left with either account —
|
|
||||||
the code is the same for both.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{!loginEnabled && (
|
|
||||||
<div className="mb-4 flex items-start gap-3 rounded-card border border-sun-100 bg-sun-50 p-4">
|
|
||||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-sun-500" aria-hidden />
|
|
||||||
<p className="text-body-sm text-ink-800">
|
|
||||||
<strong className="font-semibold">Demo sign-in is off in this environment.</strong>{' '}
|
|
||||||
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{' '}
|
|
||||||
<code className="font-mono text-meta">ALLOW_DEV_LOGIN=true</code> locally.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-5">
|
|
||||||
{accounts.map((account) => (
|
|
||||||
<div key={account.phone}>
|
|
||||||
<div className="mb-2 flex items-baseline gap-2">
|
|
||||||
<span className="font-display text-h4 text-strong">{account.name}</span>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'rounded-pill border px-2.5 py-0.5 text-meta',
|
|
||||||
account.role === 'pro'
|
|
||||||
? 'border-go-100 bg-go-50 text-go-700'
|
|
||||||
: 'border-brand-200 bg-brand-100 text-ink-950',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{account.role === 'pro' ? 'Tradesperson' : 'Customer'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="mb-3 max-w-[58ch] text-body-sm text-muted">{account.blurb}</p>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<CopyField label="Mobile" value={account.phone} />
|
|
||||||
<CopyField label="Code" value={account.code} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* ── features ── */}
|
|
||||||
<section>
|
|
||||||
<SectionTitle>What the platform does</SectionTitle>
|
|
||||||
<dl className="grid gap-x-8 gap-y-5 sm:grid-cols-2">
|
|
||||||
{FEATURES.map((f) => (
|
|
||||||
<div key={f.title}>
|
|
||||||
<dt className="font-display text-h4 text-strong">{f.title}</dt>
|
|
||||||
<dd className="mt-1 text-body-sm text-muted text-pretty">{f.body}</dd>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</dl>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* ── stack ── */}
|
|
||||||
<section>
|
|
||||||
<SectionTitle>Built with</SectionTitle>
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
{STACK.map((row) => (
|
|
||||||
<div key={row.group} className="flex flex-wrap items-baseline gap-x-3 gap-y-2">
|
|
||||||
<span className="w-20 shrink-0 text-meta text-faint">{row.group}</span>
|
|
||||||
{row.items.map((item) => (
|
|
||||||
<span
|
|
||||||
key={item}
|
|
||||||
className="rounded-pill border border-hairline px-3 py-1 text-meta text-strong"
|
|
||||||
>
|
|
||||||
{item}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* ── reserved ── */}
|
|
||||||
<section className="pb-6">
|
|
||||||
<SectionTitle>More to come</SectionTitle>
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
|
||||||
{PLACEHOLDERS.map((p) => (
|
|
||||||
<div
|
|
||||||
key={p.title}
|
|
||||||
className="flex min-h-28 flex-col justify-center gap-1 rounded-card border border-dashed border-hairline p-5"
|
|
||||||
>
|
|
||||||
<span className="flex items-center gap-2 font-display text-h4 text-muted">
|
|
||||||
<Plus className="h-4 w-4" aria-hidden />
|
|
||||||
{p.title}
|
|
||||||
</span>
|
|
||||||
<span className="text-body-sm text-faint">{p.hint}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
|
||||||
return <h2 className="mb-4 text-h3">{children}</h2>;
|
|
||||||
}
|
|
||||||
@@ -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 (
|
|
||||||
<main className="min-h-dvh bg-sunken">
|
|
||||||
<div className="mx-auto flex max-w-[1400px] flex-col lg:h-dvh lg:flex-row">
|
|
||||||
{/*
|
|
||||||
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.
|
|
||||||
*/}
|
|
||||||
<div className="flex shrink-0 items-center justify-center p-6 lg:h-dvh lg:w-[520px] lg:p-10">
|
|
||||||
<PhoneFrame>
|
|
||||||
<ShowcaseDeck
|
|
||||||
categories={categories.map((c) => ({ id: c.id, name: c.name }))}
|
|
||||||
initialCards={cards}
|
|
||||||
/>
|
|
||||||
</PhoneFrame>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="min-w-0 flex-1 bg-page lg:h-dvh">
|
|
||||||
<DemoPanel accounts={DEMO_ACCOUNTS} loginEnabled={isDevLoginEnabled()} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -117,14 +117,14 @@ export function Deck({
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* DESIGN.md §6.8. rewind · pass · watch · hire · ask.
|
{/* DESIGN.md §6.8. Fixed order, two sizes: rewind · pass · watch · hire · ask.
|
||||||
Wider gaps around the two decisions than between them and the helpers,
|
Gaps are tighter around the small ones so the middle pair still reads
|
||||||
so the pair reads as a pair rather than as items 2 and 4 of five. */}
|
as the pair. */}
|
||||||
<div className="flex shrink-0 items-center gap-5 pb-2">
|
<div className="flex shrink-0 items-center gap-4 pb-2">
|
||||||
<ActionButton
|
<ActionButton
|
||||||
label="Bring back the last one"
|
label="Bring back the last one"
|
||||||
icon={Undo2}
|
icon={Undo2}
|
||||||
tone="neutral"
|
tone="ink"
|
||||||
// Disabled rather than hidden: a row that changes length as you swipe
|
// Disabled rather than hidden: a row that changes length as you swipe
|
||||||
// moves the two big buttons out from under your thumb.
|
// moves the two big buttons out from under your thumb.
|
||||||
disabled={index === 0 || !onRewind}
|
disabled={index === 0 || !onRewind}
|
||||||
@@ -140,7 +140,7 @@ export function Deck({
|
|||||||
<ActionButton
|
<ActionButton
|
||||||
label={watchedProIds?.has(visible[0]?.proId ?? '') ? 'Stop watching' : 'Tell me when they are free'}
|
label={watchedProIds?.has(visible[0]?.proId ?? '') ? 'Stop watching' : 'Tell me when they are free'}
|
||||||
icon={Eye}
|
icon={Eye}
|
||||||
tone="neutral"
|
tone="accent"
|
||||||
active={watchedProIds?.has(visible[0]?.proId ?? '') ?? false}
|
active={watchedProIds?.has(visible[0]?.proId ?? '') ?? false}
|
||||||
disabled={!onWatch}
|
disabled={!onWatch}
|
||||||
onClick={() => visible[0] && onWatch?.(visible[0].proId)}
|
onClick={() => visible[0] && onWatch?.(visible[0].proId)}
|
||||||
@@ -155,7 +155,7 @@ export function Deck({
|
|||||||
<ActionButton
|
<ActionButton
|
||||||
label="Ask a question"
|
label="Ask a question"
|
||||||
icon={MessageCircle}
|
icon={MessageCircle}
|
||||||
tone="neutral"
|
tone="ink"
|
||||||
disabled={!onAsk}
|
disabled={!onAsk}
|
||||||
onClick={() => visible[0] && onAsk?.(visible[0].proId)}
|
onClick={() => visible[0] && onAsk?.(visible[0].proId)}
|
||||||
/>
|
/>
|
||||||
@@ -190,7 +190,7 @@ export function Card({
|
|||||||
return (
|
return (
|
||||||
<motion.article
|
<motion.article
|
||||||
className={cn(
|
className={cn(
|
||||||
'deck-card absolute inset-0 overflow-hidden rounded-deck border shadow-lg',
|
'deck-card absolute inset-0 overflow-hidden rounded-3xl border shadow-xl',
|
||||||
'border-hairline bg-raised',
|
'border-hairline bg-raised',
|
||||||
interactive ? 'cursor-grab active:cursor-grabbing' : 'pointer-events-none',
|
interactive ? 'cursor-grab active:cursor-grabbing' : 'pointer-events-none',
|
||||||
)}
|
)}
|
||||||
@@ -283,25 +283,12 @@ export function Card({
|
|||||||
/**
|
/**
|
||||||
* DESIGN.md §6.8. One of the five circles under the card.
|
* 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
|
* `size` IS the hierarchy, not decoration: the two actions that end the card are
|
||||||
* of outlined rings, each shouting as loudly as the next. Three rules replaced
|
* large and meet the thumb first; the three that do not are small — but never
|
||||||
* that, and they are the whole design:
|
* below 44px, which is the floor in §8.
|
||||||
*
|
*
|
||||||
* 1. TWO colours, not five. Only the two actions that decide the card are
|
* `active` fills the circle for the one control whose state persists (watch),
|
||||||
* coloured, and they borrow the exact hues of the drag stamps they mirror
|
* and its label changes with it, because §8 forbids fill as the only signal.
|
||||||
* (`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({
|
function ActionButton({
|
||||||
label,
|
label,
|
||||||
@@ -314,13 +301,19 @@ function ActionButton({
|
|||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
icon: typeof Check;
|
icon: typeof Check;
|
||||||
tone: 'pass' | 'hire' | 'neutral';
|
tone: 'pass' | 'hire' | 'accent' | 'ink';
|
||||||
size?: 'sm' | 'lg';
|
size?: 'sm' | 'lg';
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
}) {
|
}) {
|
||||||
const isLarge = size === 'lg';
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -330,29 +323,22 @@ function ActionButton({
|
|||||||
aria-label={label}
|
aria-label={label}
|
||||||
aria-pressed={active || undefined}
|
aria-pressed={active || undefined}
|
||||||
title={label}
|
title={label}
|
||||||
|
style={{
|
||||||
|
borderColor: colour,
|
||||||
|
color: active ? undefined : colour,
|
||||||
|
backgroundColor: active ? colour : undefined,
|
||||||
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex shrink-0 items-center justify-center rounded-full',
|
'flex items-center justify-center rounded-full border-2 shadow-lg',
|
||||||
'transition-[transform,background-color,color] duration-[120ms] ease-standard',
|
'transition hover:scale-105 active:scale-95',
|
||||||
'active:scale-90 disabled:pointer-events-none disabled:opacity-30',
|
'disabled:pointer-events-none disabled:opacity-35 disabled:shadow-none',
|
||||||
// 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',
|
isLarge ? 'h-16 w-16' : 'h-11 w-11',
|
||||||
|
active ? 'text-white' : 'bg-raised',
|
||||||
// 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
|
<Icon
|
||||||
className={isLarge ? 'h-7 w-7' : 'h-[1.15rem] w-[1.15rem]'}
|
className={isLarge ? 'h-7 w-7' : 'h-5 w-5'}
|
||||||
strokeWidth={isLarge ? 2.75 : 2.25}
|
strokeWidth={isLarge ? 3 : 2.5}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -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 (
|
|
||||||
<div className="flex items-center gap-3 rounded-lg border border-hairline bg-raised px-4 py-2.5">
|
|
||||||
<span className="w-16 shrink-0 text-meta text-faint">{label}</span>
|
|
||||||
<code className="min-w-0 flex-1 truncate font-mono text-body-sm text-strong tabular-nums">
|
|
||||||
{value}
|
|
||||||
</code>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={copy}
|
|
||||||
aria-label={copied ? `${label} copied` : `Copy ${label.toLowerCase()}`}
|
|
||||||
className={cn(
|
|
||||||
'flex h-9 w-9 shrink-0 items-center justify-center rounded-lg',
|
|
||||||
'transition-colors duration-[120ms] ease-standard',
|
|
||||||
'focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-brand-200',
|
|
||||||
copied ? 'text-go-600' : 'text-muted hover:bg-inset hover:text-accent',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{copied ? (
|
|
||||||
<Check className="h-4 w-4" aria-hidden />
|
|
||||||
) : (
|
|
||||||
<Copy className="h-4 w-4" aria-hidden />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,74 +1,31 @@
|
|||||||
import { inArray } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { db, schema } from '@linkder/db';
|
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
|
* 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
|
* the whole app untestable without a phone in your hand and Twilio credits. This
|
||||||
* makes it impossible to show somebody both sides of a two-sided marketplace in
|
* pins one number to one known code so `pnpm dev` is usable.
|
||||||
* a meeting. These accounts pin known numbers to one known code.
|
|
||||||
*
|
*
|
||||||
* THREE independent guards, unchanged by adding more accounts, because a login
|
* THREE independent guards, because a login bypass reaching production is the
|
||||||
* bypass reaching production is the worst bug this codebase could ship:
|
* worst bug this codebase could ship:
|
||||||
*
|
*
|
||||||
* 1. NODE_ENV must not be 'production'.
|
* 1. NODE_ENV must not be 'production'.
|
||||||
* 2. ALLOW_DEV_LOGIN must be explicitly 'true' — being in dev is not enough.
|
* 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
|
* Any one of them failing falls straight back to the real OTP path.
|
||||||
* `/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';
|
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 {
|
export function isDevLoginEnabled(): boolean {
|
||||||
return process.env.NODE_ENV !== 'production' && process.env.ALLOW_DEV_LOGIN === 'true';
|
return process.env.NODE_ENV !== 'production' && process.env.ALLOW_DEV_LOGIN === 'true';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isDevLoginPhone(phone: string): boolean {
|
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<void> {
|
|||||||
await db
|
await db
|
||||||
.update(schema.verifications)
|
.update(schema.verifications)
|
||||||
.set({ value: `${DEV_CODE}:0` })
|
.set({ value: `${DEV_CODE}:0` })
|
||||||
.where(inArray(schema.verifications.identifier, [phone]))
|
.where(eq(schema.verifications.identifier, phone));
|
||||||
.catch(() => {});
|
|
||||||
|
|
||||||
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: DEV_PHONE, code: DEV_CODE } as const;
|
||||||
export const DEV_LOGIN = { phone: DEMO_ACCOUNTS[0]!.phone, code: DEV_CODE } as const;
|
|
||||||
|
|||||||
Reference in New Issue
Block a user