Files
NextChair/src/features/owner/components/loyalty-config-form.tsx
T
Stefan Lazic cf3a28f3cd Loyalty Card updates
1. Card title fix: The "top-right text" feature was renamed and repositioned to be the actual card title (top-left, replacing the hardcoded "Loyalty Card"), matching what you asked for. This included a proper database migration (cardTopRightText → cardTitleText, etc., using CHANGE COLUMN so any already-configured value is preserved, not dropped) and updates through every layer — schema, service, owner form, and both the owner preview and customer-facing card. Verified live: typed "VIP Rewards" into the new "Card title" field and confirmed it renders top-left on both the owner's live preview and the real customer loyalty page, with the member number staying in its own badge top-right.

2. Tabs: The long "Card Appearance" control list is now split into Colours / Stamp / Text tabs (using the same Tabs component used elsewhere in the app), exactly as you suggested — Colours holds every colour + the background image, Stamp holds just the icon picker, Text holds the title, font, size, and terms line.

Along the way, while running full E2E regression, I found 6 test failures — but traced them via git stash to prove they're pre-existing bugs unrelated to this change (a booking/appointments-list bug), so I reverted-then-confirmed before restoring my work, and filed a separate follow-up task for them rather than mixing it into this change.

Both fixes are typechecked, linted, verified with the full unit (253) and integration (185) suites, confirmed live in a real browser end-to-end, and deployed to your running Docker container — the migration applied cleanly against your real database with no data loss.
2026-08-05 12:19:08 +01:00

