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.
167 lines
7.0 KiB
TypeScript
167 lines
7.0 KiB
TypeScript
"use client";
|
|
|
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
import { Gift } from "lucide-react";
|
|
import { LoyaltyCardFont, LoyaltyCardTextSize, RewardPositionMode } from "@/generated/prisma/enums";
|
|
import { findCatalogIcon } from "@/lib/icons/icon-catalog";
|
|
import { cn } from "@/lib/utils/cn";
|
|
import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
|
|
|
|
const FONT_CLASSES: Record<LoyaltyCardFont, string> = {
|
|
[LoyaltyCardFont.SANS]: "font-sans",
|
|
[LoyaltyCardFont.SERIF]: "font-serif",
|
|
[LoyaltyCardFont.MONO]: "font-mono",
|
|
};
|
|
|
|
/** MD matches the size "Loyalty Card" rendered at before this was configurable, so leaving this unset doesn't change the default look. */
|
|
const TITLE_TEXT_SIZE_CLASSES: Record<LoyaltyCardTextSize, string> = {
|
|
[LoyaltyCardTextSize.SM]: "text-base",
|
|
[LoyaltyCardTextSize.MD]: "text-lg",
|
|
[LoyaltyCardTextSize.LG]: "text-2xl",
|
|
};
|
|
|
|
export interface LoyaltyCardProps {
|
|
businessName: string;
|
|
memberNumber: string;
|
|
stampsRequired: number;
|
|
stampsEarned: number;
|
|
rewardLabel: string;
|
|
termsLine?: string;
|
|
/** Owner-configured card appearance (LoyaltyProgramme) — see the loyalty settings designer. All optional; sensible defaults apply when unset. */
|
|
cardBackgroundColor?: string | null;
|
|
cardBackgroundImageUrl?: 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. */
|
|
rewardPosition?: RewardPositionMode;
|
|
/** Replaces the "Loyalty Card" title (top-left) when set. */
|
|
cardTitleText?: string | null;
|
|
cardFont?: LoyaltyCardFont;
|
|
cardTitleTextSize?: LoyaltyCardTextSize;
|
|
cardTitleTextColor?: string | null;
|
|
cardBorderColor?: string | null;
|
|
cardMemberNumberColor?: string | null;
|
|
}
|
|
|
|
/**
|
|
* Static, non-flipping card — see docs/phase-9-summary.md ("Loyalty Card
|
|
* Improvements"). Previously a 3D flip whose back face just repeated the
|
|
* business name in a generic panel; the brief asked to drop that and only
|
|
* ever show the stamped side, restyled to feel like a real plastic
|
|
* loyalty card (diagonal shine + soft depth via layered CSS
|
|
* gradients/shadows only — no extra image asset, so this stays cheap to
|
|
* render). Also, previously, cardBackgroundColor/cardBackgroundImageUrl/
|
|
* stampIcon were stored on LoyaltyProgramme but never actually reached
|
|
* this component — every business rendered the same hardcoded card
|
|
* regardless of configuration. Fixed here.
|
|
*/
|
|
export function LoyaltyCard({
|
|
businessName,
|
|
memberNumber,
|
|
stampsRequired,
|
|
stampsEarned,
|
|
rewardLabel,
|
|
termsLine,
|
|
cardBackgroundColor,
|
|
cardBackgroundImageUrl,
|
|
stampIcon,
|
|
rewardPosition = RewardPositionMode.AFTER_ALL_STAMPS,
|
|
cardTitleText,
|
|
cardFont = LoyaltyCardFont.SANS,
|
|
cardTitleTextSize = LoyaltyCardTextSize.MD,
|
|
cardTitleTextColor,
|
|
cardBorderColor,
|
|
cardMemberNumberColor,
|
|
}: LoyaltyCardProps) {
|
|
const icon = findCatalogIcon(stampIcon);
|
|
const threshold = resolveRewardThreshold({ stampsRequired, rewardPosition });
|
|
// Only FINAL_SLOT_IS_REWARD sets aside a dedicated slot — under
|
|
// AFTER_ALL_STAMPS every position is a plain stamp and the reward is
|
|
// shown separately (see the "Reward available" banner on the loyalty
|
|
// page), not by any one slot.
|
|
const rewardSlotIndex = rewardPosition === RewardPositionMode.FINAL_SLOT_IS_REWARD ? stampsRequired - 1 : null;
|
|
const borderColor = cardBorderColor || "var(--color-accent-loyalty)";
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"relative w-full overflow-hidden rounded-2xl",
|
|
FONT_CLASSES[cardFont],
|
|
)}
|
|
style={{
|
|
aspectRatio: "1.65 / 1",
|
|
backgroundColor: cardBackgroundColor || "#141414",
|
|
boxShadow: `0 30px 60px rgba(0,0,0,0.45), 0 0 0 3px ${borderColor}`,
|
|
}}
|
|
role="img"
|
|
aria-label={`${businessName} loyalty card, ${stampsEarned} of ${stampsRequired} stamps`}
|
|
>
|
|
{cardBackgroundImageUrl && (
|
|
// eslint-disable-next-line @next/next/no-img-element -- local/user-supplied URL, same pattern used app-wide
|
|
<img src={cardBackgroundImageUrl} alt="" className="absolute inset-0 h-full w-full object-cover" />
|
|
)}
|
|
{/* Dark scrim so stamps/text stay legible over an arbitrary background image. */}
|
|
{cardBackgroundImageUrl && <div className="absolute inset-0 bg-black/35" aria-hidden="true" />}
|
|
{/* Premium plastic-card shine — a soft diagonal highlight, CSS-only. */}
|
|
<div
|
|
className="pointer-events-none absolute inset-0"
|
|
style={{
|
|
background:
|
|
"linear-gradient(115deg, rgba(255,255,255,0.16) 0%, rgba(255,255,255,0) 30%, rgba(255,255,255,0) 70%, rgba(255,255,255,0.08) 100%)",
|
|
}}
|
|
aria-hidden="true"
|
|
/>
|
|
|
|
<div className="relative flex h-full flex-col justify-between p-4">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<h2
|
|
className={cn(TITLE_TEXT_SIZE_CLASSES[cardTitleTextSize], "font-black italic tracking-tight text-white")}
|
|
style={{ color: cardTitleTextColor || undefined }}
|
|
>
|
|
{cardTitleText || "Loyalty Card"}
|
|
</h2>
|
|
<span
|
|
className="border-accent-loyalty/30 text-accent-loyalty shrink-0 rounded border px-2 py-0.5 text-[8px] font-bold tracking-widest uppercase"
|
|
style={{ color: cardMemberNumberColor || undefined, borderColor: cardMemberNumberColor ? `${cardMemberNumberColor}4d` : undefined }}
|
|
>
|
|
Member #{memberNumber}
|
|
</span>
|
|
</div>
|
|
<div className="grid grid-cols-5 gap-2">
|
|
{Array.from({ length: stampsRequired }, (_, i) => {
|
|
const isRewardSlot = i === rewardSlotIndex;
|
|
const earned = isRewardSlot ? stampsEarned >= threshold : i < stampsEarned;
|
|
return (
|
|
<div
|
|
key={i}
|
|
className={cn(
|
|
"flex aspect-square items-center justify-center rounded-full",
|
|
isRewardSlot
|
|
? earned
|
|
? "bg-accent-loyalty scale-[1.08] shadow-lg"
|
|
: "border-accent-loyalty/50 bg-accent-loyalty/10 border-2 border-dashed"
|
|
: earned
|
|
? "bg-white shadow-inner"
|
|
: "border-2 border-dashed border-white/20 bg-white/10",
|
|
)}
|
|
>
|
|
{isRewardSlot ? (
|
|
earned ? (
|
|
<span className="text-ink text-center text-[7px] leading-tight font-black">{rewardLabel}</span>
|
|
) : (
|
|
<Gift className="text-accent-loyalty h-3.5 w-3.5" aria-hidden="true" />
|
|
)
|
|
) : earned ? (
|
|
<FontAwesomeIcon icon={icon} className="text-ink h-3.5 w-3.5" />
|
|
) : null}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
{termsLine && (
|
|
<p className="mt-2 text-center text-[7px] font-bold tracking-widest text-white/40 uppercase">{termsLine}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|