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.
121 lines
3.5 KiB
TypeScript
121 lines
3.5 KiB
TypeScript
"use client";
|
|
|
|
import { zodResolver } from "@hookform/resolvers/zod";
|
|
import Link from "next/link";
|
|
import { useState } from "react";
|
|
import { useForm } from "react-hook-form";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { registerAction } from "@/features/auth/actions/register";
|
|
import { registerSchema, type RegisterInput } from "@/features/auth/schemas";
|
|
|
|
export function RegisterForm() {
|
|
const [formError, setFormError] = useState<string | null>(null);
|
|
|
|
const {
|
|
register,
|
|
handleSubmit,
|
|
setError,
|
|
formState: { errors, isSubmitting },
|
|
} = useForm<RegisterInput>({
|
|
resolver: zodResolver(registerSchema),
|
|
});
|
|
|
|
const onSubmit = async (data: RegisterInput) => {
|
|
setFormError(null);
|
|
const formData = new FormData();
|
|
Object.entries(data).forEach(([key, value]) => formData.set(key, value ?? ""));
|
|
|
|
const result = await registerAction(formData);
|
|
|
|
// A successful call redirects server-side and never resolves here with
|
|
// success: true in practice, but we still handle it for completeness.
|
|
if (!result.success) {
|
|
setFormError(result.error ?? "Something went wrong. Please try again.");
|
|
if (result.fieldErrors) {
|
|
for (const [field, messages] of Object.entries(result.fieldErrors)) {
|
|
if (messages?.[0]) {
|
|
setError(field as keyof RegisterInput, { message: messages[0] });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<Input
|
|
label="First Name"
|
|
placeholder="Alex"
|
|
error={errors.firstName?.message}
|
|
{...register("firstName")}
|
|
/>
|
|
<Input
|
|
label="Last Name"
|
|
placeholder="Chen"
|
|
error={errors.lastName?.message}
|
|
{...register("lastName")}
|
|
/>
|
|
</div>
|
|
<Input
|
|
label="Email Address"
|
|
type="email"
|
|
placeholder="name@example.com"
|
|
error={errors.email?.message}
|
|
{...register("email")}
|
|
/>
|
|
<Input
|
|
label="Phone Number (optional)"
|
|
type="tel"
|
|
placeholder="+44 7700 900000"
|
|
error={errors.phone?.message}
|
|
{...register("phone")}
|
|
/>
|
|
<Input
|
|
label="Password"
|
|
type="password"
|
|
placeholder="••••••••"
|
|
error={errors.password?.message}
|
|
{...register("password")}
|
|
/>
|
|
<Input
|
|
label="Confirm Password"
|
|
type="password"
|
|
placeholder="••••••••"
|
|
error={errors.confirmPassword?.message}
|
|
{...register("confirmPassword")}
|
|
/>
|
|
|
|
{formError && (
|
|
<p role="alert" className="text-danger text-sm font-medium">
|
|
{formError}
|
|
</p>
|
|
)}
|
|
|
|
<p className="text-muted px-1 text-[10px] leading-relaxed">
|
|
By creating an account, you agree to our{" "}
|
|
<a href="#" className="underline">
|
|
Terms of Service
|
|
</a>{" "}
|
|
and{" "}
|
|
<a href="#" className="underline">
|
|
Privacy Policy
|
|
</a>
|
|
.
|
|
</p>
|
|
|
|
<Button type="submit" className="mt-4 w-full" isLoading={isSubmitting}>
|
|
Sign Up
|
|
</Button>
|
|
|
|
<p className="text-muted mt-10 text-center text-sm">
|
|
Already have an account?{" "}
|
|
<Link href="/sign-in" className="border-ink text-ink border-b font-bold">
|
|
Log in
|
|
</Link>
|
|
</p>
|
|
</form>
|
|
);
|
|
}
|