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.
This commit is contained in:
73 files changed
+8400
-53
No files matched your search
@@ -0,0 +1,216 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { updateLoyaltyConfigAction } from "../actions/loyalty-config";
|
||||
import type { ProgrammeConfigInput } from "@/services/loyalty/loyalty.service";
|
||||
|
||||
const REWARD_TYPES: { value: ProgrammeConfigInput["rewardType"]; label: string }[] = [
|
||||
{ value: "PERCENTAGE", label: "Percentage Discount" },
|
||||
{ value: "FIXED_AMOUNT", label: "Fixed-Value Discount" },
|
||||
{ value: "FREE_SERVICE", label: "Free Service" },
|
||||
];
|
||||
|
||||
export interface LoyaltyConfigFormProps {
|
||||
initialConfig: ProgrammeConfigInput;
|
||||
initialEligibleServiceIds: string[];
|
||||
initialEligibleStaffIds: string[];
|
||||
services: { id: string; name: string }[];
|
||||
staff: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
export function LoyaltyConfigForm({
|
||||
initialConfig,
|
||||
initialEligibleServiceIds,
|
||||
initialEligibleStaffIds,
|
||||
services,
|
||||
staff,
|
||||
}: LoyaltyConfigFormProps) {
|
||||
const router = useRouter();
|
||||
const [config, setConfig] = useState(initialConfig);
|
||||
const [eligibleServiceIds, setEligibleServiceIds] = useState(initialEligibleServiceIds);
|
||||
const [eligibleStaffIds, setEligibleStaffIds] = useState(initialEligibleStaffIds);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const set = <K extends keyof ProgrammeConfigInput>(key: K, value: ProgrammeConfigInput[K]) => {
|
||||
setSaved(false);
|
||||
setConfig((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const toggleInList = (list: string[], setList: (v: string[]) => void, id: string) => {
|
||||
setSaved(false);
|
||||
setList(list.includes(id) ? list.filter((x) => x !== id) : [...list, id]);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await updateLoyaltyConfigAction({ config, eligibleServiceIds, eligibleStaffIds });
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Couldn't save loyalty settings.");
|
||||
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="rounded-jumbo border-line border bg-white p-6 lg:col-span-2">
|
||||
<Switch
|
||||
label="Loyalty programme enabled"
|
||||
description="Turn off to stop earning/redeeming everywhere, without losing the configuration below."
|
||||
checked={config.isEnabled}
|
||||
onCheckedChange={(v) => set("isEnabled", v)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Reward Rules</h3>
|
||||
<div className="rounded-jumbo border-line space-y-5 border bg-white p-6">
|
||||
<Input
|
||||
label="Stamps required for reward"
|
||||
type="number"
|
||||
min={1}
|
||||
value={config.stampsRequired}
|
||||
onChange={(e) => set("stampsRequired", Number(e.target.value))}
|
||||
/>
|
||||
<Select label="Reward Type" value={config.rewardType} onChange={(e) => set("rewardType", e.target.value as ProgrammeConfigInput["rewardType"])}>
|
||||
{REWARD_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
{config.rewardType === "FREE_SERVICE" ? (
|
||||
<Select
|
||||
label="Free Service"
|
||||
value={config.freeServiceId ?? ""}
|
||||
onChange={(e) => set("freeServiceId", e.target.value)}
|
||||
>
|
||||
<option value="">Choose a service…</option>
|
||||
{services.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
label={config.rewardType === "PERCENTAGE" ? "Percentage off (1–100)" : "Fixed amount off"}
|
||||
value={config.rewardValue ?? ""}
|
||||
onChange={(e) => set("rewardValue", e.target.value)}
|
||||
placeholder={config.rewardType === "PERCENTAGE" ? "50" : "10.00"}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Input label="Reward Name" value={config.rewardLabel ?? ""} onChange={(e) => set("rewardLabel", e.target.value)} placeholder="e.g. 50% Off Any Service" />
|
||||
<Input
|
||||
label="Maximum reward value (optional)"
|
||||
value={config.maxRewardValue ?? ""}
|
||||
onChange={(e) => set("maxRewardValue", e.target.value)}
|
||||
hint="Caps a percentage discount so it never exceeds this amount."
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3 lg:col-span-2">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Eligibility</h3>
|
||||
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6 lg:grid lg:grid-cols-2 lg:gap-6 lg:space-y-0">
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-bold">Eligible services (none checked = all services earn stamps)</p>
|
||||
<div className="space-y-2">
|
||||
{services.map((s) => (
|
||||
<Checkbox
|
||||
key={s.id}
|
||||
label={s.name}
|
||||
checked={eligibleServiceIds.includes(s.id)}
|
||||
onChange={() => toggleInList(eligibleServiceIds, setEligibleServiceIds, s.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-line border-t pt-4 lg:border-t-0 lg:border-l lg:pt-0 lg:pl-6">
|
||||
<p className="mb-2 text-xs font-bold">Eligible staff (none checked = all staff earn stamps)</p>
|
||||
<div className="space-y-2">
|
||||
{staff.map((s) => (
|
||||
<Checkbox
|
||||
key={s.id}
|
||||
label={s.name}
|
||||
checked={eligibleStaffIds.includes(s.id)}
|
||||
onChange={() => toggleInList(eligibleStaffIds, setEligibleStaffIds, s.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Rules & Expiry</h3>
|
||||
<div className="rounded-jumbo border-line space-y-5 border bg-white p-6">
|
||||
<Switch label="Apply reward automatically" checked={config.autoApplyReward} onCheckedChange={(v) => set("autoApplyReward", v)} />
|
||||
<Switch
|
||||
label="Require staff approval to redeem"
|
||||
description="Redemption already requires a permitted staff member — this is informational only this phase."
|
||||
checked={config.requiresStaffApproval}
|
||||
onCheckedChange={(v) => set("requiresStaffApproval", v)}
|
||||
/>
|
||||
<Switch label="Earn stamps on cancelled appointments" checked={config.earnOnCancelled} onCheckedChange={(v) => set("earnOnCancelled", v)} />
|
||||
<Switch label="Earn stamps on no-shows" checked={config.earnOnNoShow} onCheckedChange={(v) => set("earnOnNoShow", v)} />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Stamps expire after (days, optional)"
|
||||
type="number"
|
||||
min={1}
|
||||
value={config.stampsExpireAfterDays ?? ""}
|
||||
onChange={(e) => set("stampsExpireAfterDays", e.target.value ? Number(e.target.value) : undefined)}
|
||||
/>
|
||||
<Input
|
||||
label="Reward expires after (days, optional)"
|
||||
type="number"
|
||||
min={1}
|
||||
value={config.rewardExpiresAfterDays ?? ""}
|
||||
onChange={(e) => set("rewardExpiresAfterDays", e.target.value ? Number(e.target.value) : undefined)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Card Appearance</h3>
|
||||
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
|
||||
<Input label="Card background color" value={config.cardBackgroundColor ?? ""} onChange={(e) => set("cardBackgroundColor", e.target.value)} placeholder="#111b15" />
|
||||
<Input label="Card background image URL (optional)" value={config.cardBackgroundImageUrl ?? ""} onChange={(e) => set("cardBackgroundImageUrl", e.target.value)} />
|
||||
<Input label="Stamp icon" value={config.stampIcon ?? ""} onChange={(e) => set("stampIcon", e.target.value)} placeholder="scissors" />
|
||||
<Input label="Terms line" value={config.termsLine ?? ""} onChange={(e) => set("termsLine", e.target.value)} placeholder="Valid for 12 months. One reward per visit." />
|
||||
</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.
|
||||
</p>
|
||||
)}
|
||||
<Button className="w-full" isLoading={isPending} onClick={handleSave}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user