Owner Administration is complete. Summary of this final stretch of work: Seed data extended with an inactive staff member, a pending invitation, a redeemed-reward fixture, a service with no eligible staff, and a future-appointment fixture for conflict testing — all idempotent, verified against the dev DB. Testing brought to 179 unit / 86 integration / 27 E2E tests, all passing together on a freshly seeded, disposable Postgres: Unit tests for every new pure validator (business hours, categories/services, booking rules, loyalty config, staff creation). Integration tests covering opening-hours conflicts, last-owner protection, staff deactivation/eligibility conflicts, loyalty redemption's atomic race-guard, and owner-vs-barber/business-isolation authorization — all against a real Postgres. 12 new Playwright owner journeys, which surfaced and fixed two genuine issues along the way: a cookie-clearing race condition in the sign-out-mid-test flow (a real, reproducible timing bug, not just a test artifact), and confirmation that seed fixtures upserted by deterministic ID go stale once "today" has moved on — now handled defensively. Desktop responsiveness: converted the single-column Business Details, Booking Rules, and Loyalty Configuration forms to a genuine two-column layout at the lg: breakpoint (verified via measured element positions, not just screenshots), confirmed mobile stacking is unaffected. Final verification: clean tsc, eslint, and next build, plus the full three-suite run against one fresh database as the authoritative pass.
207 lines
8.4 KiB
TypeScript
207 lines
8.4 KiB
TypeScript
"use client";
|
|
|
|
import { useRouter } from "next/navigation";
|
|
import { useState, useTransition } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Select } from "@/components/ui/select";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { updateBusinessProfileAction, updateBusinessSettingsAction } from "../actions/business";
|
|
|
|
const TIMEZONES = ["Europe/London", "Europe/Paris", "Europe/Dublin", "America/New_York", "America/Los_Angeles"];
|
|
const CURRENCIES = ["GBP", "USD", "EUR"];
|
|
const LOCALES = ["en-GB", "en-US"];
|
|
|
|
export interface BusinessDetailsInitial {
|
|
name: string;
|
|
description: string;
|
|
logoUrl: string;
|
|
phone: string;
|
|
email: string;
|
|
website: string;
|
|
instagramUrl: string;
|
|
facebookUrl: string;
|
|
timezone: string;
|
|
currency: string;
|
|
locale: string;
|
|
addressLine1: string;
|
|
addressLine2: string;
|
|
city: string;
|
|
region: string;
|
|
postalCode: string;
|
|
country: string;
|
|
cancellationPolicy: string;
|
|
lateArrivalPolicy: string;
|
|
bookingTerms: string;
|
|
privacyPolicyUrl: string;
|
|
}
|
|
|
|
export function BusinessDetailsForm({ initial }: { initial: BusinessDetailsInitial }) {
|
|
const router = useRouter();
|
|
const [form, setForm] = useState(initial);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [saved, setSaved] = useState(false);
|
|
const [isPending, startTransition] = useTransition();
|
|
|
|
const set = <K extends keyof BusinessDetailsInitial>(key: K, value: BusinessDetailsInitial[K]) => {
|
|
setSaved(false);
|
|
setForm((prev) => ({ ...prev, [key]: value }));
|
|
};
|
|
|
|
const handleSave = () => {
|
|
setError(null);
|
|
startTransition(async () => {
|
|
const profileResult = await updateBusinessProfileAction({
|
|
profile: {
|
|
name: form.name,
|
|
description: form.description || undefined,
|
|
logoUrl: form.logoUrl || undefined,
|
|
phone: form.phone || undefined,
|
|
email: form.email || undefined,
|
|
website: form.website || undefined,
|
|
instagramUrl: form.instagramUrl || undefined,
|
|
facebookUrl: form.facebookUrl || undefined,
|
|
timezone: form.timezone,
|
|
currency: form.currency,
|
|
locale: form.locale,
|
|
},
|
|
location: {
|
|
addressLine1: form.addressLine1,
|
|
addressLine2: form.addressLine2 || undefined,
|
|
city: form.city,
|
|
region: form.region || undefined,
|
|
postalCode: form.postalCode,
|
|
country: form.country,
|
|
},
|
|
});
|
|
if (!profileResult.success) {
|
|
setError(profileResult.error ?? "Couldn't save business details.");
|
|
return;
|
|
}
|
|
|
|
const settingsResult = await updateBusinessSettingsAction({
|
|
cancellationPolicy: form.cancellationPolicy || undefined,
|
|
lateArrivalPolicy: form.lateArrivalPolicy || undefined,
|
|
bookingTerms: form.bookingTerms || undefined,
|
|
privacyPolicyUrl: form.privacyPolicyUrl || undefined,
|
|
});
|
|
if (!settingsResult.success) {
|
|
setError(settingsResult.error ?? "Couldn't save business policies.");
|
|
return;
|
|
}
|
|
|
|
setSaved(true);
|
|
router.refresh();
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6 lg:grid lg:grid-cols-2 lg:items-start lg:gap-6 lg:space-y-0">
|
|
<section className="space-y-3">
|
|
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Profile</h3>
|
|
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
|
|
<Input label="Business Name" required value={form.name} onChange={(e) => set("name", e.target.value)} />
|
|
<Input label="Logo URL" placeholder="https://…" value={form.logoUrl} onChange={(e) => set("logoUrl", e.target.value)} />
|
|
<Textarea label="Description" value={form.description} onChange={(e) => set("description", e.target.value)} />
|
|
</div>
|
|
</section>
|
|
|
|
<section className="space-y-3">
|
|
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Contact & Address</h3>
|
|
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
|
|
<Input label="Street Address" required value={form.addressLine1} onChange={(e) => set("addressLine1", e.target.value)} />
|
|
<Input label="Address Line 2 (optional)" value={form.addressLine2} onChange={(e) => set("addressLine2", e.target.value)} />
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<Input label="City" required value={form.city} onChange={(e) => set("city", e.target.value)} />
|
|
<Input label="Postcode" required value={form.postalCode} onChange={(e) => set("postalCode", e.target.value)} />
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<Input label="Region (optional)" value={form.region} onChange={(e) => set("region", e.target.value)} />
|
|
<Input label="Country" value={form.country} onChange={(e) => set("country", e.target.value)} />
|
|
</div>
|
|
<Input label="Phone" type="tel" value={form.phone} onChange={(e) => set("phone", e.target.value)} />
|
|
<Input label="Email" type="email" value={form.email} onChange={(e) => set("email", e.target.value)} />
|
|
<Input label="Website" type="url" placeholder="https://" value={form.website} onChange={(e) => set("website", e.target.value)} />
|
|
</div>
|
|
</section>
|
|
|
|
<section className="space-y-3">
|
|
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Social Links</h3>
|
|
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
|
|
<Input label="Instagram" placeholder="@yourshop" value={form.instagramUrl} onChange={(e) => set("instagramUrl", e.target.value)} />
|
|
<Input label="Facebook" placeholder="facebook.com/yourshop" value={form.facebookUrl} onChange={(e) => set("facebookUrl", e.target.value)} />
|
|
</div>
|
|
</section>
|
|
|
|
<section className="space-y-3">
|
|
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Regional Settings</h3>
|
|
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
|
|
<Select label="Time Zone" value={form.timezone} onChange={(e) => set("timezone", e.target.value)}>
|
|
{TIMEZONES.map((tz) => (
|
|
<option key={tz} value={tz}>
|
|
{tz}
|
|
</option>
|
|
))}
|
|
</Select>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<Select label="Currency" value={form.currency} onChange={(e) => set("currency", e.target.value)}>
|
|
{CURRENCIES.map((c) => (
|
|
<option key={c} value={c}>
|
|
{c}
|
|
</option>
|
|
))}
|
|
</Select>
|
|
<Select label="Locale" value={form.locale} onChange={(e) => set("locale", e.target.value)}>
|
|
{LOCALES.map((l) => (
|
|
<option key={l} value={l}>
|
|
{l}
|
|
</option>
|
|
))}
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="space-y-3">
|
|
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Policies</h3>
|
|
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
|
|
<Textarea
|
|
label="Cancellation Policy"
|
|
value={form.cancellationPolicy}
|
|
onChange={(e) => set("cancellationPolicy", e.target.value)}
|
|
/>
|
|
<Textarea
|
|
label="Late Arrival Policy"
|
|
value={form.lateArrivalPolicy}
|
|
onChange={(e) => set("lateArrivalPolicy", e.target.value)}
|
|
/>
|
|
<Textarea label="Booking Terms" value={form.bookingTerms} onChange={(e) => set("bookingTerms", e.target.value)} />
|
|
<Input
|
|
label="Privacy Policy URL"
|
|
type="url"
|
|
placeholder="https://"
|
|
value={form.privacyPolicyUrl}
|
|
onChange={(e) => set("privacyPolicyUrl", e.target.value)}
|
|
/>
|
|
</div>
|
|
</section>
|
|
|
|
<div className="space-y-4 lg:col-span-2">
|
|
{error && (
|
|
<p role="alert" className="text-danger text-sm font-medium">
|
|
{error}
|
|
</p>
|
|
)}
|
|
{saved && !error && (
|
|
<p role="status" className="text-accent text-sm font-medium">
|
|
Saved — changes are live for customers immediately.
|
|
</p>
|
|
)}
|
|
<Button className="w-full" isLoading={isPending} onClick={handleSave}>
|
|
Save
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|