PHP Rebuild
CI / ci (push) Canceled after 0s

Initial PHP rebuild of the app
This commit is contained in:
Stefan Lazic committed 2026-09-05 08:21:33 +01:00
1 parent 8a2b211069
commit 2ef3654841
690 files changed
+33944 -12046

No files matched your search

@@ -1,598 +0,0 @@
"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<WeekdayCode, string> = {
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<string | null>(null);
// Step 1
const [token, setToken] = useState("");
const [tokenError, setTokenError] = useState<string | null>(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<HoursDayState[]>(
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<StarterServiceState[]>(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<HoursDayState>) => {
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<StarterServiceState>) => {
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 (
<div className="space-y-6">
<div>
<p className="text-muted text-[10px] font-bold tracking-widest uppercase">
Step {step} of {STEPS.length} — {STEPS[step - 1]}
</p>
<div className="bg-line mt-2 h-1.5 w-full overflow-hidden rounded-full">
<div className="bg-ink h-full rounded-full transition-all" style={{ width: `${(step / STEPS.length) * 100}%` }} />
</div>
</div>
{step === 1 && (
<div className="space-y-4">
<h1 className="text-2xl font-bold">Set up your business</h1>
<p className="text-muted text-sm">
This wizard creates the first administrator account and configures your business — opening hours,
booking rules, and branding. It only needs to run once.
</p>
<Input
label="Installation Token"
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
error={tokenError ?? undefined}
required
autoComplete="off"
/>
<Button className="w-full" isLoading={isPending} disabled={!token} onClick={verifyToken}>
Continue
</Button>
</div>
)}
{step === 2 && (
<div className="space-y-4">
<h2 className="text-xl font-bold">Administrator account</h2>
<p className="text-muted text-sm">This becomes the first owner — full permissions, protected from removal.</p>
<div className="grid grid-cols-2 gap-3">
<Input label="First Name" value={admin.firstName} onChange={(e) => setAdmin({ ...admin, firstName: e.target.value })} required />
<Input label="Last Name" value={admin.lastName} onChange={(e) => setAdmin({ ...admin, lastName: e.target.value })} required />
</div>
<Input label="Email Address" type="email" value={admin.email} onChange={(e) => setAdmin({ ...admin, email: e.target.value })} required />
<Input label="Phone (optional)" type="tel" value={admin.phone} onChange={(e) => setAdmin({ ...admin, phone: e.target.value })} />
<Input
label="Password"
type="password"
value={admin.password}
onChange={(e) => setAdmin({ ...admin, password: e.target.value })}
hint="At least 8 characters."
required
/>
<Input
label="Confirm Password"
type="password"
value={admin.confirmPassword}
onChange={(e) => setAdmin({ ...admin, confirmPassword: e.target.value })}
error={admin.confirmPassword && admin.password !== admin.confirmPassword ? "Passwords don't match" : undefined}
required
/>
<StepNav onBack={() => setStep(1)} onNext={() => setStep(3)} nextDisabled={!canProceedFromStep(2)} />
</div>
)}
{step === 3 && (
<div className="space-y-4">
<h2 className="text-xl font-bold">Business details</h2>
<Input label="Business Name" value={business.name} onChange={(e) => setBusiness({ ...business, name: e.target.value })} required />
<Input
label="Short Name (optional)"
hint="Used where space is tight, e.g. the home-screen icon label."
value={business.shortName}
onChange={(e) => setBusiness({ ...business, shortName: e.target.value })}
/>
<Textarea
label="Description (optional)"
value={business.description}
onChange={(e) => setBusiness({ ...business, description: e.target.value })}
/>
<Input label="Business Email (optional)" type="email" value={business.email} onChange={(e) => setBusiness({ ...business, email: e.target.value })} />
<Input label="Phone Number (optional)" type="tel" value={business.phone} onChange={(e) => setBusiness({ ...business, phone: e.target.value })} />
<Input label="Website (optional)" value={business.website} onChange={(e) => setBusiness({ ...business, website: e.target.value })} />
<StepNav onBack={() => setStep(2)} onNext={() => setStep(4)} nextDisabled={!canProceedFromStep(3)} />
</div>
)}
{step === 4 && (
<div className="space-y-4">
<h2 className="text-xl font-bold">Location & contact</h2>
<Input label="Address Line 1" value={location.addressLine1} onChange={(e) => setLocation({ ...location, addressLine1: e.target.value })} required />
<Input label="Address Line 2 (optional)" value={location.addressLine2} onChange={(e) => setLocation({ ...location, addressLine2: e.target.value })} />
<div className="grid grid-cols-2 gap-3">
<Input label="Town / City" value={location.city} onChange={(e) => setLocation({ ...location, city: e.target.value })} required />
<Input label="County (optional)" value={location.region} onChange={(e) => setLocation({ ...location, region: e.target.value })} />
</div>
<div className="grid grid-cols-2 gap-3">
<Input label="Postcode" value={location.postalCode} onChange={(e) => setLocation({ ...location, postalCode: e.target.value })} required />
<Input label="Country" value={location.country} onChange={(e) => setLocation({ ...location, country: e.target.value })} required />
</div>
<div className="grid grid-cols-3 gap-3">
<Input label="Timezone" value={location.timezone} onChange={(e) => setLocation({ ...location, timezone: e.target.value })} required />
<Input label="Currency" value={location.currency} onChange={(e) => setLocation({ ...location, currency: e.target.value.toUpperCase() })} maxLength={3} required />
<Input label="Locale" value={location.locale} onChange={(e) => setLocation({ ...location, locale: e.target.value })} required />
</div>
<StepNav onBack={() => setStep(3)} onNext={() => setStep(5)} nextDisabled={!canProceedFromStep(4)} />
</div>
)}
{step === 5 && (
<div className="space-y-4">
<h2 className="text-xl font-bold">Opening hours</h2>
<div className="flex gap-4">
<button type="button" onClick={copyMondayToWeekdays} className="text-accent text-xs font-bold tracking-widest uppercase underline">
Copy Monday to weekdays
</button>
<button type="button" onClick={copyMondayToAll} className="text-accent text-xs font-bold tracking-widest uppercase underline">
Copy Monday to all days
</button>
</div>
<div className="divide-line rounded-md border-line divide-y border bg-white">
{hours.map((day) => (
<div key={day.dayOfWeek} className="flex items-center justify-between gap-4 px-5 py-4">
<div className="flex items-center gap-3">
<Switch checked={!day.isClosed} onCheckedChange={(checked) => updateDay(day.dayOfWeek, { isClosed: !checked })} aria-label={`${DAY_LABEL[day.dayOfWeek]} open`} />
<p className="text-sm font-bold">{DAY_LABEL[day.dayOfWeek]}</p>
</div>
{day.isClosed ? (
<span className="text-muted text-xs">Closed</span>
) : (
<div className="flex items-center gap-2">
<input type="time" value={day.startTime} onChange={(e) => updateDay(day.dayOfWeek, { startTime: e.target.value })} className="border-line rounded-lg border px-2 py-1.5 text-xs" aria-label={`${DAY_LABEL[day.dayOfWeek]} opening time`} />
<span className="text-muted text-xs">to</span>
<input type="time" value={day.endTime} onChange={(e) => updateDay(day.dayOfWeek, { endTime: e.target.value })} className="border-line rounded-lg border px-2 py-1.5 text-xs" aria-label={`${DAY_LABEL[day.dayOfWeek]} closing time`} />
</div>
)}
</div>
))}
</div>
<StepNav onBack={() => setStep(4)} onNext={() => setStep(6)} />
</div>
)}
{step === 6 && (
<div className="space-y-4">
<h2 className="text-xl font-bold">Booking settings</h2>
<div className="rounded-md border-line border bg-white p-5">
<Switch
checked={bookingRules.allowGuestBooking}
onCheckedChange={(checked) => setBookingRules({ ...bookingRules, allowGuestBooking: checked })}
label="Allow customers to book as guests"
description="When enabled, customers can book without creating an account. When disabled, customers must sign in or create an account before confirming their booking. Off is recommended — you can turn this on later in Booking Settings."
/>
</div>
<div className="grid grid-cols-2 gap-3">
<Input
label="Minimum Notice (minutes)"
type="number"
value={bookingRules.minNoticeMinutes}
onChange={(e) => setBookingRules({ ...bookingRules, minNoticeMinutes: Number(e.target.value) })}
/>
<Input
label="Max Advance Booking (days)"
type="number"
value={bookingRules.maxAdvanceDays}
onChange={(e) => setBookingRules({ ...bookingRules, maxAdvanceDays: Number(e.target.value) })}
/>
<Input
label="Cancellation Window (hours)"
type="number"
value={bookingRules.cancellationWindowHours}
onChange={(e) => setBookingRules({ ...bookingRules, cancellationWindowHours: Number(e.target.value) })}
/>
<Input
label="Reschedule Window (hours)"
type="number"
value={bookingRules.rescheduleWindowHours}
onChange={(e) => setBookingRules({ ...bookingRules, rescheduleWindowHours: Number(e.target.value) })}
/>
<Input
label="Slot Interval (minutes)"
type="number"
value={bookingRules.slotIntervalMinutes}
onChange={(e) => setBookingRules({ ...bookingRules, slotIntervalMinutes: Number(e.target.value) })}
/>
</div>
<div className="rounded-md border-line space-y-3 border bg-white p-5">
<Switch
checked={bookingRules.customerCanChooseStaff}
onCheckedChange={(checked) => setBookingRules({ ...bookingRules, customerCanChooseStaff: checked })}
label="Customers can choose their barber"
/>
<Switch
checked={bookingRules.requireApproval}
onCheckedChange={(checked) => setBookingRules({ ...bookingRules, requireApproval: checked })}
label="New bookings require staff approval"
/>
</div>
<StepNav onBack={() => setStep(5)} onNext={() => setStep(7)} />
</div>
)}
{step === 7 && (
<div className="space-y-4">
<h2 className="text-xl font-bold">Branding</h2>
<p className="text-muted text-sm">
Optional — every screen in the app uses your business name and these images/colours instead of the
product name. You can add or change all of this later in Business Settings.
</p>
<Input label="Logo URL (optional)" placeholder="https://…" value={branding.logoUrl} onChange={(e) => setBranding({ ...branding, logoUrl: e.target.value })} />
<Input label="Cover Image URL (optional)" placeholder="https://…" value={branding.coverImageUrl} onChange={(e) => setBranding({ ...branding, coverImageUrl: e.target.value })} />
<div className="grid grid-cols-2 gap-3">
<Input label="Theme Colour (optional)" placeholder="#111b15" value={branding.themeColor} onChange={(e) => setBranding({ ...branding, themeColor: e.target.value })} />
<Input label="Background Colour (optional)" placeholder="#f4f6f4" value={branding.backgroundColor} onChange={(e) => setBranding({ ...branding, backgroundColor: e.target.value })} />
</div>
<StepNav onBack={() => setStep(6)} onNext={() => setStep(8)} />
</div>
)}
{step === 8 && (
<div className="space-y-4">
<h2 className="text-xl font-bold">Starter services</h2>
<p className="text-muted text-sm">
A small editable starting point so your booking page isn&apos;t empty on day one. Edit, remove, or add
your own before finishing setup.
</p>
<div className="space-y-3">
{starterServices.map((s) => (
<div key={s.key} className="rounded-md border-line space-y-3 border bg-white p-4">
<div className="flex items-center justify-between">
<p className="text-muted text-[10px] font-bold tracking-widest uppercase">Service</p>
<button type="button" onClick={() => removeStarter(s.key)} className="text-danger text-xs font-bold uppercase">
Remove
</button>
</div>
<div className="grid grid-cols-2 gap-3">
<Input label="Category" value={s.categoryName} onChange={(e) => updateStarter(s.key, { categoryName: e.target.value })} />
<Input label="Service Name" value={s.name} onChange={(e) => updateStarter(s.key, { name: e.target.value })} />
</div>
<div className="grid grid-cols-2 gap-3">
<Input label="Price" value={s.price} onChange={(e) => updateStarter(s.key, { price: e.target.value })} />
<Input label="Duration (minutes)" type="number" value={s.durationMinutes} onChange={(e) => updateStarter(s.key, { durationMinutes: Number(e.target.value) })} />
</div>
</div>
))}
</div>
<Button variant="secondary" className="w-full" onClick={addStarter}>
Add Another Service
</Button>
<StepNav onBack={() => setStep(7)} onNext={() => setStep(9)} />
</div>
)}
{step === 9 && (
<div className="space-y-4">
<h2 className="text-xl font-bold">Review</h2>
<ReviewSection title="Administrator">
{admin.firstName} {admin.lastName} · {admin.email}
</ReviewSection>
<ReviewSection title="Business">{business.name}</ReviewSection>
<ReviewSection title="Location">
{location.addressLine1}, {location.city}, {location.postalCode} · {location.timezone} · {location.currency}
</ReviewSection>
<ReviewSection title="Opening Hours">
{hours.filter((d) => !d.isClosed).length} day{hours.filter((d) => !d.isClosed).length === 1 ? "" : "s"} open
</ReviewSection>
<ReviewSection title="Booking Settings">
Guest booking {bookingRules.allowGuestBooking ? "enabled" : "disabled"} · {bookingRules.requireApproval ? "requires approval" : "auto-confirms"}
</ReviewSection>
<ReviewSection title="Branding">{branding.logoUrl ? "Logo set" : "No logo yet — add one later in Business Settings"}</ReviewSection>
<ReviewSection title="Starter Services">
{starterServices.filter((s) => s.name.trim()).length} service{starterServices.filter((s) => s.name.trim()).length === 1 ? "" : "s"}
</ReviewSection>
{submitError && (
<p role="alert" className="text-danger text-sm font-medium">
{submitError}
</p>
)}
<div className="flex gap-3">
<Button variant="secondary" className="flex-1" onClick={() => setStep(8)} disabled={isPending}>
Back
</Button>
<Button className="flex-1" isLoading={isPending} onClick={submit}>
Confirm & Install
</Button>
</div>
</div>
)}
</div>
);
}
function StepNav({ onBack, onNext, nextDisabled }: { onBack: () => void; onNext: () => void; nextDisabled?: boolean }) {
return (
<div className="flex gap-3">
<Button variant="secondary" className="flex-1" onClick={onBack}>
Back
</Button>
<Button className="flex-1" onClick={onNext} disabled={nextDisabled}>
Continue
</Button>
</div>
);
}
function ReviewSection({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="rounded-md border-line border bg-white p-4">
<p className="text-muted text-[10px] font-bold tracking-widest uppercase">{title}</p>
<p className="mt-1 text-sm">{children}</p>
</div>
);
}