Post Phase 8 - Fixes 1

Delivered:

Super Administrator — env-var provisioned, no StaffProfile at all (structural exclusion from Team/barber-picker/appointments/staff-totals, not a filter). hasPermission short-circuit + new getActingBusinessContext() helper retrofitted across 16 owner pages so it can actually manage the business.
Media Manager — one upload/library/external-URL modal + field picker, backing every image field app-wide. Local-disk storage + Docker volume, SVG rejected, size-capped.
Welcome backgrounds — desktop/mobile images with CSS breakpoint swap.
Business Branding settings — logo/cover/welcome/favicon/icon/colours all editable post-install; cover image finally rendered (was dead config).
Service descriptions — now surfaced on Home and in the booking flow.
Loyalty card — live preview, ColorPicker/MediaPicker/IconPicker, flip removed for a static premium-styled card, Loyalty added to bottom nav. Fixed a real pre-existing bug where the card's configured colour/image/icon were stored but never actually rendered.
Home Screen Redesign — hero gallery + sticky Services/Portfolio/Reviews/Details tabs, backed by a new unified gallery model/service (no hero/portfolio duplication). Details tab surfaces social links and a map that were never shown before.
Google Reviews — real sync pipeline (24h cron, CRON_SECRET-gated, mirrors the existing notification cron exactly), DB-only customer reads, owner hide/unhide + policy management page.
Architecture cleanup — SettingsPageShell extracted from 6 duplicated page wrappers; fixed a second pre-existing bug where branding image fields could never actually be cleared once set.
Verification: 418 tests passing (236 unit + 182 integration, all against a real MySQL database), full typecheck/lint clean across src, and a real Docker build+run cycle — which caught and fixed a genuine bug (scripts/ wasn't copied into the production image, so Super Admin sync would have crashed on every real deployment).
This commit is contained in:
Stefan Lazic committed 2026-08-04 17:10:30 +01:00
1 parent 77f25c0489
commit 9e39908ea7
79 files changed
+4121 -450

No files matched your search

+77 -75
View File
@@ -1,7 +1,7 @@
"use client";
import { Scissors } from "lucide-react";
import { useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { findCatalogIcon } from "@/lib/icons/icon-catalog";
import { cn } from "@/lib/utils/cn";
export interface LoyaltyCardProps {
@@ -11,15 +11,23 @@ export interface LoyaltyCardProps {
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;
}
/**
* Ports the 3D flip loyalty card from templates/11 — the best-executed
* visual in the whole template set (see docs/implementation-plan.md §2) —
* but fixes the gap flagged in §8: the original had no actual flip
* trigger wired up. Here the card is a real <button> with
* `aria-pressed`, and the flip transition is skipped for users who
* prefer reduced motion.
* 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,
@@ -28,80 +36,74 @@ export function LoyaltyCard({
stampsEarned,
rewardLabel,
termsLine,
cardBackgroundColor,
cardBackgroundImageUrl,
stampIcon,
}: LoyaltyCardProps) {
const [flipped, setFlipped] = useState(false);
const icon = findCatalogIcon(stampIcon);
return (
<button
type="button"
onClick={() => setFlipped((v) => !v)}
aria-pressed={flipped}
aria-label={flipped ? "Show stamp side" : "Show card back"}
className="w-full [perspective:1000px]"
style={{ aspectRatio: "1.65 / 1" }}
<div
className="ring-accent-loyalty relative w-full overflow-hidden rounded-2xl shadow-[0_30px_60px_rgba(0,0,0,0.45)] ring-[3px]"
style={{ aspectRatio: "1.65 / 1", backgroundColor: cardBackgroundColor || "#141414" }}
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={cn(
"relative h-full w-full rounded-2xl shadow-[0_30px_60px_rgba(0,0,0,0.45)] transition-transform duration-700 [transform-style:preserve-3d] motion-reduce:transition-none",
"ring-[3px] ring-accent-loyalty",
)}
style={{ transform: flipped ? "rotateY(180deg)" : "rotateY(0deg)" }}
>
{/* Front — stamps */}
<div className="absolute inset-0 flex flex-col justify-between overflow-hidden rounded-2xl bg-[#141414] p-4 [backface-visibility:hidden]">
<div className="flex items-center justify-between">
<h2 className="text-lg font-black italic tracking-tight text-white">Loyalty Card</h2>
<span className="rounded border border-accent-loyalty/30 px-2 py-0.5 text-[8px] font-bold uppercase tracking-widest text-accent-loyalty">
Member #{memberNumber}
</span>
</div>
<div className="grid grid-cols-5 gap-2">
{Array.from({ length: stampsRequired }, (_, i) => {
const earned = i < stampsEarned;
// Only the final slot gets reward styling, and only once it's
// actually been earned — otherwise an in-progress card (e.g.
// 3/10 stamps) would show the reward label on slot 10 already.
const isRewardSlot = earned && i === stampsRequired - 1;
return (
<div
key={i}
className={cn(
"flex aspect-square items-center justify-center rounded-full",
isRewardSlot
? "scale-[1.08] bg-accent-loyalty shadow-lg"
: earned
? "bg-white shadow-inner"
: "border-2 border-dashed border-white/20 bg-[#2a2a2a]",
)}
>
{isRewardSlot ? (
<span className="text-center text-[7px] font-black leading-tight text-ink">
{rewardLabel}
</span>
) : earned ? (
<Scissors className="h-3.5 w-3.5 text-ink" aria-hidden="true" />
) : null}
</div>
);
})}
</div>
{termsLine && (
<p className="text-center text-[7px] font-bold uppercase tracking-widest text-white/40">
{termsLine}
</p>
)}
</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"
/>
{/* Back — brand */}
<div className="absolute inset-0 flex items-center justify-center rounded-2xl bg-ink [backface-visibility:hidden] [transform:rotateY(180deg)]">
<div className="text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-white text-3xl text-ink">
<Scissors className="h-7 w-7" aria-hidden="true" />
</div>
<h3 className="text-2xl font-black uppercase tracking-tighter text-white">{businessName}</h3>
</div>
<div className="relative flex h-full flex-col justify-between p-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-black italic tracking-tight text-white">Loyalty Card</h2>
<span className="border-accent-loyalty/30 text-accent-loyalty rounded border px-2 py-0.5 text-[8px] font-bold tracking-widest uppercase">
Member #{memberNumber}
</span>
</div>
<div className="grid grid-cols-5 gap-2">
{Array.from({ length: stampsRequired }, (_, i) => {
const earned = i < stampsEarned;
// Only the final slot gets reward styling, and only once it's
// actually been earned — otherwise an in-progress card (e.g.
// 3/10 stamps) would show the reward label on slot 10 already.
const isRewardSlot = earned && i === stampsRequired - 1;
return (
<div
key={i}
className={cn(
"flex aspect-square items-center justify-center rounded-full",
isRewardSlot
? "bg-accent-loyalty scale-[1.08] shadow-lg"
: earned
? "bg-white shadow-inner"
: "border-2 border-dashed border-white/20 bg-white/10",
)}
>
{isRewardSlot ? (
<span className="text-ink text-center text-[7px] leading-tight font-black">{rewardLabel}</span>
) : earned ? (
<FontAwesomeIcon icon={icon} className="text-ink h-3.5 w-3.5" />
) : null}
</div>
);
})}
</div>
{termsLine && (
<p className="text-center text-[7px] font-bold tracking-widest text-white/40 uppercase">{termsLine}</p>
)}
</div>
</button>
</div>
);
}