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

+2 -1
View File
@@ -2,7 +2,7 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { CalendarCheck, CalendarDays, ChartLine, House, Scissors, Settings, User, Users } from "lucide-react";
import { CalendarCheck, CalendarDays, ChartLine, Gift, House, Scissors, Settings, User, Users } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils/cn";
@@ -18,6 +18,7 @@ import { cn } from "@/lib/utils/cn";
export const NAV_ICON_MAP = {
house: House,
"calendar-check": CalendarCheck,
gift: Gift,
user: User,
"calendar-days": CalendarDays,
scissors: Scissors,
@@ -0,0 +1,26 @@
import type { ReactNode } from "react";
import { TopNav } from "@/components/navigation/top-nav";
import { cn } from "@/lib/utils/cn";
export interface SettingsPageShellProps {
title: string;
children: ReactNode;
/** Merged onto the default `<main>` classes via cn()/tailwind-merge — pass e.g. "lg:max-w-2xl" to opt out of the default wide desktop layout, or extra utilities like "space-y-8". */
mainClassName?: string;
}
/**
* The TopNav + centered container wrapper repeated verbatim across every
* owner settings page (Business Details, Gallery, Loyalty, Booking
* Rules, Opening Hours, Reviews, ...) — extracted here in Phase 9 once
* enough pages shared it to be worth the indirection. See
* docs/phase-9-summary.md, "Architecture Review".
*/
export function SettingsPageShell({ title, children, mainClassName }: SettingsPageShellProps) {
return (
<div className="pb-12">
<TopNav title={title} onBack sticky className="bg-stone" />
<main className={cn("mx-auto max-w-2xl px-6 py-6 lg:max-w-4xl", mainClassName)}>{children}</main>
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
"use client";
import { useId, useState } from "react";
import { cn } from "@/lib/utils/cn";
const DEFAULT_PRESETS = ["#111b15", "#1d4ed8", "#b91c1c", "#15803d", "#a16207", "#374151", "#ffffff"];
const HEX_PATTERN = /^#[0-9a-f]{6}$/i;
export interface ColorPickerProps {
label?: string;
value: string | null | undefined;
onChange: (hex: string) => void;
presets?: string[];
className?: string;
}
/**
* Single reusable colour input — native swatch + hex text field + presets
* — shared by theme/background colour (Business Branding) and the
* loyalty card designer rather than each screen rolling its own. See
* docs/phase-9-summary.md.
*/
export function ColorPicker({ label, value, onChange, presets = DEFAULT_PRESETS, className }: ColorPickerProps) {
const id = useId();
const [text, setText] = useState(value ?? "");
const swatchColor = value && HEX_PATTERN.test(value) ? value : "#000000";
function commit(next: string) {
setText(next);
if (HEX_PATTERN.test(next)) onChange(next);
}
return (
<div className={cn("w-full", className)}>
{label && (
<label htmlFor={id} className="text-muted mb-2 ml-1 block text-[10px] font-bold tracking-widest uppercase">
{label}
</label>
)}
<div className="flex items-center gap-3">
<label
className="border-line relative h-11 w-11 shrink-0 overflow-hidden rounded-xl border"
style={{ backgroundColor: swatchColor }}
>
<input
type="color"
value={swatchColor}
onChange={(e) => commit(e.target.value)}
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
aria-label={label ? `${label} colour swatch` : "Colour swatch"}
/>
</label>
<input
id={id}
type="text"
value={text}
onChange={(e) => commit(e.target.value)}
placeholder="#111b15"
spellCheck={false}
className="border-line placeholder:text-muted focus:ring-ink w-full rounded-xl border bg-white px-4 py-3 font-mono text-sm outline-none focus:ring-2"
/>
</div>
{presets.length > 0 && (
<div className="mt-2 flex flex-wrap gap-2">
{presets.map((preset) => (
<button
key={preset}
type="button"
onClick={() => commit(preset)}
aria-label={`Use ${preset}`}
className={cn(
"h-6 w-6 rounded-full border-2",
swatchColor.toLowerCase() === preset.toLowerCase() ? "border-ink" : "border-transparent",
)}
style={{ backgroundColor: preset }}
/>
))}
</div>
)}
</div>
);
}
+87
View File
@@ -0,0 +1,87 @@
"use client";
import { useId, useMemo, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Search } from "lucide-react";
import { Modal } from "@/components/ui/modal";
import { ICON_CATALOG, findCatalogIcon } from "@/lib/icons/icon-catalog";
import { cn } from "@/lib/utils/cn";
export interface IconPickerProps {
label?: string;
value: string | null | undefined;
onChange: (iconName: string) => void;
}
/** Searchable Font Awesome icon picker — replaces free-text icon-name input (e.g. the loyalty stamp icon). See src/lib/icons/icon-catalog.ts. */
export function IconPicker({ label, value, onChange }: IconPickerProps) {
const labelId = useId();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const selected = findCatalogIcon(value);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return ICON_CATALOG;
return ICON_CATALOG.filter((icon) => icon.iconName.toLowerCase().includes(q));
}, [query]);
return (
<div className="w-full">
{label && (
<span id={labelId} className="text-muted mb-2 ml-1 block text-[10px] font-bold tracking-widest uppercase">
{label}
</span>
)}
<button
type="button"
onClick={() => setOpen(true)}
aria-labelledby={label ? labelId : undefined}
className="border-line flex w-full items-center gap-3 rounded-xl border bg-white px-5 py-4 text-left text-sm"
>
<span className="bg-stone flex h-9 w-9 shrink-0 items-center justify-center rounded-full">
<FontAwesomeIcon icon={selected} className="h-4 w-4" />
</span>
<span className="flex-1 capitalize">{selected.iconName.replace(/-/g, " ")}</span>
<span className="text-muted text-xs font-bold tracking-widest uppercase">Change</span>
</button>
<Modal open={open} onClose={() => setOpen(false)} title="Choose an icon">
<div className="relative mb-4">
<Search className="text-muted pointer-events-none absolute top-1/2 left-4 h-4 w-4 -translate-y-1/2" aria-hidden="true" />
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search icons"
className="border-line focus:ring-ink w-full rounded-xl border bg-white py-3 pr-4 pl-11 text-sm outline-none focus:ring-2"
/>
</div>
{filtered.length === 0 ? (
<p className="text-muted py-6 text-center text-sm">No icons match &ldquo;{query}&rdquo;.</p>
) : (
<div className="grid max-h-80 grid-cols-5 gap-2 overflow-y-auto">
{filtered.map((icon) => (
<button
key={icon.iconName}
type="button"
onClick={() => {
onChange(icon.iconName);
setOpen(false);
}}
aria-label={icon.iconName}
aria-pressed={icon.iconName === selected.iconName}
className={cn(
"flex aspect-square flex-col items-center justify-center rounded-xl border",
icon.iconName === selected.iconName ? "border-ink bg-stone" : "border-line hover:bg-stone",
)}
>
<FontAwesomeIcon icon={icon} className="h-4 w-4" />
</button>
))}
</div>
)}
</Modal>
</div>
);
}
+77
View File
@@ -0,0 +1,77 @@
"use client";
import { useState } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils/cn";
export interface ImageGalleryProps {
images: { id: string; url: string; caption?: string | null }[];
className?: string;
}
/**
* Home page hero — a real gallery with thumbnails (Phase 9), replacing
* the old single full-bleed logo banner. Matches the content width rather
* than bleeding edge-to-edge, per the brief. Plain click-to-swap + arrow
* buttons; no swipe-gesture library, since the thumbnail strip already
* covers "jump to any image" and arrows cover "next/previous" — a
* touch-drag carousel would be a reasonable future upgrade, not required
* for this to be a real gallery.
*/
export function ImageGallery({ images, className }: ImageGalleryProps) {
const [activeIndex, setActiveIndex] = useState(0);
if (images.length === 0) return null;
const active = images[Math.min(activeIndex, images.length - 1)]!;
const goTo = (index: number) => setActiveIndex((index + images.length) % images.length);
return (
<div className={cn("w-full", className)}>
<div className="bg-stone relative aspect-[16/9] w-full overflow-hidden rounded-jumbo">
{/* eslint-disable-next-line @next/next/no-img-element -- local/user-supplied URL, same pattern used app-wide */}
<img src={active.url} alt={active.caption ?? ""} className="h-full w-full object-cover" />
{images.length > 1 && (
<>
<button
type="button"
onClick={() => goTo(activeIndex - 1)}
aria-label="Previous image"
className="bg-ink/60 hover:bg-ink/80 absolute top-1/2 left-3 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full text-white"
>
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
</button>
<button
type="button"
onClick={() => goTo(activeIndex + 1)}
aria-label="Next image"
className="bg-ink/60 hover:bg-ink/80 absolute top-1/2 right-3 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full text-white"
>
<ChevronRight className="h-4 w-4" aria-hidden="true" />
</button>
</>
)}
</div>
{images.length > 1 && (
<div className="mt-2 flex gap-2 overflow-x-auto pb-1">
{images.map((image, index) => (
<button
key={image.id}
type="button"
onClick={() => setActiveIndex(index)}
aria-label={`Show image ${index + 1}`}
aria-current={index === activeIndex}
className={cn(
"h-14 w-20 shrink-0 overflow-hidden rounded-lg border-2",
index === activeIndex ? "border-ink" : "border-transparent opacity-70",
)}
>
{/* eslint-disable-next-line @next/next/no-img-element -- local/user-supplied URL, same pattern used app-wide */}
<img src={image.url} alt="" className="h-full w-full object-cover" />
</button>
))}
</div>
)}
</div>
);
}
+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>
);
}
+7
View File
@@ -10,9 +10,16 @@ export interface ServiceCardProps {
export function ServiceCard({ service, action }: ServiceCardProps) {
return (
<div className="flex items-center justify-between gap-4 py-4">
{service.imageUrl && (
// eslint-disable-next-line @next/next/no-img-element -- local/user-supplied URL, same pattern used app-wide
<img src={service.imageUrl} alt="" className="bg-stone h-14 w-14 shrink-0 rounded-xl object-cover" />
)}
<div className="flex-1">
<h4 className="text-sm font-medium">{service.name}</h4>
<p className="mt-1 text-xs text-muted">{service.durationMinutes}m</p>
{service.description && (
<p className="mt-1 text-xs text-muted line-clamp-2">{service.description}</p>
)}
</div>
<div className="flex items-center gap-4">
<span className="font-bold">{service.formattedPrice}</span>