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.
This commit is contained in:
12 files changed
+143
-126
No files matched your search
@@ -10,6 +10,7 @@ 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";
|
||||
@@ -29,7 +30,7 @@ const FONT_OPTIONS: { value: LoyaltyCardFont; label: string }[] = [
|
||||
{ value: LoyaltyCardFont.MONO, label: "Monospace" },
|
||||
];
|
||||
|
||||
const TEXT_SIZE_OPTIONS: { value: LoyaltyCardTextSize; label: string }[] = [
|
||||
const TITLE_SIZE_OPTIONS: { value: LoyaltyCardTextSize; label: string }[] = [
|
||||
{ value: LoyaltyCardTextSize.SM, label: "Small" },
|
||||
{ value: LoyaltyCardTextSize.MD, label: "Medium" },
|
||||
{ value: LoyaltyCardTextSize.LG, label: "Large" },
|
||||
@@ -273,48 +274,62 @@ export function LoyaltyConfigForm({
|
||||
cardBackgroundImageUrl={config.cardBackgroundImageUrl}
|
||||
stampIcon={config.stampIcon}
|
||||
rewardPosition={config.rewardPosition}
|
||||
cardTopRightText={config.cardTopRightText}
|
||||
cardTitleText={config.cardTitleText}
|
||||
cardFont={config.cardFont}
|
||||
cardTopRightTextSize={config.cardTopRightTextSize}
|
||||
cardTopRightTextColor={config.cardTopRightTextColor}
|
||||
cardTitleTextSize={config.cardTitleTextSize}
|
||||
cardTitleTextColor={config.cardTitleTextColor}
|
||||
cardBorderColor={config.cardBorderColor}
|
||||
cardMemberNumberColor={config.cardMemberNumberColor}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 space-y-4 lg:mt-0">
|
||||
<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)}
|
||||
/>
|
||||
<IconPicker label="Stamp icon" value={config.stampIcon} onChange={(iconName) => set("stampIcon", iconName)} />
|
||||
<Input
|
||||
label="Terms line"
|
||||
value={config.termsLine ?? ""}
|
||||
onChange={(e) => set("termsLine", e.target.value)}
|
||||
placeholder="Valid for 12 months. One reward per visit."
|
||||
/>
|
||||
<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>
|
||||
|
||||
<div className="border-line border-t pt-4">
|
||||
<p className="mb-3 text-xs font-bold">Top-right card text</p>
|
||||
<div className="space-y-4">
|
||||
<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="Text"
|
||||
value={config.cardTopRightText ?? ""}
|
||||
onChange={(e) => set("cardTopRightText", e.target.value)}
|
||||
placeholder="e.g. VIP Member"
|
||||
hint="Shown above the member number. Leave blank to show only the member number, as today."
|
||||
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
|
||||
@@ -329,31 +344,26 @@ export function LoyaltyConfigForm({
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
label="Text size"
|
||||
value={config.cardTopRightTextSize}
|
||||
onChange={(e) => set("cardTopRightTextSize", e.target.value as ProgrammeConfigInput["cardTopRightTextSize"])}
|
||||
label="Title size"
|
||||
value={config.cardTitleTextSize}
|
||||
onChange={(e) => set("cardTitleTextSize", e.target.value as ProgrammeConfigInput["cardTitleTextSize"])}
|
||||
>
|
||||
{TEXT_SIZE_OPTIONS.map((s) => (
|
||||
{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 this text.</p>
|
||||
<ColorPicker
|
||||
label="Text colour"
|
||||
value={config.cardTopRightTextColor}
|
||||
onChange={(hex) => set("cardTopRightTextColor", hex)}
|
||||
<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."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ColorPicker
|
||||
label="Member number colour"
|
||||
value={config.cardMemberNumberColor}
|
||||
onChange={(hex) => set("cardMemberNumberColor", hex)}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
Reference in new issue
Block a user