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

+385
View File
@@ -0,0 +1,385 @@
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import { AtSign, Globe, Mail, Phone, Search, Share2, Star } from "lucide-react";
import { Button } from "@/components/ui/button";
import { BarberCard } from "@/components/ui/barber-card";
import { Card } from "@/components/ui/card";
import { ServiceCard } from "@/components/ui/service-card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils/cn";
import type { AvailableService } from "@/services/scheduling/availability.service";
import type { BusinessReviewSummary } from "@/services/reviews/reviews.service";
import type { GalleryImageSummary } from "@/services/gallery/gallery.service";
import type { StaffSummary } from "@/types/domain";
export interface HomeTabsProps {
services: AvailableService[];
categories: { id: string; name: string }[];
portfolioImages: GalleryImageSummary[];
reviews: BusinessReviewSummary;
description: string | null;
phone: string | null;
email: string | null;
website: string | null;
instagramUrl: string | null;
facebookUrl: string | null;
hoursRows: { label: string; hoursLabel: string }[];
staff: (StaffSummary & { isPreferred: boolean })[];
mapEmbedSrc: string | null;
}
/**
* Sticky Services/Portfolio/Reviews/Details tabs below the Home hero —
* Phase 9's Home Screen Redesign. All data is pre-fetched server-side by
* the Home page and passed in as plain props; tab switching and the
* Services search/category filter are pure client-side state over that
* already-fetched data, not new network round trips per interaction.
*/
export function HomeTabs(props: HomeTabsProps) {
return (
<Tabs defaultValue="services">
<TabsList className="bg-stone border-line sticky top-0 z-30 -mx-6 rounded-none border-b px-6 py-3">
<TabsTrigger value="services">Services</TabsTrigger>
<TabsTrigger value="portfolio">Portfolio</TabsTrigger>
<TabsTrigger value="reviews">Reviews</TabsTrigger>
<TabsTrigger value="details">Details</TabsTrigger>
</TabsList>
<TabsContent value="services">
<ServicesTab services={props.services} categories={props.categories} />
</TabsContent>
<TabsContent value="portfolio">
<PortfolioTab images={props.portfolioImages} />
</TabsContent>
<TabsContent value="reviews">
<ReviewsTab reviews={props.reviews} />
</TabsContent>
<TabsContent value="details">
<DetailsTab
description={props.description}
phone={props.phone}
email={props.email}
website={props.website}
instagramUrl={props.instagramUrl}
facebookUrl={props.facebookUrl}
hoursRows={props.hoursRows}
staff={props.staff}
mapEmbedSrc={props.mapEmbedSrc}
/>
</TabsContent>
</Tabs>
);
}
function ServicesTab({
services,
categories,
}: {
services: AvailableService[];
categories: { id: string; name: string }[];
}) {
const [query, setQuery] = useState("");
const [activeCategoryId, setActiveCategoryId] = useState<string | null>(null);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return services.filter((s) => {
if (activeCategoryId && s.categoryId !== activeCategoryId) return false;
if (q && !s.name.toLowerCase().includes(q)) return false;
return true;
});
}, [services, query, activeCategoryId]);
const grouped = useMemo(() => {
const byCategory = new Map<string, AvailableService[]>();
for (const service of filtered) {
const list = byCategory.get(service.categoryId) ?? [];
list.push(service);
byCategory.set(service.categoryId, list);
}
return categories
.map((c) => ({ ...c, services: byCategory.get(c.id) ?? [] }))
.filter((g) => g.services.length > 0);
}, [filtered, categories]);
const categoriesInUse = useMemo(() => categories.filter((c) => services.some((s) => s.categoryId === c.id)), [categories, services]);
return (
<div className="space-y-4">
<div className="relative">
<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 services"
className="border-line focus:ring-ink w-full rounded-xl border bg-white py-2.5 pr-4 pl-11 text-sm outline-none focus:ring-2"
/>
</div>
{categoriesInUse.length > 1 && (
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setActiveCategoryId(null)}
className={cn(
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
activeCategoryId === null ? "bg-ink text-white" : "border-line text-muted border bg-white",
)}
>
All
</button>
{categoriesInUse.map((c) => (
<button
key={c.id}
type="button"
onClick={() => setActiveCategoryId(c.id)}
className={cn(
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
activeCategoryId === c.id ? "bg-ink text-white" : "border-line text-muted border bg-white",
)}
>
{c.name}
</button>
))}
</div>
)}
{grouped.length === 0 ? (
<p className="text-muted py-6 text-center text-sm">No services match.</p>
) : (
grouped.map((group) => (
<section key={group.id}>
<h3 className="text-muted mb-2 text-xs font-bold tracking-widest uppercase">{group.name}</h3>
<Card className="divide-line divide-y px-5">
{group.services.map((service) => (
<ServiceCard
key={service.id}
service={{
id: service.id,
name: service.name,
description: service.description,
imageUrl: service.imageUrl,
durationMinutes: service.durationMinutes,
formattedPrice: `£${Number(service.price).toFixed(2)}`,
}}
action={
<Button asChild size="sm">
<Link href="/book">Book</Link>
</Button>
}
/>
))}
</Card>
</section>
))
)}
</div>
);
}
function PortfolioTab({ images }: { images: GalleryImageSummary[] }) {
const categories = useMemo(
() => Array.from(new Set(images.map((i) => i.category).filter((c): c is string => !!c))),
[images],
);
const [activeCategory, setActiveCategory] = useState<string | null>(null);
const filtered = activeCategory ? images.filter((i) => i.category === activeCategory) : images;
if (images.length === 0) {
return <p className="text-muted py-10 text-center text-sm">No portfolio images yet.</p>;
}
return (
<div className="space-y-4">
{categories.length > 0 && (
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setActiveCategory(null)}
className={cn(
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
activeCategory === null ? "bg-ink text-white" : "border-line text-muted border bg-white",
)}
>
All
</button>
{categories.map((c) => (
<button
key={c}
type="button"
onClick={() => setActiveCategory(c)}
className={cn(
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
activeCategory === c ? "bg-ink text-white" : "border-line text-muted border bg-white",
)}
>
{c}
</button>
))}
</div>
)}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{filtered.map((image) => (
<div key={image.id} className="bg-stone aspect-square overflow-hidden rounded-xl">
{/* eslint-disable-next-line @next/next/no-img-element -- local/user-supplied URL, same pattern used app-wide */}
<img src={image.imageUrl} alt={image.caption ?? ""} className="h-full w-full object-cover" />
</div>
))}
</div>
</div>
);
}
function ReviewsTab({ reviews }: { reviews: BusinessReviewSummary }) {
if (reviews.reviews.length === 0) {
return <p className="text-muted py-10 text-center text-sm">No reviews yet.</p>;
}
return (
<div className="space-y-3">
{reviews.isDemoData && (
<p className="text-muted text-right text-[9px] font-bold tracking-widest uppercase">Demo data</p>
)}
{reviews.reviews.map((review) => (
<Card key={review.id} className="p-4">
<div className="mb-1 flex items-center justify-between">
<p className="text-sm font-bold">{review.authorName}</p>
<span className="text-muted text-[10px]">{review.relativeDate}</span>
</div>
<div className="text-accent-loyalty mb-2 flex gap-0.5">
{Array.from({ length: review.rating }, (_, i) => (
<Star key={i} className="h-3 w-3 fill-current" aria-hidden="true" />
))}
</div>
<p className="text-muted text-xs">{review.text}</p>
</Card>
))}
</div>
);
}
function DetailsTab({
description,
phone,
email,
website,
instagramUrl,
facebookUrl,
hoursRows,
staff,
mapEmbedSrc,
}: {
description: string | null;
phone: string | null;
email: string | null;
website: string | null;
instagramUrl: string | null;
facebookUrl: string | null;
hoursRows: { label: string; hoursLabel: string }[];
staff: (StaffSummary & { isPreferred: boolean })[];
mapEmbedSrc: string | null;
}) {
const hasSocial = website || instagramUrl || facebookUrl;
return (
<div className="space-y-8">
{mapEmbedSrc && (
<section>
<div className="bg-stone aspect-[16/9] w-full overflow-hidden rounded-jumbo">
<iframe
src={mapEmbedSrc}
title="Business location map"
className="h-full w-full border-0"
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
/>
</div>
</section>
)}
{description && (
<section>
<h3 className="text-muted mb-2 text-xs font-bold tracking-widest uppercase">About Us</h3>
<p className="text-muted text-sm leading-relaxed">{description}</p>
</section>
)}
{staff.length > 0 && (
<section>
<h3 className="text-muted mb-3 text-xs font-bold tracking-widest uppercase">Staff</h3>
<div className="flex gap-5 overflow-x-auto pb-1">
{staff.map((s) => (
<BarberCard key={s.id} staff={s} isPreferred={s.isPreferred} />
))}
</div>
</section>
)}
{hoursRows.length > 0 && (
<section>
<h3 className="text-muted mb-3 text-xs font-bold tracking-widest uppercase">Opening Hours</h3>
<Card className="divide-line divide-y px-5">
{hoursRows.map((row) => (
<div key={row.label} className="flex items-center justify-between py-3 text-sm">
<span className="text-muted">{row.label}</span>
<span className="font-medium">{row.hoursLabel}</span>
</div>
))}
</Card>
</section>
)}
{(hasSocial || phone || email) && (
<section>
<h3 className="text-muted mb-3 text-xs font-bold tracking-widest uppercase">Social &amp; Contact</h3>
<div className="grid grid-cols-2 gap-3">
{phone && (
<a href={`tel:${phone}`} className="border-line flex items-center justify-center gap-2 rounded-xl border bg-white py-4 text-sm font-bold">
<Phone className="text-accent h-4 w-4" aria-hidden="true" /> Call
</a>
)}
{email && (
<a href={`mailto:${email}`} className="border-line flex items-center justify-center gap-2 rounded-xl border bg-white py-4 text-sm font-bold">
<Mail className="text-accent h-4 w-4" aria-hidden="true" /> Email
</a>
)}
{website && (
<a
href={website}
target="_blank"
rel="noopener noreferrer"
className="border-line flex items-center justify-center gap-2 rounded-xl border bg-white py-4 text-sm font-bold"
>
<Globe className="text-accent h-4 w-4" aria-hidden="true" /> Website
</a>
)}
{instagramUrl && (
<a
href={instagramUrl.startsWith("http") ? instagramUrl : `https://instagram.com/${instagramUrl.replace(/^@/, "")}`}
target="_blank"
rel="noopener noreferrer"
className="border-line flex items-center justify-center gap-2 rounded-xl border bg-white py-4 text-sm font-bold"
>
<AtSign className="text-accent h-4 w-4" aria-hidden="true" /> Instagram
</a>
)}
{facebookUrl && (
<a
href={facebookUrl.startsWith("http") ? facebookUrl : `https://${facebookUrl}`}
target="_blank"
rel="noopener noreferrer"
className="border-line flex items-center justify-center gap-2 rounded-xl border bg-white py-4 text-sm font-bold"
>
<Share2 className="text-accent h-4 w-4" aria-hidden="true" /> Facebook
</a>
)}
</div>
</section>
)}
</div>
);
}