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
@@ -0,0 +1,9 @@
|
|||||||
|
-- Phase 11: the loyalty card's owner-configurable text now replaces the
|
||||||
|
-- hardcoded "Loyalty Card" title (top-left), not a secondary badge next to
|
||||||
|
-- the member number (top-right) — renamed to match, using CHANGE COLUMN so
|
||||||
|
-- any value an owner already configured (e.g. a custom title) survives the
|
||||||
|
-- rename instead of being dropped.
|
||||||
|
ALTER TABLE `LoyaltyProgramme`
|
||||||
|
CHANGE COLUMN `cardTopRightText` `cardTitleText` VARCHAR(191) NULL,
|
||||||
|
CHANGE COLUMN `cardTopRightTextSize` `cardTitleTextSize` ENUM('SM', 'MD', 'LG') NOT NULL DEFAULT 'MD',
|
||||||
|
CHANGE COLUMN `cardTopRightTextColor` `cardTitleTextColor` VARCHAR(191) NULL;
|
||||||
+12
-13
@@ -1214,19 +1214,18 @@ model LoyaltyProgramme {
|
|||||||
stampIcon String?
|
stampIcon String?
|
||||||
termsLine String?
|
termsLine String?
|
||||||
|
|
||||||
/// Phase 10 — free text shown in the card's top-right area, above the
|
/// Phase 11 — replaces the hardcoded "Loyalty Card" title shown top-left
|
||||||
/// member number. Separate from the member number itself (always
|
/// on the card. Null/empty falls back to "Loyalty Card", same as before
|
||||||
/// shown, has its own colour below) — null/empty means nothing renders
|
/// this was configurable.
|
||||||
/// there beyond the member number, same as today.
|
cardTitleText String?
|
||||||
cardTopRightText String?
|
/// Card-wide — applies to all card text, not just the title. See
|
||||||
/// Card-wide — applies to all card text, not just the top-right text.
|
/// LoyaltyCardFont's doc comment for why this is card-wide rather than a
|
||||||
/// See LoyaltyCardFont's doc comment for why this is card-wide rather
|
/// control per element.
|
||||||
/// than a control per element.
|
cardFont LoyaltyCardFont @default(SANS)
|
||||||
cardFont LoyaltyCardFont @default(SANS)
|
cardTitleTextSize LoyaltyCardTextSize @default(MD)
|
||||||
cardTopRightTextSize LoyaltyCardTextSize @default(MD)
|
cardTitleTextColor String?
|
||||||
cardTopRightTextColor String?
|
cardBorderColor String?
|
||||||
cardBorderColor String?
|
cardMemberNumberColor String?
|
||||||
cardMemberNumberColor String?
|
|
||||||
|
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
|||||||
@@ -143,17 +143,17 @@ export default async function CustomerHomePage() {
|
|||||||
<div>
|
<div>
|
||||||
{/* Hero — an owner-managed gallery (see /owner/settings/gallery), matching the content width rather than bleeding edge-to-edge. */}
|
{/* Hero — an owner-managed gallery (see /owner/settings/gallery), matching the content width rather than bleeding edge-to-edge. */}
|
||||||
{heroGalleryImages.length > 0 && (
|
{heroGalleryImages.length > 0 && (
|
||||||
<div className="mx-auto max-w-2xl px-6 pt-4 bg-white">
|
<div className="mx-auto max-w-2xl px-6 pt-4 bg-white border-line border-b">
|
||||||
<ImageGallery images={heroGalleryImages} />
|
<ImageGallery images={heroGalleryImages} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<main className="mx-auto max-w-2xl bg-white px-6 py-6">
|
<main className="mx-auto max-w-2xl bg-white px-6 py-6">
|
||||||
{session?.user && (
|
{/* {session?.user && (
|
||||||
<p className="text-muted mb-1 text-sm">
|
<p className="text-muted mb-1 text-sm">
|
||||||
Good to see you, {session.user.name?.split(" ")[0] ?? "there"}
|
Good to see you, {session.user.name?.split(" ")[0] ?? "there"}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)} */}
|
||||||
<h1 className="mb-1 text-2xl font-bold tracking-tight">{business.name}</h1>
|
<h1 className="mb-1 text-2xl font-bold tracking-tight">{business.name}</h1>
|
||||||
{location && (
|
{location && (
|
||||||
<p className="text-muted mb-3 text-xs">
|
<p className="text-muted mb-3 text-xs">
|
||||||
@@ -176,9 +176,11 @@ export default async function CustomerHomePage() {
|
|||||||
{/* Upcoming appointment */}
|
{/* Upcoming appointment */}
|
||||||
{upcomingAppointment && (
|
{upcomingAppointment && (
|
||||||
<section className="mb-6">
|
<section className="mb-6">
|
||||||
|
{session?.user && (
|
||||||
<h2 className="text-muted mb-3 text-xs font-bold tracking-widest uppercase">
|
<h2 className="text-muted mb-3 text-xs font-bold tracking-widest uppercase">
|
||||||
Your Next Visit
|
{session.user.name?.split(" ")[0] ?? "Your Next Appointment"}, Your Next Appointment
|
||||||
</h2>
|
</h2>
|
||||||
|
)}
|
||||||
<AppointmentCard
|
<AppointmentCard
|
||||||
status={STATUS_MAP[upcomingAppointment.status] ?? "confirmed"}
|
status={STATUS_MAP[upcomingAppointment.status] ?? "confirmed"}
|
||||||
serviceName={upcomingAppointment.services.map((s) => s.service.name).join(" + ")}
|
serviceName={upcomingAppointment.services.map((s) => s.service.name).join(" + ")}
|
||||||
|
|||||||
@@ -86,10 +86,10 @@ export default async function LoyaltyPage() {
|
|||||||
cardBackgroundImageUrl={loyalty.cardBackgroundImageUrl}
|
cardBackgroundImageUrl={loyalty.cardBackgroundImageUrl}
|
||||||
stampIcon={loyalty.stampIcon}
|
stampIcon={loyalty.stampIcon}
|
||||||
rewardPosition={loyalty.rewardPosition}
|
rewardPosition={loyalty.rewardPosition}
|
||||||
cardTopRightText={loyalty.cardTopRightText}
|
cardTitleText={loyalty.cardTitleText}
|
||||||
cardFont={loyalty.cardFont}
|
cardFont={loyalty.cardFont}
|
||||||
cardTopRightTextSize={loyalty.cardTopRightTextSize}
|
cardTitleTextSize={loyalty.cardTitleTextSize}
|
||||||
cardTopRightTextColor={loyalty.cardTopRightTextColor}
|
cardTitleTextColor={loyalty.cardTitleTextColor}
|
||||||
cardBorderColor={loyalty.cardBorderColor}
|
cardBorderColor={loyalty.cardBorderColor}
|
||||||
cardMemberNumberColor={loyalty.cardMemberNumberColor}
|
cardMemberNumberColor={loyalty.cardMemberNumberColor}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -51,10 +51,10 @@ export default async function OwnerLoyaltyConfigPage() {
|
|||||||
cardBackgroundImageUrl: programme.cardBackgroundImageUrl ?? undefined,
|
cardBackgroundImageUrl: programme.cardBackgroundImageUrl ?? undefined,
|
||||||
stampIcon: programme.stampIcon ?? undefined,
|
stampIcon: programme.stampIcon ?? undefined,
|
||||||
termsLine: programme.termsLine ?? undefined,
|
termsLine: programme.termsLine ?? undefined,
|
||||||
cardTopRightText: programme.cardTopRightText ?? undefined,
|
cardTitleText: programme.cardTitleText ?? undefined,
|
||||||
cardFont: programme.cardFont,
|
cardFont: programme.cardFont,
|
||||||
cardTopRightTextSize: programme.cardTopRightTextSize,
|
cardTitleTextSize: programme.cardTitleTextSize,
|
||||||
cardTopRightTextColor: programme.cardTopRightTextColor ?? undefined,
|
cardTitleTextColor: programme.cardTitleTextColor ?? undefined,
|
||||||
cardBorderColor: programme.cardBorderColor ?? undefined,
|
cardBorderColor: programme.cardBorderColor ?? undefined,
|
||||||
cardMemberNumberColor: programme.cardMemberNumberColor ?? undefined,
|
cardMemberNumberColor: programme.cardMemberNumberColor ?? undefined,
|
||||||
}
|
}
|
||||||
@@ -68,7 +68,7 @@ export default async function OwnerLoyaltyConfigPage() {
|
|||||||
earnOnCancelled: false,
|
earnOnCancelled: false,
|
||||||
earnOnNoShow: false,
|
earnOnNoShow: false,
|
||||||
cardFont: LoyaltyCardFont.SANS,
|
cardFont: LoyaltyCardFont.SANS,
|
||||||
cardTopRightTextSize: LoyaltyCardTextSize.MD,
|
cardTitleTextSize: LoyaltyCardTextSize.MD,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -13,10 +13,11 @@ const FONT_CLASSES: Record<LoyaltyCardFont, string> = {
|
|||||||
[LoyaltyCardFont.MONO]: "font-mono",
|
[LoyaltyCardFont.MONO]: "font-mono",
|
||||||
};
|
};
|
||||||
|
|
||||||
const TOP_RIGHT_TEXT_SIZE_CLASSES: Record<LoyaltyCardTextSize, string> = {
|
/** MD matches the size "Loyalty Card" rendered at before this was configurable, so leaving this unset doesn't change the default look. */
|
||||||
[LoyaltyCardTextSize.SM]: "text-[8px]",
|
const TITLE_TEXT_SIZE_CLASSES: Record<LoyaltyCardTextSize, string> = {
|
||||||
[LoyaltyCardTextSize.MD]: "text-[10px]",
|
[LoyaltyCardTextSize.SM]: "text-base",
|
||||||
[LoyaltyCardTextSize.LG]: "text-[13px]",
|
[LoyaltyCardTextSize.MD]: "text-lg",
|
||||||
|
[LoyaltyCardTextSize.LG]: "text-2xl",
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface LoyaltyCardProps {
|
export interface LoyaltyCardProps {
|
||||||
@@ -32,10 +33,11 @@ export interface LoyaltyCardProps {
|
|||||||
stampIcon?: string | null;
|
stampIcon?: string | null;
|
||||||
/** See resolveRewardThreshold's doc comment — determines whether the final stamp position renders as a normal stamp or as the reward slot itself. */
|
/** See resolveRewardThreshold's doc comment — determines whether the final stamp position renders as a normal stamp or as the reward slot itself. */
|
||||||
rewardPosition?: RewardPositionMode;
|
rewardPosition?: RewardPositionMode;
|
||||||
cardTopRightText?: string | null;
|
/** Replaces the "Loyalty Card" title (top-left) when set. */
|
||||||
|
cardTitleText?: string | null;
|
||||||
cardFont?: LoyaltyCardFont;
|
cardFont?: LoyaltyCardFont;
|
||||||
cardTopRightTextSize?: LoyaltyCardTextSize;
|
cardTitleTextSize?: LoyaltyCardTextSize;
|
||||||
cardTopRightTextColor?: string | null;
|
cardTitleTextColor?: string | null;
|
||||||
cardBorderColor?: string | null;
|
cardBorderColor?: string | null;
|
||||||
cardMemberNumberColor?: string | null;
|
cardMemberNumberColor?: string | null;
|
||||||
}
|
}
|
||||||
@@ -63,10 +65,10 @@ export function LoyaltyCard({
|
|||||||
cardBackgroundImageUrl,
|
cardBackgroundImageUrl,
|
||||||
stampIcon,
|
stampIcon,
|
||||||
rewardPosition = RewardPositionMode.AFTER_ALL_STAMPS,
|
rewardPosition = RewardPositionMode.AFTER_ALL_STAMPS,
|
||||||
cardTopRightText,
|
cardTitleText,
|
||||||
cardFont = LoyaltyCardFont.SANS,
|
cardFont = LoyaltyCardFont.SANS,
|
||||||
cardTopRightTextSize = LoyaltyCardTextSize.MD,
|
cardTitleTextSize = LoyaltyCardTextSize.MD,
|
||||||
cardTopRightTextColor,
|
cardTitleTextColor,
|
||||||
cardBorderColor,
|
cardBorderColor,
|
||||||
cardMemberNumberColor,
|
cardMemberNumberColor,
|
||||||
}: LoyaltyCardProps) {
|
}: LoyaltyCardProps) {
|
||||||
@@ -111,23 +113,18 @@ export function LoyaltyCard({
|
|||||||
|
|
||||||
<div className="relative flex h-full flex-col justify-between p-4">
|
<div className="relative flex h-full flex-col justify-between p-4">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<h2 className="text-lg font-black italic tracking-tight text-white">Loyalty Card</h2>
|
<h2
|
||||||
<div className="flex flex-col items-end gap-1">
|
className={cn(TITLE_TEXT_SIZE_CLASSES[cardTitleTextSize], "font-black italic tracking-tight text-white")}
|
||||||
{cardTopRightText && (
|
style={{ color: cardTitleTextColor || undefined }}
|
||||||
<span
|
>
|
||||||
className={cn(TOP_RIGHT_TEXT_SIZE_CLASSES[cardTopRightTextSize], "text-right font-bold text-white")}
|
{cardTitleText || "Loyalty Card"}
|
||||||
style={{ color: cardTopRightTextColor || undefined }}
|
</h2>
|
||||||
>
|
<span
|
||||||
{cardTopRightText}
|
className="border-accent-loyalty/30 text-accent-loyalty shrink-0 rounded border px-2 py-0.5 text-[8px] font-bold tracking-widest uppercase"
|
||||||
</span>
|
style={{ color: cardMemberNumberColor || undefined, borderColor: cardMemberNumberColor ? `${cardMemberNumberColor}4d` : undefined }}
|
||||||
)}
|
>
|
||||||
<span
|
Member #{memberNumber}
|
||||||
className="border-accent-loyalty/30 text-accent-loyalty rounded border px-2 py-0.5 text-[8px] font-bold tracking-widest uppercase"
|
</span>
|
||||||
style={{ color: cardMemberNumberColor || undefined, borderColor: cardMemberNumberColor ? `${cardMemberNumberColor}4d` : undefined }}
|
|
||||||
>
|
|
||||||
Member #{memberNumber}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-5 gap-2">
|
<div className="grid grid-cols-5 gap-2">
|
||||||
{Array.from({ length: stampsRequired }, (_, i) => {
|
{Array.from({ length: stampsRequired }, (_, i) => {
|
||||||
@@ -161,7 +158,7 @@ export function LoyaltyCard({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{termsLine && (
|
{termsLine && (
|
||||||
<p className="text-center text-[7px] mt-2 font-bold tracking-widest text-white/40 uppercase">{termsLine}</p>
|
<p className="mt-2 text-center text-[7px] font-bold tracking-widest text-white/40 uppercase">{termsLine}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export const Tabs = TabsPrimitive.Root;
|
|||||||
export function TabsList({ className, ...props }: TabsPrimitive.TabsListProps) {
|
export function TabsList({ className, ...props }: TabsPrimitive.TabsListProps) {
|
||||||
return (
|
return (
|
||||||
<TabsPrimitive.List
|
<TabsPrimitive.List
|
||||||
className={cn("flex gap-2 rounded-full bg-stone p-1", className)}
|
className={cn("flex gap-1 rounded-full bg-white p-1", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -24,7 +24,7 @@ export function TabsTrigger({ className, ...props }: TabsPrimitive.TabsTriggerPr
|
|||||||
return (
|
return (
|
||||||
<TabsPrimitive.Trigger
|
<TabsPrimitive.Trigger
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 rounded-full px-4 py-2.5 text-xs font-bold uppercase tracking-widest text-muted transition-all",
|
"flex-1 rounded-md px-3 py-2.5 text-xs font-bold uppercase text-muted transition-all",
|
||||||
"data-[state=active]:bg-ink data-[state=active]:text-white",
|
"data-[state=active]:bg-ink data-[state=active]:text-white",
|
||||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-stone",
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-stone",
|
||||||
className,
|
className,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { LoyaltyCard } from "@/components/ui/loyalty-card";
|
import { LoyaltyCard } from "@/components/ui/loyalty-card";
|
||||||
import { Select } from "@/components/ui/select";
|
import { Select } from "@/components/ui/select";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { MediaPicker } from "@/features/media/components/media-picker";
|
import { MediaPicker } from "@/features/media/components/media-picker";
|
||||||
import { LoyaltyCardFont, LoyaltyCardTextSize, RewardPositionMode } from "@/generated/prisma/enums";
|
import { LoyaltyCardFont, LoyaltyCardTextSize, RewardPositionMode } from "@/generated/prisma/enums";
|
||||||
import { cn } from "@/lib/utils/cn";
|
import { cn } from "@/lib/utils/cn";
|
||||||
@@ -29,7 +30,7 @@ const FONT_OPTIONS: { value: LoyaltyCardFont; label: string }[] = [
|
|||||||
{ value: LoyaltyCardFont.MONO, label: "Monospace" },
|
{ 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.SM, label: "Small" },
|
||||||
{ value: LoyaltyCardTextSize.MD, label: "Medium" },
|
{ value: LoyaltyCardTextSize.MD, label: "Medium" },
|
||||||
{ value: LoyaltyCardTextSize.LG, label: "Large" },
|
{ value: LoyaltyCardTextSize.LG, label: "Large" },
|
||||||
@@ -273,48 +274,62 @@ export function LoyaltyConfigForm({
|
|||||||
cardBackgroundImageUrl={config.cardBackgroundImageUrl}
|
cardBackgroundImageUrl={config.cardBackgroundImageUrl}
|
||||||
stampIcon={config.stampIcon}
|
stampIcon={config.stampIcon}
|
||||||
rewardPosition={config.rewardPosition}
|
rewardPosition={config.rewardPosition}
|
||||||
cardTopRightText={config.cardTopRightText}
|
cardTitleText={config.cardTitleText}
|
||||||
cardFont={config.cardFont}
|
cardFont={config.cardFont}
|
||||||
cardTopRightTextSize={config.cardTopRightTextSize}
|
cardTitleTextSize={config.cardTitleTextSize}
|
||||||
cardTopRightTextColor={config.cardTopRightTextColor}
|
cardTitleTextColor={config.cardTitleTextColor}
|
||||||
cardBorderColor={config.cardBorderColor}
|
cardBorderColor={config.cardBorderColor}
|
||||||
cardMemberNumberColor={config.cardMemberNumberColor}
|
cardMemberNumberColor={config.cardMemberNumberColor}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 space-y-4 lg:mt-0">
|
<div className="mt-4 lg:mt-0">
|
||||||
<ColorPicker
|
<Tabs defaultValue="colours">
|
||||||
label="Background colour"
|
<TabsList>
|
||||||
value={config.cardBackgroundColor}
|
<TabsTrigger value="colours">Colours</TabsTrigger>
|
||||||
onChange={(hex) => set("cardBackgroundColor", hex)}
|
<TabsTrigger value="stamp">Stamp</TabsTrigger>
|
||||||
/>
|
<TabsTrigger value="text">Text</TabsTrigger>
|
||||||
<MediaPicker
|
</TabsList>
|
||||||
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="border-line border-t pt-4">
|
<TabsContent value="colours" className="space-y-4">
|
||||||
<p className="mb-3 text-xs font-bold">Top-right card text</p>
|
<ColorPicker
|
||||||
<div className="space-y-4">
|
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
|
<Input
|
||||||
label="Text"
|
label="Card title"
|
||||||
value={config.cardTopRightText ?? ""}
|
value={config.cardTitleText ?? ""}
|
||||||
onChange={(e) => set("cardTopRightText", e.target.value)}
|
onChange={(e) => set("cardTitleText", e.target.value)}
|
||||||
placeholder="e.g. VIP Member"
|
placeholder="Loyalty Card"
|
||||||
hint="Shown above the member number. Leave blank to show only the member number, as today."
|
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">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Select
|
<Select
|
||||||
@@ -329,31 +344,26 @@ export function LoyaltyConfigForm({
|
|||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
<Select
|
<Select
|
||||||
label="Text size"
|
label="Title size"
|
||||||
value={config.cardTopRightTextSize}
|
value={config.cardTitleTextSize}
|
||||||
onChange={(e) => set("cardTopRightTextSize", e.target.value as ProgrammeConfigInput["cardTopRightTextSize"])}
|
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}>
|
<option key={s.value} value={s.value}>
|
||||||
{s.label}
|
{s.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted -mt-2 text-[10px]">Font applies to the whole card, not just this text.</p>
|
<p className="text-muted -mt-2 text-[10px]">Font applies to the whole card, not just the title.</p>
|
||||||
<ColorPicker
|
<Input
|
||||||
label="Text colour"
|
label="Terms line"
|
||||||
value={config.cardTopRightTextColor}
|
value={config.termsLine ?? ""}
|
||||||
onChange={(hex) => set("cardTopRightTextColor", hex)}
|
onChange={(e) => set("termsLine", e.target.value)}
|
||||||
|
placeholder="Valid for 12 months. One reward per visit."
|
||||||
/>
|
/>
|
||||||
</div>
|
</TabsContent>
|
||||||
</div>
|
</Tabs>
|
||||||
|
|
||||||
<ColorPicker
|
|
||||||
label="Member number colour"
|
|
||||||
value={config.cardMemberNumberColor}
|
|
||||||
onChange={(hex) => set("cardMemberNumberColor", hex)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const base: ProgrammeConfigInput = {
|
|||||||
earnOnCancelled: false,
|
earnOnCancelled: false,
|
||||||
earnOnNoShow: false,
|
earnOnNoShow: false,
|
||||||
cardFont: LoyaltyCardFont.SANS,
|
cardFont: LoyaltyCardFont.SANS,
|
||||||
cardTopRightTextSize: LoyaltyCardTextSize.MD,
|
cardTitleTextSize: LoyaltyCardTextSize.MD,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("validateProgrammeConfig", () => {
|
describe("validateProgrammeConfig", () => {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ describe("LoyaltyService — owner configuration and redemption (integration)",
|
|||||||
earnOnCancelled: false,
|
earnOnCancelled: false,
|
||||||
earnOnNoShow: false,
|
earnOnNoShow: false,
|
||||||
cardFont: LoyaltyCardFont.SANS,
|
cardFont: LoyaltyCardFont.SANS,
|
||||||
cardTopRightTextSize: LoyaltyCardTextSize.MD,
|
cardTitleTextSize: LoyaltyCardTextSize.MD,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(first.success).toBe(true);
|
expect(first.success).toBe(true);
|
||||||
@@ -63,7 +63,7 @@ describe("LoyaltyService — owner configuration and redemption (integration)",
|
|||||||
earnOnCancelled: false,
|
earnOnCancelled: false,
|
||||||
earnOnNoShow: false,
|
earnOnNoShow: false,
|
||||||
cardFont: LoyaltyCardFont.SANS,
|
cardFont: LoyaltyCardFont.SANS,
|
||||||
cardTopRightTextSize: LoyaltyCardTextSize.MD,
|
cardTitleTextSize: LoyaltyCardTextSize.MD,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(second.success).toBe(true);
|
expect(second.success).toBe(true);
|
||||||
@@ -92,7 +92,7 @@ describe("LoyaltyService — owner configuration and redemption (integration)",
|
|||||||
earnOnCancelled: false,
|
earnOnCancelled: false,
|
||||||
earnOnNoShow: false,
|
earnOnNoShow: false,
|
||||||
cardFont: LoyaltyCardFont.SANS,
|
cardFont: LoyaltyCardFont.SANS,
|
||||||
cardTopRightTextSize: LoyaltyCardTextSize.MD,
|
cardTitleTextSize: LoyaltyCardTextSize.MD,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
|
|||||||
@@ -56,10 +56,10 @@ export interface LoyaltySummary {
|
|||||||
cardBackgroundColor: string | null;
|
cardBackgroundColor: string | null;
|
||||||
cardBackgroundImageUrl: string | null;
|
cardBackgroundImageUrl: string | null;
|
||||||
termsLine: string | null;
|
termsLine: string | null;
|
||||||
cardTopRightText: string | null;
|
cardTitleText: string | null;
|
||||||
cardFont: LoyaltyCardFont;
|
cardFont: LoyaltyCardFont;
|
||||||
cardTopRightTextSize: LoyaltyCardTextSize;
|
cardTitleTextSize: LoyaltyCardTextSize;
|
||||||
cardTopRightTextColor: string | null;
|
cardTitleTextColor: string | null;
|
||||||
cardBorderColor: string | null;
|
cardBorderColor: string | null;
|
||||||
cardMemberNumberColor: string | null;
|
cardMemberNumberColor: string | null;
|
||||||
}
|
}
|
||||||
@@ -105,10 +105,10 @@ export async function getLoyaltySummary(
|
|||||||
cardBackgroundColor: programme.cardBackgroundColor,
|
cardBackgroundColor: programme.cardBackgroundColor,
|
||||||
cardBackgroundImageUrl: programme.cardBackgroundImageUrl,
|
cardBackgroundImageUrl: programme.cardBackgroundImageUrl,
|
||||||
termsLine: programme.termsLine,
|
termsLine: programme.termsLine,
|
||||||
cardTopRightText: programme.cardTopRightText,
|
cardTitleText: programme.cardTitleText,
|
||||||
cardFont: programme.cardFont,
|
cardFont: programme.cardFont,
|
||||||
cardTopRightTextSize: programme.cardTopRightTextSize,
|
cardTitleTextSize: programme.cardTitleTextSize,
|
||||||
cardTopRightTextColor: programme.cardTopRightTextColor,
|
cardTitleTextColor: programme.cardTitleTextColor,
|
||||||
cardBorderColor: programme.cardBorderColor,
|
cardBorderColor: programme.cardBorderColor,
|
||||||
cardMemberNumberColor: programme.cardMemberNumberColor,
|
cardMemberNumberColor: programme.cardMemberNumberColor,
|
||||||
};
|
};
|
||||||
@@ -546,11 +546,11 @@ export interface ProgrammeConfigInput {
|
|||||||
cardBackgroundImageUrl?: string;
|
cardBackgroundImageUrl?: string;
|
||||||
stampIcon?: string;
|
stampIcon?: string;
|
||||||
termsLine?: string;
|
termsLine?: string;
|
||||||
/** Free text shown in the card's top-right area — see schema.prisma's cardTopRightText doc comment. */
|
/** Replaces the "Loyalty Card" title (top-left) when set — see schema.prisma's cardTitleText doc comment. */
|
||||||
cardTopRightText?: string;
|
cardTitleText?: string;
|
||||||
cardFont: LoyaltyCardFont;
|
cardFont: LoyaltyCardFont;
|
||||||
cardTopRightTextSize: LoyaltyCardTextSize;
|
cardTitleTextSize: LoyaltyCardTextSize;
|
||||||
cardTopRightTextColor?: string;
|
cardTitleTextColor?: string;
|
||||||
cardBorderColor?: string;
|
cardBorderColor?: string;
|
||||||
cardMemberNumberColor?: string;
|
cardMemberNumberColor?: string;
|
||||||
}
|
}
|
||||||
@@ -627,10 +627,10 @@ export async function updateProgrammeConfig(
|
|||||||
cardBackgroundImageUrl: params.input.cardBackgroundImageUrl || null,
|
cardBackgroundImageUrl: params.input.cardBackgroundImageUrl || null,
|
||||||
stampIcon: params.input.stampIcon || null,
|
stampIcon: params.input.stampIcon || null,
|
||||||
termsLine: params.input.termsLine?.trim() || null,
|
termsLine: params.input.termsLine?.trim() || null,
|
||||||
cardTopRightText: params.input.cardTopRightText?.trim() || null,
|
cardTitleText: params.input.cardTitleText?.trim() || null,
|
||||||
cardFont: params.input.cardFont,
|
cardFont: params.input.cardFont,
|
||||||
cardTopRightTextSize: params.input.cardTopRightTextSize,
|
cardTitleTextSize: params.input.cardTitleTextSize,
|
||||||
cardTopRightTextColor: params.input.cardTopRightTextColor || null,
|
cardTitleTextColor: params.input.cardTitleTextColor || null,
|
||||||
cardBorderColor: params.input.cardBorderColor || null,
|
cardBorderColor: params.input.cardBorderColor || null,
|
||||||
cardMemberNumberColor: params.input.cardMemberNumberColor || null,
|
cardMemberNumberColor: params.input.cardMemberNumberColor || null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ export async function getConfigurationWarnings(prisma: PrismaClient, businessId:
|
|||||||
earnOnCancelled: loyaltyProgramme.earnOnCancelled,
|
earnOnCancelled: loyaltyProgramme.earnOnCancelled,
|
||||||
earnOnNoShow: loyaltyProgramme.earnOnNoShow,
|
earnOnNoShow: loyaltyProgramme.earnOnNoShow,
|
||||||
cardFont: loyaltyProgramme.cardFont,
|
cardFont: loyaltyProgramme.cardFont,
|
||||||
cardTopRightTextSize: loyaltyProgramme.cardTopRightTextSize,
|
cardTitleTextSize: loyaltyProgramme.cardTitleTextSize,
|
||||||
});
|
});
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
warnings.push({
|
warnings.push({
|
||||||
|
|||||||
Reference in new issue
Block a user