"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(null); const [saved, setSaved] = useState(false); const [isPending, startTransition] = useTransition(); const set = (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 (
set("isEnabled", v)} />

Reward Rules

{ const next = Number(e.target.value); set("stampsRequired", Number.isFinite(next) ? Math.min(Math.max(next, 1), MAX_STAMPS_REQUIRED) : 1); }} />

Reward Timing

{( [ { 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 ( ); })}
{config.rewardType === "FREE_SERVICE" ? ( ) : ( set("rewardValue", e.target.value)} placeholder={config.rewardType === "PERCENTAGE" ? "50" : "10.00"} /> )} set("rewardLabel", e.target.value)} placeholder="e.g. 50% Off Any Service" /> set("maxRewardValue", e.target.value)} hint="Caps a percentage discount so it never exceeds this amount." />

Eligibility

Eligible services (none checked = all services earn stamps)

{services.map((s) => ( toggleInList(eligibleServiceIds, setEligibleServiceIds, s.id)} /> ))}

Eligible staff (none checked = all staff earn stamps)

{staff.map((s) => ( toggleInList(eligibleStaffIds, setEligibleStaffIds, s.id)} /> ))}

Rules & Expiry

set("autoApplyReward", v)} /> set("requiresStaffApproval", v)} /> set("earnOnCancelled", v)} /> set("earnOnNoShow", v)} />
set("stampsExpireAfterDays", e.target.value ? Number(e.target.value) : undefined)} /> set("rewardExpiresAfterDays", e.target.value ? Number(e.target.value) : undefined)} />

Card Appearance

Live Preview

Colours Stamp Text set("cardBackgroundColor", hex)} /> set("cardBackgroundImageUrl", url ?? undefined)} category="loyalty" /> set("cardBorderColor", hex)} /> set("cardTitleTextColor", hex)} /> set("cardMemberNumberColor", hex)} /> set("stampIcon", iconName)} /> 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.`} />

Font applies to the whole card, not just the title.

set("termsLine", e.target.value)} placeholder="Valid for 12 months. One reward per visit." />
{error && (

{error}

)} {saved && !error && (

Saved.

)}
); }