Phase 4
Seed data extended with a second barber (different hours/services), a beard-trim service + category, breaks, time off, a guest customer, and appointments in all three key states — verified against a live throwaway Postgres. 14 new integration tests (38 total with Phase 3's) covering guest/authenticated booking, any-barber assignment, slot-conflict handling, ownership enforcement, guest-data isolation, and book-again repricing. Playwright E2E suite for the 6 required customer journeys, with its own global-setup.ts that migrates and seeds a database automatically. Full verification pass, which caught and fixed three real bugs along the way (not just test-workarounds): A date off-by-one bug in the calendar picker that made real weekdays show as closed for anyone in a UTC+ timezone (including the business's own). A dialog that could vanish mid-confirmation on cancellation, before the customer could read the late-cancellation notice. A silent test-cleanup leak in the Phase 3 fixtures that had been leaking stray businesses since Phase 3. Plus a baseline desktop-responsive fix (centered max-w-2xl columns) across Home/Appointments/Settings/booking flow, since desktop had been effectively unstyled. Final state: 90 unit tests, 38 integration tests, and all 6 E2E journeys passing together on a fresh database; clean tsc, eslint, and next build. Full writeup with routes, security model, guest-matching rules, known limitations, and what to check before Phase 5 is in docs/phase-4-summary.md.
This commit is contained in:
66 files changed
+4599
-107
No files matched your search
@@ -15,11 +15,11 @@ export function ErrorState({
|
||||
}: ErrorStateProps) {
|
||||
return (
|
||||
<div role="alert" className="flex flex-col items-center px-6 py-16 text-center">
|
||||
<div className="mb-5 flex h-16 w-16 items-center justify-center rounded-full bg-red-50 text-red-500">
|
||||
<div className="bg-danger/10 text-danger mb-5 flex h-16 w-16 items-center justify-center rounded-full">
|
||||
<TriangleAlert className="h-6 w-6" aria-hidden="true" />
|
||||
</div>
|
||||
<h3 className="mb-1 text-base font-bold">{title}</h3>
|
||||
<p className="mb-6 text-sm text-muted">{description}</p>
|
||||
<p className="text-muted mb-6 text-sm">{description}</p>
|
||||
{onRetry && (
|
||||
<Button variant="secondary" size="md" onClick={onRetry}>
|
||||
Try again
|
||||
|
||||
@@ -58,30 +58,35 @@ export function BottomNav({ items, variant = "customer" }: BottomNavProps) {
|
||||
<nav
|
||||
aria-label="Primary"
|
||||
className={cn(
|
||||
"fixed inset-x-0 bottom-0 z-50 flex items-center justify-between border-t px-5 py-2",
|
||||
"fixed inset-x-0 bottom-0 z-50 border-t px-5 py-2",
|
||||
"pb-[max(0.5rem,env(safe-area-inset-bottom))]",
|
||||
isStaff ? "border-white/5 bg-[#0c0c0c] text-gray-400" : "border-line bg-white text-muted",
|
||||
isStaff ? "border-white/5 bg-[#0c0c0c] text-gray-400" : "border-line text-muted bg-white",
|
||||
)}
|
||||
>
|
||||
{items.map(({ href, label, icon }) => {
|
||||
const Icon = ICON_MAP[icon];
|
||||
const active = pathname === href || pathname.startsWith(`${href}/`);
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1 rounded-lg px-3 py-1 transition-colors",
|
||||
active ? (isStaff ? "text-white" : "text-ink") : "hover:text-ink",
|
||||
isStaff && !active && "hover:text-white",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5" aria-hidden="true" />
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest">{label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{/* The bar itself stays edge-to-edge (matches the booking flow's fixed
|
||||
action bars); only the items inside are centered/width-capped so
|
||||
they don't stretch out unreadably on a wide desktop viewport. */}
|
||||
<div className="mx-auto flex w-full max-w-2xl items-center justify-between">
|
||||
{items.map(({ href, label, icon }) => {
|
||||
const Icon = ICON_MAP[icon];
|
||||
const active = pathname === href || pathname.startsWith(`${href}/`);
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1 rounded-lg px-3 py-1 transition-colors",
|
||||
active ? (isStaff ? "text-white" : "text-ink") : "hover:text-ink",
|
||||
isStaff && !active && "hover:text-white",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5" aria-hidden="true" />
|
||||
<span className="text-[10px] font-bold tracking-widest uppercase">{label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export const buttonVariants = cva(
|
||||
accent: "bg-accent text-white hover:brightness-95",
|
||||
secondary: "bg-white text-ink border border-line hover:bg-stone",
|
||||
ghost: "text-ink hover:bg-stone",
|
||||
destructive: "border border-red-200 text-red-500 hover:bg-red-50",
|
||||
destructive: "border border-danger/30 text-danger hover:bg-danger/10",
|
||||
},
|
||||
size: {
|
||||
sm: "h-9 rounded-full px-4 text-[10px]",
|
||||
@@ -37,15 +37,17 @@ export const buttonVariants = cva(
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
extends ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
|
||||
/** Render the styling onto the single child element instead of a <button> (e.g. a Next <Link>). */
|
||||
asChild?: boolean;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, isLoading = false, children, disabled, ...props }, ref) => {
|
||||
(
|
||||
{ className, variant, size, asChild = false, isLoading = false, children, disabled, ...props },
|
||||
ref,
|
||||
) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
|
||||
@@ -22,7 +22,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="mb-2 ml-1 block text-[10px] font-bold uppercase tracking-widest text-muted"
|
||||
className="text-muted mb-2 ml-1 block text-[10px] font-bold tracking-widest uppercase"
|
||||
>
|
||||
{label}
|
||||
{required && <span aria-hidden="true"> *</span>}
|
||||
@@ -30,7 +30,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
)}
|
||||
<div className="relative">
|
||||
{icon && (
|
||||
<span className="pointer-events-none absolute left-5 top-1/2 -translate-y-1/2 text-muted">
|
||||
<span className="text-muted pointer-events-none absolute top-1/2 left-5 -translate-y-1/2">
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
@@ -41,21 +41,21 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
aria-invalid={!!error || undefined}
|
||||
aria-describedby={cn(hintId, errorId) || undefined}
|
||||
className={cn(
|
||||
"w-full rounded-xl border border-line bg-white px-5 py-4 text-sm outline-none transition-all placeholder:text-muted focus:ring-2 focus:ring-ink",
|
||||
"border-line placeholder:text-muted focus:ring-ink w-full rounded-xl border bg-white px-5 py-4 text-sm transition-all outline-none focus:ring-2",
|
||||
icon && "pl-12",
|
||||
error && "border-red-300 focus:ring-red-400",
|
||||
error && "border-danger/50 focus:ring-danger",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
{hint && !error && (
|
||||
<p id={hintId} className="mt-1.5 ml-1 text-xs text-muted">
|
||||
<p id={hintId} className="text-muted mt-1.5 ml-1 text-xs">
|
||||
{hint}
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p id={errorId} role="alert" className="mt-1.5 ml-1 text-xs font-medium text-red-500">
|
||||
<p id={errorId} role="alert" className="text-danger mt-1.5 ml-1 text-xs font-medium">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -24,7 +24,7 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={selectId}
|
||||
className="mb-2 ml-1 block text-[10px] font-bold uppercase tracking-widest text-muted"
|
||||
className="text-muted mb-2 ml-1 block text-[10px] font-bold tracking-widest uppercase"
|
||||
>
|
||||
{label}
|
||||
{required && <span aria-hidden="true"> *</span>}
|
||||
@@ -38,8 +38,8 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(
|
||||
aria-invalid={!!error || undefined}
|
||||
aria-describedby={errorId}
|
||||
className={cn(
|
||||
"w-full appearance-none rounded-xl border border-line bg-white px-4 py-3 pr-10 text-sm font-medium outline-none transition-all focus:ring-2 focus:ring-ink",
|
||||
error && "border-red-300 focus:ring-red-400",
|
||||
"border-line focus:ring-ink w-full appearance-none rounded-xl border bg-white px-4 py-3 pr-10 text-sm font-medium transition-all outline-none focus:ring-2",
|
||||
error && "border-danger/50 focus:ring-danger",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -48,11 +48,11 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(
|
||||
</select>
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute right-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted"
|
||||
className="text-muted pointer-events-none absolute top-1/2 right-4 h-4 w-4 -translate-y-1/2"
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p id={errorId} role="alert" className="mt-1.5 ml-1 text-xs font-medium text-red-500">
|
||||
<p id={errorId} role="alert" className="text-danger mt-1.5 ml-1 text-xs font-medium">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -2,21 +2,30 @@ import type { AppointmentStatus } from "@/types/domain";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
|
||||
const STATUS_CONFIG: Record<AppointmentStatus, { label: string; className: string }> = {
|
||||
pending_approval: { label: "Pending Approval", className: "bg-amber-100 text-amber-700" },
|
||||
confirmed: { label: "Confirmed", className: "bg-accent/10 text-accent" },
|
||||
arrived: { label: "Arrived", className: "bg-accent/10 text-accent" },
|
||||
in_progress: { label: "In Progress", className: "bg-blue-100 text-blue-700" },
|
||||
pending_approval: {
|
||||
label: "Pending Approval",
|
||||
className: "bg-status-pending/10 text-status-pending",
|
||||
},
|
||||
confirmed: { label: "Confirmed", className: "bg-status-confirmed/10 text-status-confirmed" },
|
||||
arrived: { label: "Arrived", className: "bg-status-confirmed/10 text-status-confirmed" },
|
||||
in_progress: { label: "In Progress", className: "bg-status-progress/10 text-status-progress" },
|
||||
completed: { label: "Completed", className: "bg-stone text-muted" },
|
||||
cancelled: { label: "Cancelled", className: "bg-red-50 text-red-500" },
|
||||
no_show: { label: "No-show", className: "bg-red-50 text-red-500" },
|
||||
cancelled: { label: "Cancelled", className: "bg-status-cancelled/10 text-status-cancelled" },
|
||||
no_show: { label: "No-show", className: "bg-status-cancelled/10 text-status-cancelled" },
|
||||
};
|
||||
|
||||
export function StatusPill({ status, className }: { status: AppointmentStatus; className?: string }) {
|
||||
export function StatusPill({
|
||||
status,
|
||||
className,
|
||||
}: {
|
||||
status: AppointmentStatus;
|
||||
className?: string;
|
||||
}) {
|
||||
const config = STATUS_CONFIG[status];
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block rounded-full px-3 py-1 text-[10px] font-bold uppercase tracking-widest",
|
||||
"inline-block rounded-full px-3 py-1 text-[10px] font-bold tracking-widest uppercase",
|
||||
config.className,
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -20,7 +20,7 @@ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={textareaId}
|
||||
className="mb-2 ml-1 block text-[10px] font-bold uppercase tracking-widest text-muted"
|
||||
className="text-muted mb-2 ml-1 block text-[10px] font-bold tracking-widest uppercase"
|
||||
>
|
||||
{label}
|
||||
{required && <span aria-hidden="true"> *</span>}
|
||||
@@ -34,19 +34,19 @@ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
aria-invalid={!!error || undefined}
|
||||
aria-describedby={cn(hintId, errorId) || undefined}
|
||||
className={cn(
|
||||
"w-full resize-none rounded-xl border border-line bg-white p-4 text-sm outline-none transition-all placeholder:text-muted focus:ring-2 focus:ring-ink",
|
||||
error && "border-red-300 focus:ring-red-400",
|
||||
"border-line placeholder:text-muted focus:ring-ink w-full resize-none rounded-xl border bg-white p-4 text-sm transition-all outline-none focus:ring-2",
|
||||
error && "border-danger/50 focus:ring-danger",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{hint && !error && (
|
||||
<p id={hintId} className="mt-1.5 ml-1 text-xs text-muted">
|
||||
<p id={hintId} className="text-muted mt-1.5 ml-1 text-xs">
|
||||
{hint}
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p id={errorId} role="alert" className="mt-1.5 ml-1 text-xs font-medium text-red-500">
|
||||
<p id={errorId} role="alert" className="text-danger mt-1.5 ml-1 text-xs font-medium">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user