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:
Stefan Lazic committed 2026-08-02 21:39:24 +01:00
1 parent 8b63cc66a9
commit 6ae145d187
66 files changed
+4599 -107

No files matched your search

+2 -2
View File
@@ -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
+26 -21
View File
@@ -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>
);
}
+6 -4
View File
@@ -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
+6 -6
View File
@@ -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>
)}
+5 -5
View File
@@ -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>
)}
+17 -8
View File
@@ -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,
)}
+5 -5
View File
@@ -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>
)}