"use client"; import { useState, useTransition } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Switch } from "@/components/ui/switch"; import { submitInstallationAction, verifyInstallationTokenAction } from "../actions/install"; import type { InstallInput } from "../schema"; /** * Holds every wizard step's data as plain React state, not a signed * server-side draft — nothing is validated or written to the database * until the single final `submitInstallationAction` call on Step 9's * confirmation (see installer.service.ts's own header). That means a * page refresh loses progress, a deliberate simplification: this page is * only ever visited once, by the person physically standing up the * deployment, so "refresh loses your place" is a fair trade for not * needing a signed-cookie draft model and its own cleanup story. */ const WEEKDAYS = ["MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY", "SUNDAY"] as const; type WeekdayCode = (typeof WEEKDAYS)[number]; const DAY_LABEL: Record = { MONDAY: "Monday", TUESDAY: "Tuesday", WEDNESDAY: "Wednesday", THURSDAY: "Thursday", FRIDAY: "Friday", SATURDAY: "Saturday", SUNDAY: "Sunday", }; interface HoursDayState { dayOfWeek: WeekdayCode; isClosed: boolean; startTime: string; endTime: string; } interface StarterServiceState { key: string; categoryName: string; name: string; description: string; price: string; durationMinutes: number; active: boolean; allowOnlineBooking: boolean; } function toMinutes(time: string): number { const [h, m] = time.split(":").map(Number); return (h ?? 0) * 60 + (m ?? 0); } const STEPS = [ "Welcome", "Administrator", "Business Details", "Location & Contact", "Opening Hours", "Booking Settings", "Branding", "Starter Services", "Review", ] as const; let starterServiceKeySeq = 0; function newStarterServiceKey(): string { starterServiceKeySeq += 1; return `starter-${starterServiceKeySeq}`; } const DEFAULT_STARTER_SERVICES: StarterServiceState[] = [ { key: newStarterServiceKey(), categoryName: "Haircuts", name: "Classic Haircut", description: "", price: "25.00", durationMinutes: 30, active: true, allowOnlineBooking: true, }, { key: newStarterServiceKey(), categoryName: "Beard Services", name: "Beard Trim", description: "", price: "15.00", durationMinutes: 20, active: true, allowOnlineBooking: true, }, { key: newStarterServiceKey(), categoryName: "Packages", name: "Haircut & Beard Combo", description: "", price: "35.00", durationMinutes: 45, active: true, allowOnlineBooking: true, }, ]; export function InstallWizard() { const [step, setStep] = useState(1); const [isPending, startTransition] = useTransition(); const [submitError, setSubmitError] = useState(null); // Step 1 const [token, setToken] = useState(""); const [tokenError, setTokenError] = useState(null); // Step 2 const [admin, setAdmin] = useState({ firstName: "", lastName: "", email: "", phone: "", password: "", confirmPassword: "" }); // Step 3 const [business, setBusiness] = useState({ name: "", shortName: "", description: "", email: "", phone: "", website: "" }); // Step 4 const [location, setLocation] = useState({ addressLine1: "", addressLine2: "", city: "", region: "", postalCode: "", country: "GB", timezone: "Europe/London", currency: "GBP", locale: "en-GB", }); // Step 5 const [hours, setHours] = useState( WEEKDAYS.map((dayOfWeek) => ({ dayOfWeek, isClosed: dayOfWeek === "SUNDAY", startTime: "09:00", endTime: "18:00", })), ); // Step 6 — guest booking defaults OFF, per the brief. const [bookingRules, setBookingRules] = useState({ allowGuestBooking: false, minNoticeMinutes: 120, maxAdvanceDays: 60, cancellationWindowHours: 24, rescheduleWindowHours: 24, slotIntervalMinutes: 30, bufferBeforeMinutes: 0, bufferAfterMinutes: 5, maxAppointmentsPerDay: null as number | null, maxAppointmentsPerStaffPerDay: null as number | null, customerCanChooseStaff: true, requireApproval: false, }); // Step 7 const [branding, setBranding] = useState({ logoUrl: "", coverImageUrl: "", themeColor: "", backgroundColor: "" }); // Step 8 const [starterServices, setStarterServices] = useState(DEFAULT_STARTER_SERVICES); const verifyToken = () => { setTokenError(null); startTransition(async () => { const result = await verifyInstallationTokenAction(token); if (!result.valid) { setTokenError(result.error ?? "Incorrect installation token."); return; } setStep(2); }); }; const updateDay = (dayOfWeek: WeekdayCode, patch: Partial) => { setHours((prev) => prev.map((d) => (d.dayOfWeek === dayOfWeek ? { ...d, ...patch } : d))); }; const copyMondayToWeekdays = () => { const monday = hours.find((d) => d.dayOfWeek === "MONDAY"); if (!monday) return; setHours((prev) => prev.map((d) => d.dayOfWeek === "SATURDAY" || d.dayOfWeek === "SUNDAY" || d.dayOfWeek === "MONDAY" ? d : { ...d, isClosed: monday.isClosed, startTime: monday.startTime, endTime: monday.endTime }, ), ); }; const copyMondayToAll = () => { const monday = hours.find((d) => d.dayOfWeek === "MONDAY"); if (!monday) return; setHours((prev) => prev.map((d) => (d.dayOfWeek === "MONDAY" ? d : { ...d, isClosed: monday.isClosed, startTime: monday.startTime, endTime: monday.endTime })), ); }; const updateStarter = (key: string, patch: Partial) => { setStarterServices((prev) => prev.map((s) => (s.key === key ? { ...s, ...patch } : s))); }; const removeStarter = (key: string) => { setStarterServices((prev) => prev.filter((s) => s.key !== key)); }; const addStarter = () => { setStarterServices((prev) => [ ...prev, { key: newStarterServiceKey(), categoryName: "Haircuts", name: "", description: "", price: "0.00", durationMinutes: 30, active: true, allowOnlineBooking: true, }, ]); }; const submit = () => { setSubmitError(null); const input: InstallInput = { installationToken: token, administrator: admin, business, location, hours: hours.map((d) => ({ dayOfWeek: d.dayOfWeek, isClosed: d.isClosed, ...(d.isClosed ? {} : { opensAtMinutes: toMinutes(d.startTime), closesAtMinutes: toMinutes(d.endTime) }), })), bookingRules, branding, starterServices: starterServices .filter((s) => s.name.trim()) .map((s) => ({ categoryName: s.categoryName, name: s.name, description: s.description, price: s.price, durationMinutes: s.durationMinutes, active: s.active, allowOnlineBooking: s.allowOnlineBooking, })), }; startTransition(async () => { const result = await submitInstallationAction(input); if (!result.success) { setSubmitError(result.error ?? "Installation failed. Please try again."); } // On success, submitInstallationAction signs the administrator in // and redirects server-side — nothing else to do here. }); }; const canProceedFromStep = (s: number): boolean => { switch (s) { case 2: return ( !!admin.firstName.trim() && !!admin.lastName.trim() && !!admin.email.trim() && admin.password.length >= 8 && admin.password === admin.confirmPassword ); case 3: return !!business.name.trim(); case 4: return ( !!location.addressLine1.trim() && !!location.city.trim() && !!location.postalCode.trim() && !!location.timezone.trim() && location.currency.trim().length === 3 ); default: return true; } }; return (

Step {step} of {STEPS.length} — {STEPS[step - 1]}

{step === 1 && (

Set up your business

This wizard creates the first administrator account and configures your business — opening hours, booking rules, and branding. It only needs to run once.

setToken(e.target.value)} error={tokenError ?? undefined} required autoComplete="off" />
)} {step === 2 && (

Administrator account

This becomes the first owner — full permissions, protected from removal.

setAdmin({ ...admin, firstName: e.target.value })} required /> setAdmin({ ...admin, lastName: e.target.value })} required />
setAdmin({ ...admin, email: e.target.value })} required /> setAdmin({ ...admin, phone: e.target.value })} /> setAdmin({ ...admin, password: e.target.value })} hint="At least 8 characters." required /> setAdmin({ ...admin, confirmPassword: e.target.value })} error={admin.confirmPassword && admin.password !== admin.confirmPassword ? "Passwords don't match" : undefined} required /> setStep(1)} onNext={() => setStep(3)} nextDisabled={!canProceedFromStep(2)} />
)} {step === 3 && (

Business details

setBusiness({ ...business, name: e.target.value })} required /> setBusiness({ ...business, shortName: e.target.value })} />