Files
NextChair/src/features/owner/components/business-details-form.tsx
T
Stefan Lazic ec5583f972 Phase 6
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.
2026-08-03 12:56:55 +01:00

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 &amp; 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>
);
}