389 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 { ColorPicker } from "@/components/ui/color-picker";
import { IconPicker } from "@/components/ui/icon-picker";
import { Input } from "@/components/ui/input";
import { LoyaltyCard } from "@/components/ui/loyalty-card";
import { Select } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { MediaPicker } from "@/features/media/components/media-picker";
import { LoyaltyCardFont, LoyaltyCardTextSize, RewardPositionMode } from "@/generated/prisma/enums";
import { cn } from "@/lib/utils/cn";
import { MAX_STAMPS_REQUIRED, resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
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" },
];
const FONT_OPTIONS: { value: LoyaltyCardFont; label: string }[] = [
{ value: LoyaltyCardFont.SANS, label: "Sans Serif" },
{ value: LoyaltyCardFont.SERIF, label: "Serif" },
{ value: LoyaltyCardFont.MONO, label: "Monospace" },
];
const TITLE_SIZE_OPTIONS: { value: LoyaltyCardTextSize; label: string }[] = [
{ value: LoyaltyCardTextSize.SM, label: "Small" },
{ value: LoyaltyCardTextSize.MD, label: "Medium" },
{ value: LoyaltyCardTextSize.LG, label: "Large" },
];
export interface LoyaltyConfigFormProps {
businessName: string;
initialConfig: ProgrammeConfigInput;
initialEligibleServiceIds: string[];
initialEligibleStaffIds: string[];
services: { id: string; name: string }[];
staff: { id: string; name: string }[];
}
export function LoyaltyConfigForm({
businessName,
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-md 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-md border-line space-y-5 border bg-white p-6">
<Input
label={`Stamps required for reward (1–${MAX_STAMPS_REQUIRED})`}
type="number"
min={1}
max={MAX_STAMPS_REQUIRED}
value={config.stampsRequired}
onChange={(e) => {
const next = Number(e.target.value);
set("stampsRequired", Number.isFinite(next) ? Math.min(Math.max(next, 1), MAX_STAMPS_REQUIRED) : 1);
}}
/>
<div>
<p className="mb-2 ml-1 text-[10px] font-bold tracking-widest uppercase text-muted">Reward Timing</p>
<div className="space-y-2">
{(
[
{
value: RewardPositionMode.AFTER_ALL_STAMPS,
title: "Reward after all stamps",
description: `Collect all ${config.stampsRequired} stamps, then the reward is given as a separate perk — every position on the card is a normal stamp.`,
},
{
value: RewardPositionMode.FINAL_SLOT_IS_REWARD,
title: "Final position is the reward",
description: `Collect ${resolveRewardThreshold({ stampsRequired: config.stampsRequired, rewardPosition: RewardPositionMode.FINAL_SLOT_IS_REWARD })} stamps — the last position on the card is the reward itself, marked visually, not a stamp to fill.`,
},
] as const
).map((option) => {
const selected = config.rewardPosition === option.value;
return (
<button
key={option.value}
type="button"
onClick={() => set("rewardPosition", option.value)}
aria-pressed={selected}
className={cn(
"w-full rounded-xl border p-4 text-left transition-colors",
selected ? "border-accent-loyalty bg-accent-loyalty/10" : "border-line bg-white",
)}
>
<p className="text-sm font-bold">{option.title}</p>
<p className="text-muted mt-1 text-xs">{option.description}</p>
</button>
);
})}
</div>
</div>
<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-md 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 &amp; Expiry</h3>
<div className="rounded-md border-line space-y-5 border bg-white p-6">
<Switch
label="Apply reward automatically"
description="Redemption is always staff-initiated this phase — this setting is saved but has no automatic effect yet."
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 lg:col-span-2">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Card Appearance</h3>
<div className="rounded-md border-line space-y-4 border bg-white p-6 lg:grid lg:grid-cols-[minmax(0,320px)_1fr] lg:gap-8 lg:space-y-0">
<div>
<p className="text-muted mb-2 ml-1 text-[10px] font-bold tracking-widest uppercase">Live Preview</p>
<LoyaltyCard
businessName={businessName}
memberNumber="A1B2C3"
stampsRequired={Math.max(config.stampsRequired, 1)}
stampsEarned={Math.max(
resolveRewardThreshold({ stampsRequired: Math.max(config.stampsRequired, 1), rewardPosition: config.rewardPosition }) - 1,
0,
)}
rewardLabel={config.rewardLabel || "Reward"}
termsLine={config.termsLine}
cardBackgroundColor={config.cardBackgroundColor}
cardBackgroundImageUrl={config.cardBackgroundImageUrl}
stampIcon={config.stampIcon}
rewardPosition={config.rewardPosition}
cardTitleText={config.cardTitleText}
cardFont={config.cardFont}
cardTitleTextSize={config.cardTitleTextSize}
cardTitleTextColor={config.cardTitleTextColor}
cardBorderColor={config.cardBorderColor}
cardMemberNumberColor={config.cardMemberNumberColor}
/>
</div>
<div className="mt-4 lg:mt-0">
<Tabs defaultValue="colours">
<TabsList>
<TabsTrigger value="colours">Colours</TabsTrigger>
<TabsTrigger value="stamp">Stamp</TabsTrigger>
<TabsTrigger value="text">Text</TabsTrigger>
</TabsList>
<TabsContent value="colours" className="space-y-4">
<ColorPicker
label="Background colour"
value={config.cardBackgroundColor}
onChange={(hex) => set("cardBackgroundColor", hex)}
/>
<MediaPicker
label="Background image (optional)"
value={config.cardBackgroundImageUrl}
onChange={(url) => set("cardBackgroundImageUrl", url ?? undefined)}
category="loyalty"
/>
<ColorPicker
label="Border colour"
value={config.cardBorderColor}
onChange={(hex) => set("cardBorderColor", hex)}
/>
<ColorPicker
label="Title text colour"
value={config.cardTitleTextColor}
onChange={(hex) => set("cardTitleTextColor", hex)}
/>
<ColorPicker
label="Member number colour"
value={config.cardMemberNumberColor}
onChange={(hex) => set("cardMemberNumberColor", hex)}
/>
</TabsContent>
<TabsContent value="stamp" className="space-y-4">
<IconPicker label="Stamp icon" value={config.stampIcon} onChange={(iconName) => set("stampIcon", iconName)} />
</TabsContent>
<TabsContent value="text" className="space-y-4">
<Input
label="Card title"
value={config.cardTitleText ?? ""}
onChange={(e) => set("cardTitleText", e.target.value)}
placeholder="Loyalty Card"
hint={`Shown top-left on the card, in place of the default "Loyalty Card". Leave blank to keep the default.`}
/>
<div className="grid grid-cols-2 gap-4">
<Select
label="Font"
value={config.cardFont}
onChange={(e) => set("cardFont", e.target.value as ProgrammeConfigInput["cardFont"])}
>
{FONT_OPTIONS.map((f) => (
<option key={f.value} value={f.value}>
{f.label}
</option>
))}
</Select>
<Select
label="Title size"
value={config.cardTitleTextSize}
onChange={(e) => set("cardTitleTextSize", e.target.value as ProgrammeConfigInput["cardTitleTextSize"])}
>
{TITLE_SIZE_OPTIONS.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</Select>
</div>
<p className="text-muted -mt-2 text-[10px]">Font applies to the whole card, not just the title.</p>
<Input
label="Terms line"
value={config.termsLine ?? ""}
onChange={(e) => set("termsLine", e.target.value)}
placeholder="Valid for 12 months. One reward per visit."
/>
</TabsContent>
</Tabs>
</div>
</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-foreground text-sm font-medium">
Saved.
</p>
)}
<Button className="w-full" isLoading={isPending} onClick={handleSave}>
Save
</Button>
</div>
</div>
);
}