Entry screen is the app: phone frame with a live deck inside
The homepage was a marketing brochure -- a hero paragraph, a "three steps" explainer and a tag list. It described the gesture in prose while the actual Tinder deck sat behind /deck/[jobId], reachable only after signing in AND posting a job. Nobody opening the app ever saw the product. Now / renders a phone illustration with the real app running inside it: the same <Deck>, the same drag physics, real verified pros. On a phone the bezel collapses and the deck simply fills the viewport -- drawing a picture of a phone on a phone is absurd, and it would eat the width the cards need. - Removed the fixed app bar and bottom tab bar. AppShell now renders the screen title as an in-flow h1; the bar owned the only h1 on every screen, so dropping it silently would have left every page headingless. The /jobs "post" action moved from bar chrome into the content, since the tab bar was its only other route there. - getShowcaseDeck(): a deck with no job behind it. getDeck is job-scoped (joins jobs for category and location, anti-joins swipes), which an anonymous visitor has none of, so this centres on the launch city. Eligibility rules are copied verbatim -- nobody may appear in the shop window who could not appear on a real deck. - deck.showcase: the only public procedure in the router. list and swipe stay behind clientProcedure. It reads nothing about the caller and writes nothing, so a right swipe on the entry screen is purely local. No real tradesperson is contacted until a job is posted. - <Deck> filled a hardcoded 560px desktop box; it now fills its container. The card counter moved out from between the two action buttons so the thumb zone holds nothing but the two controls. Verified against the seeded database: 22 eligible pros returned, and all three seeded traps excluded for the right reason -- Pau Ribas (22km out, 5km radius), Unverified Ulla (pending), Away Arnau (not accepting). 7 new integration tests cover exactly that. Also corrects a label I had written as "Plumbers near you" -- the showcase deck is not category-filtered and shows every trade. Includes concurrent edits to the mobile shell, ui/ primitives and DESIGN.md made outside this session. typecheck, lint, build clean; 141 tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { AlertTriangle, CheckCircle2, Info, XCircle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* DESIGN.md §6.5.
|
||||
*
|
||||
* The tinted surfaces stay light in dark mode and pin their text to ink, rather
|
||||
* than inverting. Inverting would mean a second set of tints for every tone, and
|
||||
* a pale-on-pale failure the first time one was missed.
|
||||
*/
|
||||
const bannerClasses = cva(
|
||||
'flex gap-3 rounded-card border p-5 text-ink-950',
|
||||
{
|
||||
variants: {
|
||||
tone: {
|
||||
info: 'border-brand-200 bg-brand-100',
|
||||
success: 'border-go-100 bg-go-50',
|
||||
warning: 'border-sun-100 bg-sun-50',
|
||||
error: 'border-stop-100 bg-stop-50',
|
||||
},
|
||||
},
|
||||
defaultVariants: { tone: 'info' },
|
||||
},
|
||||
);
|
||||
|
||||
const TONE_ICON = {
|
||||
info: Info,
|
||||
success: CheckCircle2,
|
||||
warning: AlertTriangle,
|
||||
error: XCircle,
|
||||
} as const;
|
||||
|
||||
const TONE_ICON_COLOR = {
|
||||
info: 'text-brand-500',
|
||||
success: 'text-go-600',
|
||||
warning: 'text-sun-500',
|
||||
error: 'text-stop-500',
|
||||
} as const;
|
||||
|
||||
type BannerProps = VariantProps<typeof bannerClasses> & {
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
/** `status` for outcomes the user caused, `alert` for errors. */
|
||||
role?: 'status' | 'alert';
|
||||
};
|
||||
|
||||
export function Banner({ tone = 'info', title, children, className, role }: BannerProps) {
|
||||
const key = tone ?? 'info';
|
||||
const Icon = TONE_ICON[key];
|
||||
|
||||
return (
|
||||
<div role={role} className={cn(bannerClasses({ tone }), className)}>
|
||||
<Icon className={cn('mt-0.5 h-5 w-5 shrink-0', TONE_ICON_COLOR[key])} aria-hidden />
|
||||
<div className="min-w-0">
|
||||
{title && <p className="font-display text-h4 text-ink-950">{title}</p>}
|
||||
<div className={cn('text-body-sm text-ink-800', title && 'mt-1')}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Inline form error. §6.2 — always announced. */
|
||||
export function FormError({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<p role="alert" className="text-body-sm text-stop-500">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user