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.
389 lines
17 KiB
TypeScript
389 lines
17 KiB
TypeScript
"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 & 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>
|
||
);
|
||
}
|