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
@@ -3,18 +3,19 @@ import { Star } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { auth } from "@/auth";
|
||||
import { AppointmentCard } from "@/components/ui/appointment-card";
|
||||
import { BarberCard } from "@/components/ui/barber-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { ServiceCard } from "@/components/ui/service-card";
|
||||
import { Weekday } from "@/generated/prisma/enums";
|
||||
import { ImageGallery } from "@/components/ui/image-gallery";
|
||||
import { GalleryPlacement, Weekday } from "@/generated/prisma/enums";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { computeOpeningStatus } from "@/lib/business/opening-status";
|
||||
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getAvailableServices } from "@/services/scheduling/availability.service";
|
||||
import { listGalleryImages } from "@/services/gallery/gallery.service";
|
||||
import { getLoyaltySummary, type LoyaltySummary } from "@/services/loyalty/loyalty.service";
|
||||
import { getBusinessReviews } from "@/services/reviews/reviews.service";
|
||||
import { HomeTabs } from "@/features/home/components/home-tabs";
|
||||
import type { AppointmentStatus } from "@/types/domain";
|
||||
|
||||
export const metadata: Metadata = { title: "Home" };
|
||||
@@ -49,18 +50,50 @@ const STATUS_MAP: Record<string, AppointmentStatus> = {
|
||||
export default async function CustomerHomePage() {
|
||||
const [business, session] = await Promise.all([getPrimaryBusiness(), auth()]);
|
||||
|
||||
const [services, staff, reviews] = await Promise.all([
|
||||
const [services, categories, staff, reviews, heroImages, portfolioImages] = await Promise.all([
|
||||
getAvailableServices(prisma, { businessId: business.id }),
|
||||
prisma.serviceCategory.findMany({ where: { businessId: business.id, isActive: true }, orderBy: { displayOrder: "asc" } }),
|
||||
prisma.staffProfile.findMany({
|
||||
where: { businessId: business.id, status: "ACTIVE", isBookableOnline: true },
|
||||
include: { user: { select: { name: true, image: true } } },
|
||||
}),
|
||||
getBusinessReviews(business.id),
|
||||
listGalleryImages(prisma, { businessId: business.id, placement: GalleryPlacement.HERO, activeOnly: true }),
|
||||
listGalleryImages(prisma, { businessId: business.id, placement: GalleryPlacement.PORTFOLIO, activeOnly: true }),
|
||||
]);
|
||||
|
||||
const openingStatus = computeOpeningStatus(business.hours, business.timezone);
|
||||
const location = business.locations[0];
|
||||
const previewServices = services.slice(0, 4);
|
||||
|
||||
// Hero: owner-managed gallery first; falls back to a single cover/logo
|
||||
// image (still fully functional with zero gallery rows configured —
|
||||
// see docs/phase-9-summary.md, "Home Screen Redesign").
|
||||
const heroGalleryImages =
|
||||
heroImages.length > 0
|
||||
? heroImages.map((i) => ({ id: i.id, url: i.imageUrl, caption: i.caption }))
|
||||
: business.coverImageUrl || business.logoUrl
|
||||
? [{ id: "fallback", url: (business.coverImageUrl ?? business.logoUrl)!, caption: null }]
|
||||
: [];
|
||||
|
||||
const hoursRows = WEEKDAY_ORDER.map((day) => {
|
||||
const dayHours = business.hours.find((h) => h.dayOfWeek === day);
|
||||
const hoursLabel =
|
||||
!dayHours || dayHours.isClosed || dayHours.opensAtMinutes === null || dayHours.closesAtMinutes === null
|
||||
? "Closed"
|
||||
: `${String(Math.floor(dayHours.opensAtMinutes / 60)).padStart(2, "0")}:${String(dayHours.opensAtMinutes % 60).padStart(2, "0")} – ${String(Math.floor(dayHours.closesAtMinutes / 60)).padStart(2, "0")}:${String(dayHours.closesAtMinutes % 60).padStart(2, "0")}`;
|
||||
return { label: WEEKDAY_LABEL[day], hoursLabel };
|
||||
});
|
||||
|
||||
// Keyless Google Maps embed — no API key/SDK needed for a simple pinned
|
||||
// map, unlike the JS Maps SDK. Prefers exact coordinates; falls back to
|
||||
// the street address when they're not set.
|
||||
const mapQuery =
|
||||
location?.latitude && location?.longitude
|
||||
? `${location.latitude},${location.longitude}`
|
||||
: location
|
||||
? `${location.addressLine1}, ${location.city} ${location.postalCode}`
|
||||
: null;
|
||||
const mapEmbedSrc = mapQuery ? `https://www.google.com/maps?q=${encodeURIComponent(mapQuery)}&output=embed` : null;
|
||||
|
||||
type UpcomingAppointment = Prisma.AppointmentGetPayload<{
|
||||
include: { services: { include: { service: true } }; staff: { include: { user: true } } };
|
||||
@@ -107,13 +140,12 @@ export default async function CustomerHomePage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Hero */}
|
||||
<section className="bg-ink relative h-56 w-full overflow-hidden">
|
||||
{business.logoUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={business.logoUrl} alt="" className="h-full w-full object-cover opacity-80" />
|
||||
) : null}
|
||||
</section>
|
||||
{/* Hero — an owner-managed gallery (see /owner/settings/gallery), matching the content width rather than bleeding edge-to-edge. */}
|
||||
{heroGalleryImages.length > 0 && (
|
||||
<div className="mx-auto max-w-2xl px-6 pt-4">
|
||||
<ImageGallery images={heroGalleryImages} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<main className="mx-auto max-w-2xl bg-white px-6 py-6">
|
||||
{session?.user && (
|
||||
@@ -127,7 +159,7 @@ export default async function CustomerHomePage() {
|
||||
{location.addressLine1}, {location.city} {location.postalCode}
|
||||
</p>
|
||||
)}
|
||||
<div className="mb-4 flex items-center gap-3 text-sm">
|
||||
<div className="mb-6 flex items-center gap-3 text-sm">
|
||||
<div className="text-accent-loyalty flex items-center gap-1">
|
||||
<Star className="h-4 w-4 fill-current" aria-hidden="true" />
|
||||
<span className="text-ink font-bold">{reviews.averageRating.toFixed(1)}</span>
|
||||
@@ -139,9 +171,6 @@ export default async function CustomerHomePage() {
|
||||
{openingStatus.label}
|
||||
</span>
|
||||
</div>
|
||||
{business.description && (
|
||||
<p className="text-muted mb-6 text-sm leading-relaxed">{business.description}</p>
|
||||
)}
|
||||
|
||||
{/* Upcoming appointment */}
|
||||
{upcomingAppointment && (
|
||||
@@ -239,134 +268,26 @@ export default async function CustomerHomePage() {
|
||||
<Link href="/book">Book an Appointment</Link>
|
||||
</Button>
|
||||
|
||||
{/* Services */}
|
||||
<section className="mb-8">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-muted text-xs font-bold tracking-widest uppercase">
|
||||
Popular Services
|
||||
</h2>
|
||||
<Link
|
||||
href="/book"
|
||||
className="text-ink text-[10px] font-bold tracking-widest uppercase underline"
|
||||
>
|
||||
See all
|
||||
</Link>
|
||||
</div>
|
||||
<Card className="divide-line divide-y px-5">
|
||||
{previewServices.map((service) => (
|
||||
<ServiceCard
|
||||
key={service.id}
|
||||
service={{
|
||||
id: service.id,
|
||||
name: service.name,
|
||||
durationMinutes: service.durationMinutes,
|
||||
formattedPrice: `£${Number(service.price).toFixed(2)}`,
|
||||
}}
|
||||
action={
|
||||
<Button asChild size="sm">
|
||||
<Link href="/book">Book</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Barbers */}
|
||||
<section className="mb-8">
|
||||
<h2 className="text-muted mb-3 text-xs font-bold tracking-widest uppercase">
|
||||
Our Barbers
|
||||
</h2>
|
||||
<div className="flex gap-5 overflow-x-auto pb-1">
|
||||
{staff.map((s) => (
|
||||
<BarberCard
|
||||
key={s.id}
|
||||
staff={{
|
||||
id: s.id,
|
||||
name: s.user.name ?? "Barber",
|
||||
avatarUrl: s.user.image ?? undefined,
|
||||
}}
|
||||
isPreferred={customerProfile?.preferredBarberId === s.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Reviews */}
|
||||
<section className="mb-8">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-muted text-xs font-bold tracking-widest uppercase">Reviews</h2>
|
||||
{reviews.isDemoData && (
|
||||
<span className="text-muted text-[9px] font-bold tracking-widest uppercase">
|
||||
Demo data
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{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>
|
||||
</section>
|
||||
|
||||
{/* Hours */}
|
||||
<section className="mb-4">
|
||||
<h2 className="text-muted mb-3 text-xs font-bold tracking-widest uppercase">
|
||||
Opening Hours
|
||||
</h2>
|
||||
<Card className="divide-line divide-y px-5">
|
||||
{WEEKDAY_ORDER.map((day) => {
|
||||
const dayHours = business.hours.find((h) => h.dayOfWeek === day);
|
||||
return (
|
||||
<div key={day} className="flex items-center justify-between py-3 text-sm">
|
||||
<span className="text-muted">{WEEKDAY_LABEL[day]}</span>
|
||||
<span className="font-medium">
|
||||
{!dayHours ||
|
||||
dayHours.isClosed ||
|
||||
dayHours.opensAtMinutes === null ||
|
||||
dayHours.closesAtMinutes === null
|
||||
? "Closed"
|
||||
: `${String(Math.floor(dayHours.opensAtMinutes / 60)).padStart(2, "0")}:${String(dayHours.opensAtMinutes % 60).padStart(2, "0")} – ${String(Math.floor(dayHours.closesAtMinutes / 60)).padStart(2, "0")}:${String(dayHours.closesAtMinutes % 60).padStart(2, "0")}`}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Contact */}
|
||||
<section>
|
||||
<h2 className="text-muted mb-3 text-xs font-bold tracking-widest uppercase">Contact</h2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{business.phone && (
|
||||
<a
|
||||
href={`tel:${business.phone}`}
|
||||
className="border-line rounded-xl border bg-white py-4 text-center text-sm font-bold"
|
||||
>
|
||||
Call
|
||||
</a>
|
||||
)}
|
||||
{business.email && (
|
||||
<a
|
||||
href={`mailto:${business.email}`}
|
||||
className="border-line rounded-xl border bg-white py-4 text-center text-sm font-bold"
|
||||
>
|
||||
Email
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
<HomeTabs
|
||||
services={services}
|
||||
categories={categories.map((c) => ({ id: c.id, name: c.name }))}
|
||||
portfolioImages={portfolioImages}
|
||||
reviews={reviews}
|
||||
description={business.description}
|
||||
phone={business.phone}
|
||||
email={business.email}
|
||||
website={business.website}
|
||||
instagramUrl={business.instagramUrl}
|
||||
facebookUrl={business.facebookUrl}
|
||||
hoursRows={hoursRows}
|
||||
staff={staff.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.user.name ?? "Barber",
|
||||
avatarUrl: s.user.image ?? undefined,
|
||||
isPreferred: customerProfile?.preferredBarberId === s.id,
|
||||
}))}
|
||||
mapEmbedSrc={mapEmbedSrc}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user