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
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user