1 file changed
+179
-337
@@ -17,10 +17,10 @@ import type { InstallInput } from "../schema";
|
|||||||
/**
|
/**
|
||||||
* Holds every wizard step's data as plain React state, not a signed
|
* Holds every wizard step's data as plain React state, not a signed
|
||||||
* server-side draft — nothing is validated or written to the database
|
* server-side draft — nothing is validated or written to the database
|
||||||
* until the single final `submitInstallationAction` call on Step 9's
|
* until the single final `submitInstallationAction` call on the Review
|
||||||
* confirmation (see installer.service.ts's own header). That means a
|
* step's confirmation (see installer.service.ts's own header). That means
|
||||||
* page refresh loses progress, a deliberate simplification: this page is
|
* a page refresh loses progress, a deliberate simplification: this page
|
||||||
* only ever visited once, by the person physically standing up the
|
* is only ever visited once, by the person physically standing up the
|
||||||
* deployment, so "refresh loses your place" is a fair trade for not
|
* deployment, so "refresh loses your place" is a fair trade for not
|
||||||
* needing a signed-cookie draft model and its own cleanup story.
|
* needing a signed-cookie draft model and its own cleanup story.
|
||||||
*/
|
*/
|
||||||
@@ -44,80 +44,28 @@ interface HoursDayState {
|
|||||||
endTime: 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 {
|
function toMinutes(time: string): number {
|
||||||
const [h, m] = time.split(":").map(Number);
|
const [h, m] = time.split(":").map(Number);
|
||||||
return (h ?? 0) * 60 + (m ?? 0);
|
return (h ?? 0) * 60 + (m ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
const STEPS = [
|
const STEPS = ["Welcome", "Business & Location", "Hours & Booking", "Administrator", "Review"] as const;
|
||||||
"Welcome",
|
|
||||||
"Administrator",
|
|
||||||
"Business Details",
|
|
||||||
"Location & Contact",
|
|
||||||
"Opening Hours",
|
|
||||||
"Booking Settings",
|
|
||||||
"Branding",
|
|
||||||
"Starter Services",
|
|
||||||
"Review",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
let starterServiceKeySeq = 0;
|
// Branding and starter services are no longer collected during install —
|
||||||
function newStarterServiceKey(): string {
|
// both are fully optional and easy to add afterward (Business Settings,
|
||||||
starterServiceKeySeq += 1;
|
// and Owner > Services respectively). The Administrator/email-verification
|
||||||
return `starter-${starterServiceKeySeq}`;
|
// step was also moved to run immediately before Review (it used to be
|
||||||
}
|
// Step 2) — verification only holds for 30 minutes (see
|
||||||
|
// otp.service.ts's VERIFIED_GRACE_MS), and a long wizard in between it and
|
||||||
const DEFAULT_STARTER_SERVICES: StarterServiceState[] = [
|
// the final Confirm & Install could easily outrun that window.
|
||||||
{
|
|
||||||
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({
|
export function InstallWizard({
|
||||||
initialBusinessName = "",
|
initialBusinessName = "",
|
||||||
bookingUrl,
|
bookingUrl,
|
||||||
}: {
|
}: {
|
||||||
/** From process.env.BUSINESS_NAME, set by the Manager at provisioning time (see manager/src/lib/compose-template.ts) — pre-fills Step 3's Business Name but stays editable, since the operator may want a slightly different public name. */
|
/** From process.env.BUSINESS_NAME, set by the Manager at provisioning time (see manager/src/lib/compose-template.ts) — pre-fills the Business Name field but stays editable, since the operator may want a slightly different public name. */
|
||||||
initialBusinessName?: string;
|
initialBusinessName?: string;
|
||||||
/** From process.env.NEXT_PUBLIC_APP_URL, also Manager-provisioned — shown read-only in Step 3, never an editable field: this is infrastructure/routing configuration, not business profile data (see stacks/infra's Caddy site, which is what actually makes this domain work). */
|
/** From process.env.NEXT_PUBLIC_APP_URL, also Manager-provisioned — shown read-only, never an editable field: this is infrastructure/routing configuration, not business profile data (see stacks/infra's Caddy site, which is what actually makes this domain work). */
|
||||||
bookingUrl?: string;
|
bookingUrl?: string;
|
||||||
}) {
|
}) {
|
||||||
const [step, setStep] = useState(1);
|
const [step, setStep] = useState(1);
|
||||||
@@ -128,15 +76,7 @@ export function InstallWizard({
|
|||||||
const [token, setToken] = useState("");
|
const [token, setToken] = useState("");
|
||||||
const [tokenError, setTokenError] = useState<string | null>(null);
|
const [tokenError, setTokenError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Step 2 — otpPhase tracks verification separately from the rest of the
|
// Step 2
|
||||||
// form: "collect" (editing name/email/phone), "code" (entering the
|
|
||||||
// emailed code), "verified" (done, can proceed). Editing the email after
|
|
||||||
// verifying resets to "collect" — see the email field's onChange below.
|
|
||||||
const [admin, setAdmin] = useState({ firstName: "", lastName: "", email: "", phone: "" });
|
|
||||||
const [otpPhase, setOtpPhase] = useState<"collect" | "code" | "verified">("collect");
|
|
||||||
const [otpError, setOtpError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Step 3
|
|
||||||
const [business, setBusiness] = useState({
|
const [business, setBusiness] = useState({
|
||||||
name: initialBusinessName,
|
name: initialBusinessName,
|
||||||
shortName: "",
|
shortName: "",
|
||||||
@@ -145,8 +85,6 @@ export function InstallWizard({
|
|||||||
phone: "",
|
phone: "",
|
||||||
website: "",
|
website: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Step 4
|
|
||||||
const [location, setLocation] = useState({
|
const [location, setLocation] = useState({
|
||||||
addressLine1: "",
|
addressLine1: "",
|
||||||
addressLine2: "",
|
addressLine2: "",
|
||||||
@@ -159,7 +97,7 @@ export function InstallWizard({
|
|||||||
locale: "en-GB",
|
locale: "en-GB",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Step 5
|
// Step 3
|
||||||
const [hours, setHours] = useState<HoursDayState[]>(
|
const [hours, setHours] = useState<HoursDayState[]>(
|
||||||
WEEKDAYS.map((dayOfWeek) => ({
|
WEEKDAYS.map((dayOfWeek) => ({
|
||||||
dayOfWeek,
|
dayOfWeek,
|
||||||
@@ -168,8 +106,7 @@ export function InstallWizard({
|
|||||||
endTime: "18:00",
|
endTime: "18:00",
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
// Guest booking defaults OFF, per the brief.
|
||||||
// Step 6 — guest booking defaults OFF, per the brief.
|
|
||||||
const [bookingRules, setBookingRules] = useState({
|
const [bookingRules, setBookingRules] = useState({
|
||||||
allowGuestBooking: false,
|
allowGuestBooking: false,
|
||||||
minNoticeMinutes: 120,
|
minNoticeMinutes: 120,
|
||||||
@@ -185,11 +122,17 @@ export function InstallWizard({
|
|||||||
requireApproval: false,
|
requireApproval: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Step 7
|
// Step 4 — otpPhase tracks verification separately from the rest of the
|
||||||
const [branding, setBranding] = useState({ logoUrl: "", coverImageUrl: "", themeColor: "", backgroundColor: "" });
|
// form: "collect" (editing name/email/phone), "code" (entering the
|
||||||
|
// emailed code), "verified" (done, can proceed). Editing the email after
|
||||||
|
// verifying resets to "collect" — see the email field's onChange below.
|
||||||
|
const [admin, setAdmin] = useState({ firstName: "", lastName: "", email: "", phone: "" });
|
||||||
|
const [otpPhase, setOtpPhase] = useState<"collect" | "code" | "verified">("collect");
|
||||||
|
const [otpError, setOtpError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Step 8
|
// Not collected in the wizard — see this file's own header. Submitted
|
||||||
const [starterServices, setStarterServices] = useState<StarterServiceState[]>(DEFAULT_STARTER_SERVICES);
|
// as-is: blank branding, no starter services.
|
||||||
|
const branding = { logoUrl: "", coverImageUrl: "", themeColor: "", backgroundColor: "" };
|
||||||
|
|
||||||
const verifyToken = () => {
|
const verifyToken = () => {
|
||||||
setTokenError(null);
|
setTokenError(null);
|
||||||
@@ -249,28 +192,6 @@ export function InstallWizard({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
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 = () => {
|
const submit = () => {
|
||||||
setSubmitError(null);
|
setSubmitError(null);
|
||||||
const input: InstallInput = {
|
const input: InstallInput = {
|
||||||
@@ -285,17 +206,7 @@ export function InstallWizard({
|
|||||||
})),
|
})),
|
||||||
bookingRules,
|
bookingRules,
|
||||||
branding,
|
branding,
|
||||||
starterServices: 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 () => {
|
startTransition(async () => {
|
||||||
@@ -311,17 +222,16 @@ export function InstallWizard({
|
|||||||
const canProceedFromStep = (s: number): boolean => {
|
const canProceedFromStep = (s: number): boolean => {
|
||||||
switch (s) {
|
switch (s) {
|
||||||
case 2:
|
case 2:
|
||||||
return !!admin.firstName.trim() && !!admin.lastName.trim() && !!admin.email.trim() && otpPhase === "verified";
|
|
||||||
case 3:
|
|
||||||
return !!business.name.trim();
|
|
||||||
case 4:
|
|
||||||
return (
|
return (
|
||||||
|
!!business.name.trim() &&
|
||||||
!!location.addressLine1.trim() &&
|
!!location.addressLine1.trim() &&
|
||||||
!!location.city.trim() &&
|
!!location.city.trim() &&
|
||||||
!!location.postalCode.trim() &&
|
!!location.postalCode.trim() &&
|
||||||
!!location.timezone.trim() &&
|
!!location.timezone.trim() &&
|
||||||
location.currency.trim().length === 3
|
location.currency.trim().length === 3
|
||||||
);
|
);
|
||||||
|
case 4:
|
||||||
|
return !!admin.firstName.trim() && !!admin.lastName.trim() && !!admin.email.trim() && otpPhase === "verified";
|
||||||
default:
|
default:
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -361,6 +271,152 @@ export function InstallWizard({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{step === 2 && (
|
{step === 2 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-xl font-bold">Business & location</h2>
|
||||||
|
{bookingUrl && (
|
||||||
|
<div className="rounded-md border-line border bg-white p-4">
|
||||||
|
<p className="text-muted text-[10px] font-bold tracking-widest uppercase">Booking URL</p>
|
||||||
|
<p className="mt-1 text-sm">{bookingUrl}</p>
|
||||||
|
<p className="text-muted mt-1 text-xs">
|
||||||
|
Where customers book with you online — already set up for this deployment, not something to
|
||||||
|
configure here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Input
|
||||||
|
label="Business Name"
|
||||||
|
value={business.name}
|
||||||
|
onChange={(e) => setBusiness({ ...business, name: e.target.value })}
|
||||||
|
hint={initialBusinessName ? "Pre-filled from setup — change it if you'd like a different public name." : undefined}
|
||||||
|
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="Business Website (optional)"
|
||||||
|
placeholder="https://yourbusiness.com"
|
||||||
|
hint="Your business's own main website, if you have one — not the booking URL above."
|
||||||
|
value={business.website}
|
||||||
|
onChange={(e) => setBusiness({ ...business, website: e.target.value })}
|
||||||
|
/>
|
||||||
|
<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(1)} onNext={() => setStep(3)} nextDisabled={!canProceedFromStep(2)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 3 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-xl font-bold">Hours & booking</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>
|
||||||
|
|
||||||
|
<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(2)} onNext={() => setStep(4)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 4 && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h2 className="text-xl font-bold">Administrator account</h2>
|
<h2 className="text-xl font-bold">Administrator account</h2>
|
||||||
<p className="text-muted text-sm">
|
<p className="text-muted text-sm">
|
||||||
@@ -423,222 +479,12 @@ export function InstallWizard({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{otpPhase !== "code" && (
|
{otpPhase !== "code" && (
|
||||||
<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>
|
|
||||||
{bookingUrl && (
|
|
||||||
<div className="rounded-md border-line border bg-white p-4">
|
|
||||||
<p className="text-muted text-[10px] font-bold tracking-widest uppercase">Booking URL</p>
|
|
||||||
<p className="mt-1 text-sm">{bookingUrl}</p>
|
|
||||||
<p className="text-muted mt-1 text-xs">
|
|
||||||
Where customers book with you online — already set up for this deployment, not something to
|
|
||||||
configure here.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Input
|
|
||||||
label="Business Name"
|
|
||||||
value={business.name}
|
|
||||||
onChange={(e) => setBusiness({ ...business, name: e.target.value })}
|
|
||||||
hint={initialBusinessName ? "Pre-filled from setup — change it if you'd like a different public name." : undefined}
|
|
||||||
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="Business Website (optional)"
|
|
||||||
placeholder="https://yourbusiness.com"
|
|
||||||
hint="Your business's own main website, if you have one — not the booking URL above."
|
|
||||||
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)} />
|
<StepNav onBack={() => setStep(3)} onNext={() => setStep(5)} nextDisabled={!canProceedFromStep(4)} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === 5 && (
|
{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'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">
|
<div className="space-y-4">
|
||||||
<h2 className="text-xl font-bold">Review</h2>
|
<h2 className="text-xl font-bold">Review</h2>
|
||||||
<ReviewSection title="Administrator">
|
<ReviewSection title="Administrator">
|
||||||
@@ -654,10 +500,6 @@ export function InstallWizard({
|
|||||||
<ReviewSection title="Booking Settings">
|
<ReviewSection title="Booking Settings">
|
||||||
Guest booking {bookingRules.allowGuestBooking ? "enabled" : "disabled"} · {bookingRules.requireApproval ? "requires approval" : "auto-confirms"}
|
Guest booking {bookingRules.allowGuestBooking ? "enabled" : "disabled"} · {bookingRules.requireApproval ? "requires approval" : "auto-confirms"}
|
||||||
</ReviewSection>
|
</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 && (
|
{submitError && (
|
||||||
<p role="alert" className="text-danger text-sm font-medium">
|
<p role="alert" className="text-danger text-sm font-medium">
|
||||||
@@ -666,7 +508,7 @@ export function InstallWizard({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<Button variant="secondary" className="flex-1" onClick={() => setStep(8)} disabled={isPending}>
|
<Button variant="secondary" className="flex-1" onClick={() => setStep(4)} disabled={isPending}>
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
<Button className="flex-1" isLoading={isPending} onClick={submit}>
|
<Button className="flex-1" isLoading={isPending} onClick={submit}>
|
||||||
|
|||||||
Reference in new issue
Block a user