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:
79 files changed
+4121
-450
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 “{query}”.</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user