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

+65 -144
View File
@@ -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>
);
+2 -1
View File
@@ -7,7 +7,8 @@ import { getUnreadNotificationCount } from "@/services/notifications/notificatio
const NAV_ITEMS: BottomNavItem[] = [
{ href: "/home", label: "Home", icon: "house" },
{ href: "/appointments", label: "Bookings", icon: "calendar-check" },
{ href: "/settings", label: "Settings", icon: "user" },
{ href: "/loyalty", label: "Loyalty", icon: "gift" },
{ href: "/settings", label: "Profile", icon: "user" },
];
/**
+3
View File
@@ -78,6 +78,9 @@ export default async function LoyaltyPage() {
stampsEarned={loyalty.currentStamps}
rewardLabel={loyalty.rewardLabel ?? "Reward"}
termsLine={loyalty.availableReward ? undefined : `${loyalty.stampsRequired - loyalty.currentStamps} more to your reward`}
cardBackgroundColor={loyalty.cardBackgroundColor}
cardBackgroundImageUrl={loyalty.cardBackgroundImageUrl}
stampIcon={loyalty.stampIcon}
/>
{loyalty.availableReward ? (
@@ -9,7 +9,7 @@ import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { getLoyaltySummary } from "@/services/loyalty/loyalty.service";
import { getAppointmentById } from "@/services/scheduling/appointment.service";
import { assertAppointmentInBusiness, StaffPermissionDeniedError } from "@/features/staff/authorization";
@@ -45,15 +45,18 @@ export default async function OwnerAppointmentDetailsPage({
const canView = await hasPermission(session.user.id, Permission.VIEW_ALL_STAFF_APPOINTMENTS);
if (!canView) redirect("/staff");
const owner = await getActingStaff();
if (!owner) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const { id } = await params;
const appointment = await getAppointmentById(prisma, id);
if (!appointment) notFound();
try {
assertAppointmentInBusiness(appointment, { id: owner.id, businessId: owner.businessId });
// `id` is unused by assertAppointmentInBusiness (only checks businessId)
// — see src/features/staff/authorization.ts. context.userId stands in
// for it since a Super Administrator has no StaffProfile id to offer.
assertAppointmentInBusiness(appointment, { id: context.userId, businessId: context.businessId });
} catch (error) {
if (error instanceof StaffPermissionDeniedError) notFound();
throw error;
@@ -70,13 +73,13 @@ export default async function OwnerAppointmentDetailsPage({
where: { appointmentId: appointment.id },
orderBy: { createdAt: "desc" },
}),
getLoyaltySummary(prisma, { customerId: appointment.customerId, businessId: owner.businessId }),
getLoyaltySummary(prisma, { customerId: appointment.customerId, businessId: context.businessId }),
appointment.cancelledByUserId
? prisma.user.findUnique({ where: { id: appointment.cancelledByUserId }, select: { name: true } })
: null,
]);
const tz = owner.business.timezone;
const tz = context.businessTimezone;
const totalDuration = appointment.services.reduce((sum, s) => sum + s.durationAtBookingMinutes, 0);
const subtotal = appointment.services.reduce((sum, s) => sum + Number(s.priceAtBooking), 0);
const discount = appointment.discountAmount ? Number(appointment.discountAmount) : 0;
+11 -11
View File
@@ -9,7 +9,7 @@ import { AppointmentCard } from "@/components/ui/appointment-card";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import {
calendarDateToUtcMidnight,
minutesFromMidnight,
@@ -61,19 +61,19 @@ export default async function OwnerCalendarPage({
const canView = await hasPermission(session.user.id, Permission.VIEW_ALL_STAFF_APPOINTMENTS);
if (!canView) redirect("/staff");
const owner = await getActingStaff();
if (!owner) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const { date: dateParam, view: viewParam, staffId: staffIdParam } = await searchParams;
const today = todayIn(owner.business.timezone);
const today = todayIn(context.businessTimezone);
const date: CalendarDate = dateParam ?? today;
const view = viewParam === "agenda" ? "agenda" : "day";
const weekday = weekdayOf(date);
const dateUtcMidnight = calendarDateToUtcMidnight(date);
const businessTimezone = owner.business.timezone;
const businessTimezone = context.businessTimezone;
const allStaff = await prisma.staffProfile.findMany({
where: { businessId: owner.businessId, status: "ACTIVE" },
where: { businessId: context.businessId, status: "ACTIVE" },
include: { user: { select: { id: true, name: true } } },
orderBy: { createdAt: "asc" },
});
@@ -81,22 +81,22 @@ export default async function OwnerCalendarPage({
const [appointments, workingHoursAll, breakDaysAll, timeOffAll] = await Promise.all([
listAppointments(prisma, {
businessId: owner.businessId,
businessId: context.businessId,
staffId: selectedStaff?.id,
fromDate: date,
toDate: date,
businessTimezone,
}),
prisma.workingHours.findMany({
where: { staff: { businessId: owner.businessId }, dayOfWeek: weekday },
where: { staff: { businessId: context.businessId }, dayOfWeek: weekday },
}),
prisma.breakDay.findMany({
where: { dayOfWeek: weekday, break: { staff: { businessId: owner.businessId } } },
where: { dayOfWeek: weekday, break: { staff: { businessId: context.businessId } } },
include: { break: true },
}),
prisma.timeOff.findMany({
where: {
staff: { businessId: owner.businessId },
staff: { businessId: context.businessId },
status: "APPROVED",
startDate: { lte: dateUtcMidnight },
endDate: { gte: dateUtcMidnight },
@@ -105,7 +105,7 @@ export default async function OwnerCalendarPage({
]);
const rewardFlags = await getAvailableRewardFlags(prisma, {
businessId: owner.businessId,
businessId: context.businessId,
customerIds: [...new Set(appointments.map((a) => a.customerId))],
});
+4 -4
View File
@@ -2,7 +2,7 @@ import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { listInAppNotifications } from "@/services/notifications/notification.service";
import { NotificationList } from "@/features/notifications/components/notification-list";
@@ -11,10 +11,10 @@ export const dynamic = "force-dynamic";
/** Access to this route is already gated by (owner)/layout.tsx's MANAGE_STAFF check — this page only fetches this owner's own notifications, same as the staff/customer variants. */
export default async function OwnerNotificationsPage() {
const staff = await getActingStaff();
if (!staff) redirect("/home");
const context = await getActingBusinessContext();
if (!context) redirect("/home");
const notifications = await listInAppNotifications(prisma, { userId: staff.userId });
const notifications = await listInAppNotifications(prisma, { userId: context.userId });
return (
<div className="pb-12">
+7 -7
View File
@@ -17,7 +17,7 @@ import { Card } from "@/components/ui/card";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { todayIn } from "@/lib/time/business-time";
import { getDashboardSummary } from "@/services/reporting/owner-reporting.service";
@@ -44,13 +44,13 @@ export default async function OwnerDashboardPage() {
const canView = await hasPermission(session.user.id, Permission.VIEW_REPORTING);
if (!canView) redirect("/staff");
const staff = await getActingStaff();
if (!staff) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const today = todayIn(staff.business.timezone);
const today = todayIn(context.businessTimezone);
const summary = await getDashboardSummary(prisma, {
businessId: staff.businessId,
businessTimezone: staff.business.timezone,
businessId: context.businessId,
businessTimezone: context.businessTimezone,
date: today,
});
@@ -58,7 +58,7 @@ export default async function OwnerDashboardPage() {
<div className="space-y-6 px-6 pt-12 pb-12">
<div>
<h1 className="text-2xl font-bold tracking-tight">Dashboard</h1>
<p className="text-muted text-sm">{staff.business.name} — today</p>
<p className="text-muted text-sm">{context.businessName} — today</p>
</div>
{summary.warnings.length > 0 && (
+10 -10
View File
@@ -6,7 +6,7 @@ import { Card } from "@/components/ui/card";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { addDays, endOfBusinessDay, startOfBusinessDay, todayIn } from "@/lib/time/business-time";
import type { CalendarDate } from "@/lib/time/business-time";
import {
@@ -48,10 +48,10 @@ export default async function OwnerReportsPage({
const canView = await hasPermission(session.user.id, Permission.VIEW_REPORTING);
if (!canView) redirect("/staff");
const staff = await getActingStaff();
if (!staff) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const tz = staff.business.timezone;
const tz = context.businessTimezone;
const today = todayIn(tz);
const { from: fromParam, to: toParam } = await searchParams;
const toDate: CalendarDate = toParam ?? today;
@@ -61,12 +61,12 @@ export default async function OwnerReportsPage({
const [appointmentReport, serviceReport, staffReport, customerReport, loyaltyReport, notificationReport] =
await Promise.all([
getAppointmentReport(prisma, { businessId: staff.businessId, range }),
getServiceReport(prisma, { businessId: staff.businessId, range }),
getStaffReport(prisma, { businessId: staff.businessId, range }),
getCustomerReport(prisma, { businessId: staff.businessId, range }),
getLoyaltyReport(prisma, { businessId: staff.businessId, range }),
getNotificationReport(prisma, { businessId: staff.businessId, range }),
getAppointmentReport(prisma, { businessId: context.businessId, range }),
getServiceReport(prisma, { businessId: context.businessId, range }),
getStaffReport(prisma, { businessId: context.businessId, range }),
getCustomerReport(prisma, { businessId: context.businessId, range }),
getLoyaltyReport(prisma, { businessId: context.businessId, range }),
getNotificationReport(prisma, { businessId: context.businessId, range }),
]);
return (
+5 -4
View File
@@ -4,7 +4,7 @@ import { auth } from "@/auth";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { listCategories } from "@/services/catalogue/service-catalogue.service";
import { CatalogueManager } from "@/features/owner/components/catalogue-manager";
@@ -18,10 +18,10 @@ export default async function OwnerServicesPage() {
const canManage = await hasPermission(session.user.id, Permission.MANAGE_SERVICES);
if (!canManage) redirect("/staff");
const owner = await getActingStaff();
if (!owner) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const categories = await listCategories(prisma, owner.businessId);
const categories = await listCategories(prisma, context.businessId);
return (
<div className="px-6 pt-12 pb-12 lg:max-w-3xl">
@@ -36,6 +36,7 @@ export default async function OwnerServicesPage() {
categoryId: s.categoryId,
name: s.name,
description: s.description ?? "",
imageUrl: s.imageUrl,
durationMinutes: s.durationMinutes,
price: s.price.toString(),
bufferBeforeMinutes: s.bufferBeforeMinutes,
@@ -1,11 +1,11 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { TopNav } from "@/components/navigation/top-nav";
import { SettingsPageShell } from "@/components/settings/settings-page-shell";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { getBookingRule } from "@/services/scheduling/booking-rule.service";
import { BookingRulesForm } from "@/features/owner/components/booking-rules-form";
@@ -19,33 +19,30 @@ export default async function OwnerBookingRulesPage() {
const canManage = await hasPermission(session.user.id, Permission.MANAGE_BOOKING_RULES);
if (!canManage) redirect("/staff");
const owner = await getActingStaff();
if (!owner) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const rule = await getBookingRule(prisma, owner.businessId);
const rule = await getBookingRule(prisma, context.businessId);
if (!rule) redirect("/owner");
return (
<div className="pb-12">
<TopNav title="Booking Rules" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl px-6 py-6 lg:max-w-4xl">
<BookingRulesForm
initial={{
minNoticeMinutes: rule.minNoticeMinutes,
maxAdvanceDays: rule.maxAdvanceDays,
cancellationWindowHours: rule.cancellationWindowHours,
rescheduleWindowHours: rule.rescheduleWindowHours,
slotIntervalMinutes: rule.slotIntervalMinutes,
bufferBeforeMinutes: rule.bufferBeforeMinutes,
bufferAfterMinutes: rule.bufferAfterMinutes,
maxAppointmentsPerDay: rule.maxAppointmentsPerDay,
maxAppointmentsPerStaffPerDay: rule.maxAppointmentsPerStaffPerDay,
customerCanChooseStaff: rule.customerCanChooseStaff,
allowGuestBooking: rule.allowGuestBooking,
requireApproval: rule.requireApproval,
}}
/>
</main>
</div>
<SettingsPageShell title="Booking Rules">
<BookingRulesForm
initial={{
minNoticeMinutes: rule.minNoticeMinutes,
maxAdvanceDays: rule.maxAdvanceDays,
cancellationWindowHours: rule.cancellationWindowHours,
rescheduleWindowHours: rule.rescheduleWindowHours,
slotIntervalMinutes: rule.slotIntervalMinutes,
bufferBeforeMinutes: rule.bufferBeforeMinutes,
bufferAfterMinutes: rule.bufferAfterMinutes,
maxAppointmentsPerDay: rule.maxAppointmentsPerDay,
maxAppointmentsPerStaffPerDay: rule.maxAppointmentsPerStaffPerDay,
customerCanChooseStaff: rule.customerCanChooseStaff,
allowGuestBooking: rule.allowGuestBooking,
requireApproval: rule.requireApproval,
}}
/>
</SettingsPageShell>
);
}
@@ -1,11 +1,11 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { TopNav } from "@/components/navigation/top-nav";
import { SettingsPageShell } from "@/components/settings/settings-page-shell";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { getBusinessProfile } from "@/services/business/business.service";
import { BusinessDetailsForm } from "@/features/owner/components/business-details-form";
@@ -19,23 +19,28 @@ export default async function OwnerBusinessDetailsPage() {
const canManage = await hasPermission(session.user.id, Permission.MANAGE_BUSINESS_PROFILE);
if (!canManage) redirect("/staff");
const staff = await getActingStaff();
if (!staff) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const business = await getBusinessProfile(prisma, staff.businessId);
const business = await getBusinessProfile(prisma, context.businessId);
if (!business) redirect("/owner");
const location = business.locations[0];
return (
<div className="pb-12">
<TopNav title="Business Details" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl px-6 py-6 lg:max-w-4xl">
<BusinessDetailsForm
<SettingsPageShell title="Business Details">
<BusinessDetailsForm
initial={{
name: business.name,
description: business.description ?? "",
logoUrl: business.logoUrl ?? "",
logoUrl: business.logoUrl,
coverImageUrl: business.coverImageUrl,
welcomeBackgroundDesktopUrl: business.welcomeBackgroundDesktopUrl,
welcomeBackgroundMobileUrl: business.welcomeBackgroundMobileUrl,
faviconUrl: business.faviconUrl,
appIconUrl: business.appIconUrl,
themeColor: business.themeColor,
backgroundColor: business.backgroundColor,
phone: business.phone ?? "",
email: business.email ?? "",
website: business.website ?? "",
@@ -54,9 +59,8 @@ export default async function OwnerBusinessDetailsPage() {
lateArrivalPolicy: business.settings?.lateArrivalPolicy ?? "",
bookingTerms: business.settings?.bookingTerms ?? "",
privacyPolicyUrl: business.settings?.privacyPolicyUrl ?? "",
}}
/>
</main>
</div>
}}
/>
</SettingsPageShell>
);
}
@@ -0,0 +1,49 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { SettingsPageShell } from "@/components/settings/settings-page-shell";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { GalleryPlacement } from "@/generated/prisma/enums";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { listGalleryImages } from "@/services/gallery/gallery.service";
import { GalleryManager } from "@/features/owner/components/gallery-manager";
export const metadata: Metadata = { title: "Gallery" };
export const dynamic = "force-dynamic";
export default async function OwnerGalleryPage() {
const session = await auth();
if (!session?.user) redirect("/sign-in");
const canManage = await hasPermission(session.user.id, Permission.MANAGE_BUSINESS_PROFILE);
if (!canManage) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const [heroImages, portfolioImages] = await Promise.all([
listGalleryImages(prisma, { businessId: context.businessId, placement: GalleryPlacement.HERO }),
listGalleryImages(prisma, { businessId: context.businessId, placement: GalleryPlacement.PORTFOLIO }),
]);
return (
<SettingsPageShell title="Gallery" mainClassName="space-y-8">
<GalleryManager
placement={GalleryPlacement.HERO}
title="Home Hero Gallery"
description="Shown at the top of the customer Home page. Falls back to your cover image, then your logo, when empty."
showCategory={false}
initialImages={heroImages}
/>
<GalleryManager
placement={GalleryPlacement.PORTFOLIO}
title="Portfolio"
description="Shown in the Portfolio tab on Home. Add a category to each image to power the filter pills there."
showCategory
initialImages={portfolioImages}
/>
</SettingsPageShell>
);
}
+8 -11
View File
@@ -1,11 +1,11 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { TopNav } from "@/components/navigation/top-nav";
import { SettingsPageShell } from "@/components/settings/settings-page-shell";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { getOpeningHours } from "@/services/business/business.service";
import { OpeningHoursForm } from "@/features/owner/components/opening-hours-form";
@@ -19,17 +19,14 @@ export default async function OwnerOpeningHoursPage() {
const canManage = await hasPermission(session.user.id, Permission.MANAGE_BUSINESS_PROFILE);
if (!canManage) redirect("/staff");
const staff = await getActingStaff();
if (!staff) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const hours = await getOpeningHours(prisma, staff.businessId);
const hours = await getOpeningHours(prisma, context.businessId);
return (
<div className="pb-12">
<TopNav title="Opening Hours" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl px-6 py-6">
<OpeningHoursForm initialRows={hours} />
</main>
</div>
<SettingsPageShell title="Opening Hours" mainClassName="lg:max-w-2xl">
<OpeningHoursForm initialRows={hours} />
</SettingsPageShell>
);
}
+17 -19
View File
@@ -1,11 +1,11 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { TopNav } from "@/components/navigation/top-nav";
import { SettingsPageShell } from "@/components/settings/settings-page-shell";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { getProgrammeConfig } from "@/services/loyalty/loyalty.service";
import { LoyaltyConfigForm } from "@/features/owner/components/loyalty-config-form";
@@ -19,14 +19,14 @@ export default async function OwnerLoyaltyConfigPage() {
const canManage = await hasPermission(session.user.id, Permission.MANAGE_LOYALTY_PROGRAMME);
if (!canManage) redirect("/staff");
const owner = await getActingStaff();
if (!owner) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const [programme, services, staff] = await Promise.all([
getProgrammeConfig(prisma, owner.businessId),
prisma.service.findMany({ where: { businessId: owner.businessId, isActive: true }, orderBy: { displayOrder: "asc" } }),
getProgrammeConfig(prisma, context.businessId),
prisma.service.findMany({ where: { businessId: context.businessId, isActive: true }, orderBy: { displayOrder: "asc" } }),
prisma.staffProfile.findMany({
where: { businessId: owner.businessId, status: "ACTIVE" },
where: { businessId: context.businessId, status: "ACTIVE" },
include: { user: { select: { name: true } } },
}),
]);
@@ -62,17 +62,15 @@ export default async function OwnerLoyaltyConfigPage() {
};
return (
<div className="pb-12">
<TopNav title="Loyalty Programme" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl px-6 py-6 lg:max-w-4xl">
<LoyaltyConfigForm
initialConfig={initialConfig}
initialEligibleServiceIds={programme?.eligibleServices.map((s) => s.serviceId) ?? []}
initialEligibleStaffIds={programme?.eligibleStaff.map((s) => s.staffId) ?? []}
services={services.map((s) => ({ id: s.id, name: s.name }))}
staff={staff.map((s) => ({ id: s.id, name: s.user.name ?? "Staff" }))}
/>
</main>
</div>
<SettingsPageShell title="Loyalty Programme">
<LoyaltyConfigForm
businessName={context.businessName}
initialConfig={initialConfig}
initialEligibleServiceIds={programme?.eligibleServices.map((s) => s.serviceId) ?? []}
initialEligibleStaffIds={programme?.eligibleStaff.map((s) => s.staffId) ?? []}
services={services.map((s) => ({ id: s.id, name: s.name }))}
staff={staff.map((s) => ({ id: s.id, name: s.user.name ?? "Staff" }))}
/>
</SettingsPageShell>
);
}
+3 -1
View File
@@ -1,6 +1,6 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Bell, Building2, CalendarClock, ClipboardList, Crown, ScissorsSquare } from "lucide-react";
import { Bell, Building2, CalendarClock, ClipboardList, Crown, Images, ScissorsSquare, Star } from "lucide-react";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { ProfileCard } from "@/components/ui/profile-card";
@@ -15,9 +15,11 @@ export const dynamic = "force-dynamic";
const SETTINGS_LINKS = [
{ href: "/owner/settings/business", label: "Business Details", icon: Building2 },
{ href: "/owner/settings/gallery", label: "Gallery", icon: Images },
{ href: "/owner/settings/hours", label: "Opening Hours", icon: CalendarClock },
{ href: "/owner/settings/booking-rules", label: "Booking Rules", icon: ClipboardList },
{ href: "/owner/settings/loyalty", label: "Loyalty Programme", icon: Crown },
{ href: "/owner/settings/reviews", label: "Reviews", icon: Star },
{ href: "/staff/settings/notifications", label: "Notifications", icon: Bell },
] as const;
@@ -0,0 +1,51 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { SettingsPageShell } from "@/components/settings/settings-page-shell";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { getBusinessProfile } from "@/services/business/business.service";
import { listReviewsForOwner } from "@/services/reviews/review-management.service";
import { ReviewsManager } from "@/features/owner/components/reviews-manager";
export const metadata: Metadata = { title: "Reviews" };
export const dynamic = "force-dynamic";
export default async function OwnerReviewsPage() {
const session = await auth();
if (!session?.user) redirect("/sign-in");
const canManage = await hasPermission(session.user.id, Permission.MANAGE_REVIEWS);
if (!canManage) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const [business, reviews] = await Promise.all([
getBusinessProfile(prisma, context.businessId),
listReviewsForOwner(prisma, context.businessId),
]);
if (!business) redirect("/owner");
return (
<SettingsPageShell title="Reviews">
<ReviewsManager
initialGooglePlaceId={business.googlePlaceId ?? ""}
initialReviewsMinRating={business.settings?.reviewsMinRating ?? null}
initialReviewsExcludeRatingOnly={business.settings?.reviewsExcludeRatingOnly ?? false}
googleRating={business.googleRating ? Number(business.googleRating) : null}
googleRatingCount={business.googleRatingCount}
reviews={reviews.map((r) => ({
id: r.id,
authorName: r.authorName,
rating: r.rating,
text: r.text,
relativeTimeDescription: r.relativeTimeDescription,
isHidden: r.isHidden,
}))}
/>
</SettingsPageShell>
);
}
@@ -5,7 +5,7 @@ import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { listBreaks } from "@/services/staff/staff.service";
import { BreaksList } from "@/features/staff/components/breaks-list";
import { createOwnerBreakActionSet } from "@/features/owner/actions/break-action-set";
@@ -20,12 +20,12 @@ export default async function OwnerStaffBreaksPage({ params }: { params: Promise
const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
if (!canManage) redirect("/staff");
const owner = await getActingStaff();
if (!owner) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const { id } = await params;
const target = await prisma.staffProfile.findFirst({
where: { id, businessId: owner.businessId },
where: { id, businessId: context.businessId },
include: { user: { select: { name: true } } },
});
if (!target) notFound();
@@ -5,7 +5,7 @@ import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission, Weekday } from "@/generated/prisma/enums";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { getWorkingHours } from "@/services/staff/staff.service";
import { WorkingHoursForm } from "@/features/staff/components/working-hours-form";
import { updateStaffWorkingHoursAction } from "@/features/owner/actions/staff";
@@ -36,19 +36,19 @@ export default async function OwnerStaffWorkingHoursPage({ params }: { params: P
const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
if (!canManage) redirect("/staff");
const owner = await getActingStaff();
if (!owner) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const { id } = await params;
const target = await prisma.staffProfile.findFirst({
where: { id, businessId: owner.businessId },
where: { id, businessId: context.businessId },
include: { user: { select: { name: true } } },
});
if (!target) notFound();
const [workingHours, businessHours] = await Promise.all([
getWorkingHours(prisma, id),
prisma.businessHours.findMany({ where: { businessId: owner.businessId } }),
prisma.businessHours.findMany({ where: { businessId: context.businessId } }),
]);
const businessHoursLabel = Object.fromEntries(
+6 -6
View File
@@ -8,7 +8,7 @@ import { Card } from "@/components/ui/card";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { getStaffDetailById, listRolesForBusiness } from "@/services/staff/staff.service";
import { StaffDetailPanel } from "@/features/owner/components/staff-detail-panel";
@@ -22,14 +22,14 @@ export default async function OwnerStaffDetailPage({ params }: { params: Promise
const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
if (!canManage) redirect("/staff");
const owner = await getActingStaff();
if (!owner) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const { id } = await params;
const [target, roles, allServices, calendarConnection] = await Promise.all([
getStaffDetailById(prisma, { staffId: id, businessId: owner.businessId }),
listRolesForBusiness(prisma, owner.businessId),
prisma.service.findMany({ where: { businessId: owner.businessId, isActive: true }, orderBy: { displayOrder: "asc" } }),
getStaffDetailById(prisma, { staffId: id, businessId: context.businessId }),
listRolesForBusiness(prisma, context.businessId),
prisma.service.findMany({ where: { businessId: context.businessId, isActive: true }, orderBy: { displayOrder: "asc" } }),
// Status only — never tokens (see the brief's "Owners must not see
// OAuth tokens, refresh tokens, or unrelated private calendar
// events"). Owners can see whether a barber has connected, not
@@ -5,7 +5,7 @@ import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { listTimeOff } from "@/services/staff/staff.service";
import { TimeOffList } from "@/features/staff/components/time-off-list";
import { createOwnerTimeOffActionSet } from "@/features/owner/actions/time-off-action-set";
@@ -24,12 +24,12 @@ export default async function OwnerStaffTimeOffPage({ params }: { params: Promis
const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
if (!canManage) redirect("/staff");
const owner = await getActingStaff();
if (!owner) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const { id } = await params;
const target = await prisma.staffProfile.findFirst({
where: { id, businessId: owner.businessId },
where: { id, businessId: context.businessId },
include: { user: { select: { name: true } } },
});
if (!target) notFound();
+5 -5
View File
@@ -5,7 +5,7 @@ import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { listRolesForBusiness } from "@/services/staff/staff.service";
import { AddStaffForm } from "@/features/owner/components/add-staff-form";
@@ -19,12 +19,12 @@ export default async function OwnerAddStaffPage() {
const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
if (!canManage) redirect("/staff");
const owner = await getActingStaff();
if (!owner) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const [roles, services] = await Promise.all([
listRolesForBusiness(prisma, owner.businessId),
prisma.service.findMany({ where: { businessId: owner.businessId, isActive: true }, orderBy: { displayOrder: "asc" } }),
listRolesForBusiness(prisma, context.businessId),
prisma.service.findMany({ where: { businessId: context.businessId, isActive: true }, orderBy: { displayOrder: "asc" } }),
]);
return (
+4 -4
View File
@@ -6,7 +6,7 @@ import { auth } from "@/auth";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { listStaffForBusiness } from "@/services/staff/staff.service";
import { cn } from "@/lib/utils/cn";
@@ -24,11 +24,11 @@ export default async function OwnerTeamPage({
const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
if (!canManage) redirect("/staff");
const owner = await getActingStaff();
if (!owner) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const { q = "" } = await searchParams;
const staff = await listStaffForBusiness(prisma, { businessId: owner.businessId, q, includeInactive: true });
const staff = await listStaffForBusiness(prisma, { businessId: context.businessId, q, includeInactive: true });
return (
<div className="px-6 pt-12 pb-12 lg:max-w-4xl">
@@ -0,0 +1,37 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db/prisma";
import { isInstalledForRequest } from "@/lib/installation/installation-state";
import { secretsMatch } from "@/lib/security/secrets-match";
import { syncGoogleReviewsForAllBusinesses } from "@/services/reviews/review-sync.service";
/**
* External trigger for the Google Reviews sync — mirrors
* /api/cron/process-notifications exactly (same CRON_SECRET bearer-token
* auth, same 503-if-unconfigured / 401-if-wrong / skip-if-not-installed
* shape). Recommended frequency: every 24 hours — reviews don't change
* fast enough to justify Google Places API calls any more often than
* that, and the customer app never calls Google directly regardless (see
* src/services/reviews/reviews.service.ts).
*
* Safe to run concurrently with itself: syncGoogleReviewsForBusiness's
* Review upsert is keyed on (businessId, externalId), so an overlapping
* call just re-writes the same rows, never duplicates them.
*/
export async function POST(request: Request) {
const secret = process.env.CRON_SECRET;
if (!secret) {
return NextResponse.json({ error: "CRON_SECRET is not configured on this deployment." }, { status: 503 });
}
const provided = request.headers.get("authorization");
if (!provided || !secretsMatch(provided, `Bearer ${secret}`)) {
return NextResponse.json({ error: "Unauthorized." }, { status: 401 });
}
if (!(await isInstalledForRequest())) {
return NextResponse.json({ skipped: "not_installed" });
}
const results = await syncGoogleReviewsForAllBusinesses(prisma);
return NextResponse.json({ results });
}
+60 -37
View File
@@ -33,50 +33,73 @@ export default async function WelcomePage() {
const [branding, business] = await Promise.all([getBranding(), getPrimaryBusiness()]);
const guestBookingEnabled = business.bookingRule?.allowGuestBooking ?? false;
return (
<div id="main-content" className="flex min-h-screen flex-col justify-end bg-ink px-8 pb-16 text-white">
<div className="mb-8 flex h-16 w-16 items-center justify-center rounded-2xl bg-white text-ink">
{branding.logoUrl ? (
// eslint-disable-next-line @next/next/no-img-element -- external, business-supplied URL; not a build-time-known local asset for next/image.
<img src={branding.logoUrl} alt="" className="h-full w-full rounded-2xl object-cover" />
) : (
<Scissors className="h-7 w-7" aria-hidden="true" />
)}
</div>
<h1 className="mb-4 text-5xl font-bold tracking-tight">{branding.name}</h1>
<p className="mb-12 max-w-xs text-lg text-white/70">
Book your next visit in seconds — services, staff and loyalty rewards in one place.
</p>
const hasBackground = !!(branding.welcomeBackgroundDesktopUrl || branding.welcomeBackgroundMobileUrl);
<div className="space-y-4">
<Button asChild variant="secondary" size="lg" className="w-full bg-white">
<Link href="/register">Create Account</Link>
</Button>
<Button
asChild
variant="secondary"
size="lg"
className="w-full border-2 border-white/20 bg-transparent text-white hover:bg-white/10"
>
<Link href="/sign-in">Sign In</Link>
</Button>
{guestBookingEnabled && (
return (
<div id="main-content" className="relative flex min-h-screen flex-col justify-end overflow-hidden bg-ink px-8 pb-16 text-white">
{/* Desktop/mobile are independent images (not one image cropped two ways) — see BrandingInfo.welcomeBackgroundDesktopUrl/MobileUrl. Falls back to the plain bg-ink above when neither is set. */}
{branding.welcomeBackgroundDesktopUrl && (
<div
className="absolute inset-0 hidden bg-cover bg-center md:block"
style={{ backgroundImage: `url(${branding.welcomeBackgroundDesktopUrl})` }}
aria-hidden="true"
/>
)}
{branding.welcomeBackgroundMobileUrl && (
<div
className="absolute inset-0 block bg-cover bg-center md:hidden"
style={{ backgroundImage: `url(${branding.welcomeBackgroundMobileUrl})` }}
aria-hidden="true"
/>
)}
{hasBackground && (
<div className="from-ink via-ink/60 absolute inset-0 bg-gradient-to-t to-transparent" aria-hidden="true" />
)}
<div className="relative z-10 flex flex-1 flex-col justify-end">
<div className="mb-8 flex h-16 w-16 items-center justify-center rounded-2xl bg-white text-ink">
{branding.logoUrl ? (
// eslint-disable-next-line @next/next/no-img-element -- external, business-supplied URL; not a build-time-known local asset for next/image.
<img src={branding.logoUrl} alt="" className="h-full w-full rounded-2xl object-cover" />
) : (
<Scissors className="h-7 w-7" aria-hidden="true" />
)}
</div>
<h1 className="mb-4 text-5xl font-bold tracking-tight">{branding.name}</h1>
<p className="mb-12 max-w-xs text-lg text-white/70">
Book your next visit in seconds — services, staff and loyalty rewards in one place.
</p>
<div className="space-y-4">
<Button asChild variant="secondary" size="lg" className="w-full bg-white">
<Link href="/register">Create Account</Link>
</Button>
<Button
asChild
variant="ghost"
variant="secondary"
size="lg"
className="w-full text-white/70 hover:bg-white/10 hover:text-white"
className="w-full border-2 border-white/20 bg-transparent text-white hover:bg-white/10"
>
<Link href="/book">Continue as Guest</Link>
<Link href="/sign-in">Sign In</Link>
</Button>
)}
<Button asChild variant="ghost" size="lg" className="w-full text-white/70 hover:bg-white/10 hover:text-white">
<Link href="/home">Browse Services</Link>
</Button>
</div>
{guestBookingEnabled && (
<Button
asChild
variant="ghost"
size="lg"
className="w-full text-white/70 hover:bg-white/10 hover:text-white"
>
<Link href="/book">Continue as Guest</Link>
</Button>
)}
<Button asChild variant="ghost" size="lg" className="w-full text-white/70 hover:bg-white/10 hover:text-white">
<Link href="/home">Browse Services</Link>
</Button>
</div>
<div className="mt-6">
<WelcomeInstallCard businessName={branding.name} />
<div className="mt-6">
<WelcomeInstallCard businessName={branding.name} />
</div>
</div>
</div>
);
+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>
@@ -88,9 +88,16 @@ export function ServiceSelector(props: {
aria-pressed={selected}
className="flex w-full items-center justify-between gap-4 px-5 py-4 text-left"
>
{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-12 w-12 shrink-0 rounded-xl object-cover" />
)}
<div className="flex-1">
<h3 className="text-sm font-medium">{service.name}</h3>
<p className="text-muted mt-1 text-xs">{service.durationMinutes}m</p>
{service.description && (
<p className="text-muted mt-1 text-xs line-clamp-2">{service.description}</p>
)}
</div>
<span className="font-bold">£{Number(service.price).toFixed(2)}</span>
<span
+79
View File
@@ -0,0 +1,79 @@
"use server";
import { auth } from "@/auth";
import { prisma } from "@/lib/db/prisma";
import { Permission } from "@/generated/prisma/enums";
import type { GalleryPlacement } from "@/generated/prisma/enums";
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import {
addGalleryImage,
deleteGalleryImage,
reorderGalleryImages,
updateGalleryImage,
} from "@/services/gallery/gallery.service";
import type { GalleryImageSummary } from "@/services/gallery/gallery.service";
export interface GalleryActionResult {
success: boolean;
error?: string;
}
/** Gallery/portfolio management is business-content, same as logo/cover — reuses MANAGE_BUSINESS_PROFILE rather than a new permission. */
async function requireGalleryManager() {
const session = await auth();
if (!session?.user) return { businessId: null, error: "You must be signed in." } as const;
try {
await assertOwnerPermission(session.user.id, Permission.MANAGE_BUSINESS_PROFILE);
} catch (error) {
if (error instanceof OwnerPermissionDeniedError) return { businessId: null, error: error.message } as const;
throw error;
}
const context = await getActingBusinessContext();
if (!context) return { businessId: null, error: "You must be signed in as staff." } as const;
return { businessId: context.businessId, error: null } as const;
}
export async function addGalleryImageAction(params: {
placement: GalleryPlacement;
imageUrl: string;
category?: string;
}): Promise<{ success: true; image: GalleryImageSummary } | { success: false; error: string }> {
const { businessId, error } = await requireGalleryManager();
if (!businessId) return { success: false, error: error ?? "Not authorized." };
const image = await addGalleryImage(prisma, { businessId, ...params });
return { success: true, image };
}
export async function updateGalleryImageAction(
id: string,
input: { category?: string | null; caption?: string | null; isActive?: boolean },
): Promise<GalleryActionResult> {
const { businessId, error } = await requireGalleryManager();
if (!businessId) return { success: false, error: error ?? "Not authorized." };
const result = await updateGalleryImage(prisma, { id, businessId, ...input });
if (!result.success) return { success: false, error: "Image not found." };
return { success: true };
}
export async function deleteGalleryImageAction(id: string): Promise<GalleryActionResult> {
const { businessId, error } = await requireGalleryManager();
if (!businessId) return { success: false, error: error ?? "Not authorized." };
const result = await deleteGalleryImage(prisma, { id, businessId });
if (!result.success) return { success: false, error: "Image not found." };
return { success: true };
}
export async function reorderGalleryImagesAction(placement: GalleryPlacement, orderedIds: string[]): Promise<GalleryActionResult> {
const { businessId, error } = await requireGalleryManager();
if (!businessId) return { success: false, error: error ?? "Not authorized." };
await reorderGalleryImages(prisma, { businessId, placement, orderedIds });
return { success: true };
}
+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>
);
}
+121
View File
@@ -0,0 +1,121 @@
"use server";
import { auth } from "@/auth";
import { prisma } from "@/lib/db/prisma";
import { Permission } from "@/generated/prisma/enums";
import { hasPermission } from "@/lib/auth/permissions";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import type { ActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { createMediaAsset, deleteMediaAsset, listMediaAssets } from "@/services/media/media.service";
import type { MediaAssetSummary } from "@/services/media/media.service";
import { imageSize } from "image-size";
import { isAllowedMediaMimeType, MEDIA_MAX_UPLOAD_BYTES, storeMediaFile } from "@/lib/media/media-storage";
/**
* File uploads travel through a Server Action, not a separate REST route
* — Next.js Server Actions accept FormData (including File values)
* natively, so this stays consistent with every other owner mutation in
* the app instead of introducing a one-off API route just for this.
*
* Uses getActingBusinessContext (not getActingStaff/a raw StaffProfile
* lookup) so a Super Administrator — who by design has no StaffProfile,
* see docs/phase-9-summary.md — can manage the media library too.
*
* Deliberately NOT gated on MANAGE_MEDIA_LIBRARY here: any signed-in
* staff member needs to add/browse images for fields they're otherwise
* allowed to edit (e.g. a barber uploading their own profile photo).
* What a URL actually gets SAVED to is still authorized by that specific
* field's own Server Action (updateProfileAction, updateBusinessProfileAction,
* etc.) exactly as before — this only gates "can add to the library at all".
*/
async function requireStaffOrSuperAdmin(): Promise<ActingBusinessContext> {
const session = await auth();
if (!session?.user?.id) throw new Error("You must be signed in.");
const context = await getActingBusinessContext();
if (!context) throw new Error("You must be signed in as staff.");
return context;
}
/** Deleting an image can affect fields other staff/other pages reference — restricted to MANAGE_MEDIA_LIBRARY (Owner/Super Admin), unlike upload/browse above. */
async function requireMediaLibraryManager(): Promise<ActingBusinessContext> {
const session = await auth();
if (!session?.user?.id) throw new Error("You must be signed in.");
if (!(await hasPermission(session.user.id, Permission.MANAGE_MEDIA_LIBRARY))) {
throw new Error("You don't have permission to delete media.");
}
const context = await getActingBusinessContext();
if (!context) throw new Error("No business context.");
return context;
}
export async function listMediaAssetsAction(params: {
category?: string;
q?: string;
}): Promise<{ success: true; assets: MediaAssetSummary[] } | { success: false; error: string }> {
try {
const context = await requireStaffOrSuperAdmin();
const assets = await listMediaAssets(prisma, { businessId: context.businessId, ...params });
return { success: true, assets };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : "Couldn't load the media library." };
}
}
export async function uploadMediaAssetAction(
formData: FormData,
): Promise<{ success: true; asset: MediaAssetSummary } | { success: false; error: string }> {
try {
const context = await requireStaffOrSuperAdmin();
const file = formData.get("file");
if (!(file instanceof File)) return { success: false, error: "No file provided." };
if (!isAllowedMediaMimeType(file.type)) {
return { success: false, error: "Unsupported image type — use PNG, JPEG, WEBP, or GIF." };
}
if (file.size > MEDIA_MAX_UPLOAD_BYTES) {
return { success: false, error: `File is too large — max ${Math.floor(MEDIA_MAX_UPLOAD_BYTES / 1_000_000)}MB.` };
}
const categoryRaw = formData.get("category");
const category = typeof categoryRaw === "string" && categoryRaw.trim() ? categoryRaw.trim() : "other";
const buffer = Buffer.from(await file.arrayBuffer());
let width: number | null = null;
let height: number | null = null;
try {
const dimensions = imageSize(buffer);
width = dimensions.width ?? null;
height = dimensions.height ?? null;
} catch {
// Best-effort — an unrecognised/corrupt header shouldn't block the upload.
}
const stored = await storeMediaFile({ buffer, mimeType: file.type, category });
const asset = await createMediaAsset(prisma, {
businessId: context.businessId,
url: stored.url,
originalFilename: file.name || null,
mimeType: file.type,
sizeBytes: file.size,
width,
height,
category,
uploadedByUserId: context.userId,
});
return { success: true, asset };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : "Upload failed." };
}
}
export async function deleteMediaAssetAction(id: string): Promise<{ success: boolean; error?: string }> {
try {
const context = await requireMediaLibraryManager();
const result = await deleteMediaAsset(prisma, { id, businessId: context.businessId });
if (!result.success) return { success: false, error: "That media item couldn't be found." };
return { success: true };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : "Delete failed." };
}
}
@@ -0,0 +1,235 @@
"use client";
import { useEffect, useState, useTransition } from "react";
import { Check, Copy, ImageOff, Link as LinkIcon, Search, Trash2, Upload } from "lucide-react";
import { Modal } from "@/components/ui/modal";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils/cn";
import { deleteMediaAssetAction, listMediaAssetsAction, uploadMediaAssetAction } from "@/features/media/actions/media";
import type { MediaAssetSummary } from "@/services/media/media.service";
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export interface MediaManagerModalProps {
open: boolean;
onClose: () => void;
onSelect: (url: string) => void;
/** Tags new uploads and pre-filters the Library tab — e.g. "branding", "loyalty", "staff", "service", "portfolio", "welcome". */
category: string;
}
/**
* The one Media Manager used everywhere an image field exists — see
* docs/phase-9-summary.md. Upload / Library / External URL, all in one
* dialog, backed by src/features/media/actions/media.ts (Server Actions,
* not a REST route — see that file's header for why).
*/
export function MediaManagerModal({ open, onClose, onSelect, category }: MediaManagerModalProps) {
const [tab, setTab] = useState("library");
const [assets, setAssets] = useState<MediaAssetSummary[]>([]);
const [query, setQuery] = useState("");
const [scope, setScope] = useState<"category" | "all">("category");
const [selectedAsset, setSelectedAsset] = useState<MediaAssetSummary | null>(null);
const [externalUrl, setExternalUrl] = useState("");
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
useEffect(() => {
if (!open) return;
startTransition(async () => {
const result = await listMediaAssetsAction({ category: scope === "category" ? category : undefined, q: query || undefined });
if (result.success) {
setAssets(result.assets);
setError(null);
} else {
setError(result.error);
}
});
}, [open, scope, query, category, startTransition]);
function handleUpload(file: File) {
setError(null);
const formData = new FormData();
formData.set("file", file);
formData.set("category", category);
startTransition(async () => {
const result = await uploadMediaAssetAction(formData);
if (!result.success) {
setError(result.error);
return;
}
onSelect(result.asset.url);
onClose();
});
}
function handleDelete(asset: MediaAssetSummary) {
startTransition(async () => {
const result = await deleteMediaAssetAction(asset.id);
if (!result.success) {
setError(result.error ?? "Couldn't delete that image.");
return;
}
setAssets((prev) => prev.filter((a) => a.id !== asset.id));
setSelectedAsset((prev) => (prev?.id === asset.id ? null : prev));
});
}
return (
<Modal open={open} onClose={onClose} title="Media Manager" className="max-w-2xl">
<Tabs value={tab} onValueChange={setTab}>
<TabsList>
<TabsTrigger value="library">Library</TabsTrigger>
<TabsTrigger value="upload">Upload</TabsTrigger>
<TabsTrigger value="url">External URL</TabsTrigger>
</TabsList>
{error && (
<p role="alert" className="text-danger mt-4 text-sm font-medium">
{error}
</p>
)}
<TabsContent value="library">
<div className="mb-4 flex items-center gap-2">
<div className="relative flex-1">
<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 by filename"
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>
<button
type="button"
onClick={() => setScope((s) => (s === "category" ? "all" : "category"))}
className="border-line shrink-0 rounded-xl border bg-white px-3 py-2.5 text-xs font-bold tracking-widest uppercase"
>
{scope === "category" ? "This field" : "All images"}
</button>
</div>
{isPending && assets.length === 0 && !error ? (
<p className="text-muted py-10 text-center text-sm">Loading…</p>
) : assets.length === 0 ? (
<div className="flex flex-col items-center py-10 text-center">
<ImageOff className="text-muted mb-3 h-8 w-8" aria-hidden="true" />
<p className="text-muted text-sm">No images yet — upload one to get started.</p>
</div>
) : (
<div className="grid max-h-96 grid-cols-3 gap-3 overflow-y-auto sm:grid-cols-4">
{assets.map((asset) => (
<button
key={asset.id}
type="button"
onClick={() => setSelectedAsset(asset)}
aria-pressed={selectedAsset?.id === asset.id}
className={cn(
"border-line relative aspect-square overflow-hidden rounded-xl border",
selectedAsset?.id === asset.id && "ring-ink ring-2",
)}
>
{/* eslint-disable-next-line @next/next/no-img-element -- local/user-supplied URLs, same pattern used app-wide */}
<img src={asset.url} alt={asset.originalFilename ?? ""} className="h-full w-full object-cover" />
{selectedAsset?.id === asset.id && (
<span className="bg-ink absolute top-1.5 right-1.5 flex h-5 w-5 items-center justify-center rounded-full text-white">
<Check className="h-3 w-3" aria-hidden="true" />
</span>
)}
</button>
))}
</div>
)}
{selectedAsset && (
<div className="bg-stone mt-4 rounded-xl p-4 text-xs">
<p className="mb-2 truncate font-bold">{selectedAsset.originalFilename ?? "Untitled"}</p>
<dl className="text-muted grid grid-cols-2 gap-y-1">
<div className="flex gap-1">
<dt className="font-bold">Size:</dt>
<dd>{formatBytes(selectedAsset.sizeBytes)}</dd>
</div>
<div className="flex gap-1">
<dt className="font-bold">Dimensions:</dt>
<dd>{selectedAsset.width && selectedAsset.height ? `${selectedAsset.width}×${selectedAsset.height}` : "Unknown"}</dd>
</div>
<div className="flex gap-1">
<dt className="font-bold">Uploaded:</dt>
<dd>{new Date(selectedAsset.createdAt).toLocaleDateString("en-GB")}</dd>
</div>
<div className="flex gap-1">
<dt className="font-bold">Type:</dt>
<dd>{selectedAsset.mimeType}</dd>
</div>
</dl>
<div className="mt-3 flex flex-wrap gap-2">
<Button
type="button"
size="sm"
onClick={() => {
onSelect(selectedAsset.url);
onClose();
}}
>
Use this image
</Button>
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => navigator.clipboard.writeText(new URL(selectedAsset.url, window.location.origin).toString())}
>
<Copy className="h-3.5 w-3.5" aria-hidden="true" /> Copy URL
</Button>
<Button type="button" size="sm" variant="destructive" isLoading={isPending} onClick={() => handleDelete(selectedAsset)}>
<Trash2 className="h-3.5 w-3.5" aria-hidden="true" /> Delete
</Button>
</div>
</div>
)}
</TabsContent>
<TabsContent value="upload">
<label className="border-line bg-stone flex cursor-pointer flex-col items-center gap-3 rounded-xl border-2 border-dashed px-6 py-12 text-center">
<Upload className="text-muted h-8 w-8" aria-hidden="true" />
<span className="text-sm font-bold">Choose an image to upload</span>
<span className="text-muted text-xs">PNG, JPEG, WEBP, or GIF — max 5MB</span>
<input
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
className="sr-only"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleUpload(file);
}}
/>
</label>
{isPending && <p className="text-muted mt-3 text-center text-sm">Uploading…</p>}
</TabsContent>
<TabsContent value="url">
<Input label="Image URL" placeholder="https://…" value={externalUrl} onChange={(e) => setExternalUrl(e.target.value)} />
<Button
type="button"
className="mt-4 w-full"
disabled={!externalUrl.trim()}
onClick={() => {
onSelect(externalUrl.trim());
onClose();
}}
>
<LinkIcon className="h-4 w-4" aria-hidden="true" /> Use this URL
</Button>
</TabsContent>
</Tabs>
</Modal>
);
}
@@ -0,0 +1,55 @@
"use client";
import { useState } from "react";
import { ImageOff, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { MediaManagerModal } from "@/features/media/components/media-manager-modal";
export interface MediaPickerProps {
label?: string;
value: string | null | undefined;
onChange: (url: string | null) => void;
/** Tags new uploads and pre-filters the library to this field's own images — e.g. "branding", "loyalty", "staff", "service", "portfolio", "welcome". */
category: string;
hint?: string;
}
/**
* The standard image-field component — preview, Change (opens the Media
* Manager), Remove. Replaces every plain-text "... URL" Input across the
* app (business logo/cover, loyalty card background, staff photo,
* service photo, welcome backgrounds, PWA icon/favicon). See
* docs/phase-9-summary.md.
*/
export function MediaPicker({ label, value, onChange, category, hint }: MediaPickerProps) {
const [open, setOpen] = useState(false);
return (
<div className="w-full">
{label && <p className="text-muted mb-2 ml-1 text-[10px] font-bold tracking-widest uppercase">{label}</p>}
<div className="border-line flex items-center gap-4 rounded-xl border bg-white p-4">
<div className="bg-stone flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-lg">
{value ? (
// eslint-disable-next-line @next/next/no-img-element -- local/user-supplied URLs, same pattern used app-wide
<img src={value} alt="" className="h-full w-full object-cover" />
) : (
<ImageOff className="text-muted h-5 w-5" aria-hidden="true" />
)}
</div>
<div className="flex flex-1 flex-wrap gap-2">
<Button type="button" size="sm" variant="secondary" onClick={() => setOpen(true)}>
{value ? "Change" : "Choose image"}
</Button>
{value && (
<Button type="button" size="sm" variant="ghost" onClick={() => onChange(null)}>
<X className="h-3.5 w-3.5" aria-hidden="true" /> Remove
</Button>
)}
</div>
</div>
{hint && <p className="text-muted mt-1.5 ml-1 text-xs">{hint}</p>}
<MediaManagerModal open={open} onClose={() => setOpen(false)} onSelect={(url) => onChange(url)} category={category} />
</div>
);
}
@@ -1,8 +1,9 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { hasPermission } from "@/lib/auth/permissions";
import { hasPermission, isSuperAdmin } from "@/lib/auth/permissions";
import { assertOwnerPermission, OwnerPermissionDeniedError } from "../authorization";
import { deactivateStaff, updateStaffAsOwner } from "@/services/staff/staff.service";
import { deleteCategory } from "@/services/catalogue/service-catalogue.service";
import { Permission } from "@/generated/prisma/enums";
import {
cleanupTestBusiness,
createTestBusiness,
@@ -121,4 +122,46 @@ describe("Owner authorization and business isolation (integration)", () => {
}
});
});
describe("Super Administrator (Phase 9)", () => {
/** Deliberately no StaffProfile at all — see scripts/ensure-super-admin.ts and docs/phase-9-summary.md. */
async function createTestSuperAdmin() {
const user = await prisma.user.create({
data: { email: `super-admin-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@test.local`, isSuperAdmin: true },
});
return user.id;
}
it("isSuperAdmin reflects the User.isSuperAdmin flag", async () => {
const { userId: ordinaryUserId } = await createTestStaff(business);
const superAdminUserId = await createTestSuperAdmin();
try {
expect(await isSuperAdmin(ordinaryUserId)).toBe(false);
expect(await isSuperAdmin(superAdminUserId)).toBe(true);
} finally {
await prisma.user.delete({ where: { id: superAdminUserId } });
}
});
it("hasPermission grants every permission to a Super Administrator with no StaffProfile at all", async () => {
const superAdminUserId = await createTestSuperAdmin();
try {
for (const permission of Object.values(Permission)) {
expect(await hasPermission(superAdminUserId, permission)).toBe(true);
}
expect(await prisma.staffProfile.findUnique({ where: { userId: superAdminUserId } })).toBeNull();
} finally {
await prisma.user.delete({ where: { id: superAdminUserId } });
}
});
it("does not grant permissions to an ordinary user who merely lacks a StaffProfile", async () => {
const user = await prisma.user.create({ data: { email: `plain-${Date.now()}@test.local` } });
try {
expect(await hasPermission(user.id, Permission.MANAGE_STAFF)).toBe(false);
} finally {
await prisma.user.delete({ where: { id: user.id } });
}
});
});
});
+9 -3
View File
@@ -5,6 +5,7 @@ import { prisma } from "@/lib/db/prisma";
import { Permission } from "@/generated/prisma/enums";
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
import { writeAuditLog } from "@/lib/audit/write-audit-log";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import {
setOpeningHours,
updateBusinessProfile,
@@ -17,6 +18,11 @@ export interface BusinessActionResult {
error?: string;
}
/**
* Uses getActingBusinessContext (not a raw StaffProfile lookup) so a
* Super Administrator — who has no StaffProfile by design, see
* docs/phase-9-summary.md — can manage Business Branding too.
*/
async function requireBusinessManager() {
const session = await auth();
if (!session?.user) return { staff: null, error: "You must be signed in." } as const;
@@ -28,10 +34,10 @@ async function requireBusinessManager() {
throw error;
}
const staff = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
if (!staff) return { staff: null, error: "You must be signed in as staff." } as const;
const context = await getActingBusinessContext();
if (!context) return { staff: null, error: "You must be signed in as staff." } as const;
return { staff, error: null } as const;
return { staff: { businessId: context.businessId, userId: context.userId }, error: null } as const;
}
export async function updateBusinessProfileAction(params: {
+98
View File
@@ -0,0 +1,98 @@
"use server";
import { auth } from "@/auth";
import { prisma } from "@/lib/db/prisma";
import { Permission } from "@/generated/prisma/enums";
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { updateBusinessProfile, updateBusinessSettings } from "@/services/business/business.service";
import { setReviewHidden } from "@/services/reviews/review-management.service";
import { syncGoogleReviewsForBusiness } from "@/services/reviews/review-sync.service";
export interface ReviewsActionResult {
success: boolean;
error?: string;
}
async function requireReviewsManager() {
const session = await auth();
if (!session?.user) return { businessId: null, error: "You must be signed in." } as const;
try {
await assertOwnerPermission(session.user.id, Permission.MANAGE_REVIEWS);
} catch (error) {
if (error instanceof OwnerPermissionDeniedError) return { businessId: null, error: error.message } as const;
throw error;
}
const context = await getActingBusinessContext();
if (!context) return { businessId: null, error: "You must be signed in as staff." } as const;
return { businessId: context.businessId, error: null } as const;
}
/** Reuses updateBusinessProfile/updateBusinessSettings (the same functions Business Details uses) rather than a parallel update path — googlePlaceId lives on Business, the two policy flags on BusinessSettings. */
export async function updateReviewsSettingsAction(input: {
googlePlaceId: string | null;
reviewsMinRating: number | null;
reviewsExcludeRatingOnly: boolean;
}): Promise<ReviewsActionResult> {
const { businessId, error } = await requireReviewsManager();
if (!businessId) return { success: false, error: error ?? "Not authorized." };
const business = await prisma.business.findUnique({ where: { id: businessId } });
if (!business) return { success: false, error: "Business not found." };
const location = await prisma.businessLocation.findFirst({ where: { businessId, isPrimary: true } });
if (!location) return { success: false, error: "Business location not found." };
const profileResult = await updateBusinessProfile(prisma, {
businessId,
profile: {
name: business.name,
timezone: business.timezone,
currency: business.currency,
locale: business.locale,
googlePlaceId: input.googlePlaceId,
},
location: {
addressLine1: location.addressLine1,
city: location.city,
postalCode: location.postalCode,
country: location.country,
},
});
if (!profileResult.success) {
return { success: false, error: profileResult.validation.issues[0]?.message ?? "Couldn't save the Google Place ID." };
}
await updateBusinessSettings(prisma, {
businessId,
settings: { reviewsMinRating: input.reviewsMinRating, reviewsExcludeRatingOnly: input.reviewsExcludeRatingOnly },
});
return { success: true };
}
export async function setReviewHiddenAction(reviewId: string, isHidden: boolean): Promise<ReviewsActionResult> {
const { businessId, error } = await requireReviewsManager();
if (!businessId) return { success: false, error: error ?? "Not authorized." };
const result = await setReviewHidden(prisma, { reviewId, businessId, isHidden });
if (!result.success) return { success: false, error: "That review couldn't be found." };
return { success: true };
}
/** Manual "sync now" — the scheduled job (/api/cron/sync-google-reviews) covers the normal 24h cadence; this lets an owner see results immediately after entering a Place ID for the first time. */
export async function syncReviewsNowAction(): Promise<ReviewsActionResult> {
const { businessId, error } = await requireReviewsManager();
if (!businessId) return { success: false, error: error ?? "Not authorized." };
const business = await prisma.business.findUnique({ where: { id: businessId }, select: { googlePlaceId: true } });
if (!business?.googlePlaceId) {
return { success: false, error: "Set a Google Place ID first." };
}
const result = await syncGoogleReviewsForBusiness(prisma, { businessId, placeId: business.googlePlaceId });
if (result.error) return { success: false, error: result.error };
return { success: true };
}
@@ -3,9 +3,11 @@
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { ColorPicker } from "@/components/ui/color-picker";
import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { MediaPicker } from "@/features/media/components/media-picker";
import { updateBusinessProfileAction, updateBusinessSettingsAction } from "../actions/business";
const TIMEZONES = ["Europe/London", "Europe/Paris", "Europe/Dublin", "America/New_York", "America/Los_Angeles"];
@@ -15,7 +17,14 @@ const LOCALES = ["en-GB", "en-US"];
export interface BusinessDetailsInitial {
name: string;
description: string;
logoUrl: string;
logoUrl: string | null;
coverImageUrl: string | null;
welcomeBackgroundDesktopUrl: string | null;
welcomeBackgroundMobileUrl: string | null;
faviconUrl: string | null;
appIconUrl: string | null;
themeColor: string | null;
backgroundColor: string | null;
phone: string;
email: string;
website: string;
@@ -55,7 +64,14 @@ export function BusinessDetailsForm({ initial }: { initial: BusinessDetailsIniti
profile: {
name: form.name,
description: form.description || undefined,
logoUrl: form.logoUrl || undefined,
logoUrl: form.logoUrl,
coverImageUrl: form.coverImageUrl,
welcomeBackgroundDesktopUrl: form.welcomeBackgroundDesktopUrl,
welcomeBackgroundMobileUrl: form.welcomeBackgroundMobileUrl,
faviconUrl: form.faviconUrl,
appIconUrl: form.appIconUrl,
themeColor: form.themeColor,
backgroundColor: form.backgroundColor,
phone: form.phone || undefined,
email: form.email || undefined,
website: form.website || undefined,
@@ -101,11 +117,55 @@ export function BusinessDetailsForm({ initial }: { initial: BusinessDetailsIniti
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Profile</h3>
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
<Input label="Business Name" required value={form.name} onChange={(e) => set("name", e.target.value)} />
<Input label="Logo URL" placeholder="https://…" value={form.logoUrl} onChange={(e) => set("logoUrl", e.target.value)} />
<MediaPicker label="Logo" value={form.logoUrl} onChange={(url) => set("logoUrl", url)} category="branding" />
<Textarea label="Description" value={form.description} onChange={(e) => set("description", e.target.value)} />
</div>
</section>
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Branding</h3>
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
<MediaPicker
label="Cover Image"
value={form.coverImageUrl}
onChange={(url) => set("coverImageUrl", url)}
category="branding"
hint="Shown at the top of the customer Home page."
/>
<MediaPicker
label="Welcome Background — Desktop"
value={form.welcomeBackgroundDesktopUrl}
onChange={(url) => set("welcomeBackgroundDesktopUrl", url)}
category="welcome"
/>
<MediaPicker
label="Welcome Background — Mobile"
value={form.welcomeBackgroundMobileUrl}
onChange={(url) => set("welcomeBackgroundMobileUrl", url)}
category="welcome"
hint="Shown on the sign-in/welcome screen, before someone signs in."
/>
<MediaPicker
label="Favicon"
value={form.faviconUrl}
onChange={(url) => set("faviconUrl", url)}
category="branding"
hint="Browser tab icon. Falls back to the app default when unset."
/>
<MediaPicker
label="App Icon"
value={form.appIconUrl}
onChange={(url) => set("appIconUrl", url)}
category="branding"
hint="Used when someone installs this as an app on their phone."
/>
<div className="grid grid-cols-2 gap-4">
<ColorPicker label="Theme Colour" value={form.themeColor} onChange={(hex) => set("themeColor", hex)} />
<ColorPicker label="Background Colour" value={form.backgroundColor} onChange={(hex) => set("backgroundColor", hex)} />
</div>
</div>
</section>
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Contact &amp; Address</h3>
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
@@ -23,6 +23,7 @@ export interface CatalogueService {
categoryId: string;
name: string;
description: string;
imageUrl: string | null;
durationMinutes: number;
price: string;
bufferBeforeMinutes: number;
@@ -114,6 +115,7 @@ export function CatalogueManager({ categories }: { categories: CatalogueCategory
categoryId: service.categoryId,
name: service.name,
description: service.description,
imageUrl: service.imageUrl,
durationMinutes: service.durationMinutes,
price: service.price,
bufferBeforeMinutes: service.bufferBeforeMinutes,
@@ -220,14 +222,20 @@ export function CatalogueManager({ categories }: { categories: CatalogueCategory
<ChevronDown className="h-3 w-3" aria-hidden="true" />
</button>
</div>
<button type="button" onClick={() => openEditService(service)} className="min-w-0 flex-1 text-left">
<p className={service.isActive ? "text-sm font-bold" : "text-muted text-sm font-bold line-through"}>
{service.name}
</p>
<p className="text-muted mt-0.5 text-xs">
{service.durationMinutes} min · {formatPrice(service.price)}
{!service.allowOnlineBooking && " · Not bookable online"}
</p>
<button type="button" onClick={() => openEditService(service)} className="flex min-w-0 flex-1 items-center gap-3 text-left">
{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-10 w-10 shrink-0 rounded-lg object-cover" />
)}
<span className="min-w-0 flex-1">
<p className={service.isActive ? "text-sm font-bold" : "text-muted text-sm font-bold line-through"}>
{service.name}
</p>
<p className="text-muted mt-0.5 text-xs">
{service.durationMinutes} min · {formatPrice(service.price)}
{!service.allowOnlineBooking && " · Not bookable online"}
</p>
</span>
</button>
<Switch
checked={service.isActive}
@@ -0,0 +1,149 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { ChevronDown, ChevronUp, Plus, Trash2 } from "lucide-react";
import { GalleryPlacement } from "@/generated/prisma/enums";
import { Input } from "@/components/ui/input";
import { MediaManagerModal } from "@/features/media/components/media-manager-modal";
import {
addGalleryImageAction,
deleteGalleryImageAction,
reorderGalleryImagesAction,
updateGalleryImageAction,
} from "@/features/gallery/actions/gallery";
import type { GalleryImageSummary } from "@/services/gallery/gallery.service";
export interface GalleryManagerProps {
placement: GalleryPlacement;
title: string;
description: string;
/** Portfolio images carry a category tag for the customer-facing filter pills; the hero gallery has no use for one. */
showCategory: boolean;
initialImages: GalleryImageSummary[];
}
export function GalleryManager({ placement, title, description, showCategory, initialImages }: GalleryManagerProps) {
const router = useRouter();
const [images, setImages] = useState(initialImages);
const [pickerOpen, setPickerOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const run = (fn: () => Promise<{ success: boolean; error?: string }>) => {
setError(null);
startTransition(async () => {
const result = await fn();
if (!result.success) {
setError(result.error ?? "Something went wrong.");
return;
}
router.refresh();
});
};
const handleSelect = (url: string) => {
run(async () => {
const result = await addGalleryImageAction({ placement, imageUrl: url });
if (result.success) setImages((prev) => [...prev, result.image]);
return result;
});
};
const move = (index: number, direction: -1 | 1) => {
const targetIndex = index + direction;
if (targetIndex < 0 || targetIndex >= images.length) return;
const reordered = [...images];
const [moved] = reordered.splice(index, 1);
if (!moved) return;
reordered.splice(targetIndex, 0, moved);
setImages(reordered);
run(() => reorderGalleryImagesAction(placement, reordered.map((i) => i.id)));
};
const remove = (id: string) => {
setImages((prev) => prev.filter((i) => i.id !== id));
run(() => deleteGalleryImageAction(id));
};
const setCategory = (id: string, category: string) => {
setImages((prev) => prev.map((i) => (i.id === id ? { ...i, category } : i)));
run(() => updateGalleryImageAction(id, { category }));
};
return (
<section className="space-y-3">
<div>
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">{title}</h3>
<p className="text-muted mt-1 ml-1 text-xs">{description}</p>
</div>
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{images.map((image, index) => (
<div key={image.id} className="border-line overflow-hidden rounded-xl border bg-white">
{/* eslint-disable-next-line @next/next/no-img-element -- local/user-supplied URL, same pattern used app-wide */}
<img src={image.imageUrl} alt="" className="bg-stone h-28 w-full object-cover" />
<div className="space-y-2 p-2">
{showCategory && (
<Input
value={image.category ?? ""}
onChange={(e) => setCategory(image.id, e.target.value)}
placeholder="Category (optional)"
className="px-3 py-2 text-xs"
/>
)}
<div className="flex items-center justify-between">
<div className="flex gap-1">
<button
type="button"
onClick={() => move(index, -1)}
disabled={index === 0}
aria-label="Move earlier"
className="text-muted disabled:opacity-30"
>
<ChevronUp className="h-3.5 w-3.5" aria-hidden="true" />
</button>
<button
type="button"
onClick={() => move(index, 1)}
disabled={index === images.length - 1}
aria-label="Move later"
className="text-muted disabled:opacity-30"
>
<ChevronDown className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</div>
<button type="button" onClick={() => remove(image.id)} aria-label="Delete image" className="text-danger">
<Trash2 className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</div>
</div>
</div>
))}
<button
type="button"
onClick={() => setPickerOpen(true)}
disabled={isPending}
className="border-line text-muted flex h-full min-h-[9rem] flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed"
>
<Plus className="h-5 w-5" aria-hidden="true" />
<span className="text-xs font-bold tracking-widest uppercase">Add Image</span>
</button>
</div>
<MediaManagerModal
open={pickerOpen}
onClose={() => setPickerOpen(false)}
onSelect={handleSelect}
category={placement === GalleryPlacement.HERO ? "gallery" : "portfolio"}
/>
</section>
);
}
@@ -4,9 +4,13 @@ import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { ColorPicker } from "@/components/ui/color-picker";
import { IconPicker } from "@/components/ui/icon-picker";
import { Input } from "@/components/ui/input";
import { LoyaltyCard } from "@/components/ui/loyalty-card";
import { Select } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { MediaPicker } from "@/features/media/components/media-picker";
import { updateLoyaltyConfigAction } from "../actions/loyalty-config";
import type { ProgrammeConfigInput } from "@/services/loyalty/loyalty.service";
@@ -17,6 +21,7 @@ const REWARD_TYPES: { value: ProgrammeConfigInput["rewardType"]; label: string }
];
export interface LoyaltyConfigFormProps {
businessName: string;
initialConfig: ProgrammeConfigInput;
initialEligibleServiceIds: string[];
initialEligibleStaffIds: string[];
@@ -25,6 +30,7 @@ export interface LoyaltyConfigFormProps {
}
export function LoyaltyConfigForm({
businessName,
initialConfig,
initialEligibleServiceIds,
initialEligibleStaffIds,
@@ -191,13 +197,43 @@ export function LoyaltyConfigForm({
</div>
</section>
<section className="space-y-3">
<section className="space-y-3 lg:col-span-2">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Card Appearance</h3>
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
<Input label="Card background color" value={config.cardBackgroundColor ?? ""} onChange={(e) => set("cardBackgroundColor", e.target.value)} placeholder="#111b15" />
<Input label="Card background image URL (optional)" value={config.cardBackgroundImageUrl ?? ""} onChange={(e) => set("cardBackgroundImageUrl", e.target.value)} />
<Input label="Stamp icon" value={config.stampIcon ?? ""} onChange={(e) => set("stampIcon", e.target.value)} placeholder="scissors" />
<Input label="Terms line" value={config.termsLine ?? ""} onChange={(e) => set("termsLine", e.target.value)} placeholder="Valid for 12 months. One reward per visit." />
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6 lg:grid lg:grid-cols-[minmax(0,320px)_1fr] lg:gap-8 lg:space-y-0">
<div>
<p className="text-muted mb-2 ml-1 text-[10px] font-bold tracking-widest uppercase">Live Preview</p>
<LoyaltyCard
businessName={businessName}
memberNumber="A1B2C3"
stampsRequired={Math.max(config.stampsRequired, 1)}
stampsEarned={Math.min(3, Math.max(config.stampsRequired - 1, 0))}
rewardLabel={config.rewardLabel || "Reward"}
termsLine={config.termsLine}
cardBackgroundColor={config.cardBackgroundColor}
cardBackgroundImageUrl={config.cardBackgroundImageUrl}
stampIcon={config.stampIcon}
/>
</div>
<div className="mt-4 space-y-4 lg:mt-0">
<ColorPicker
label="Background colour"
value={config.cardBackgroundColor}
onChange={(hex) => set("cardBackgroundColor", hex)}
/>
<MediaPicker
label="Background image (optional)"
value={config.cardBackgroundImageUrl}
onChange={(url) => set("cardBackgroundImageUrl", url ?? undefined)}
category="loyalty"
/>
<IconPicker label="Stamp icon" value={config.stampIcon} onChange={(iconName) => set("stampIcon", iconName)} />
<Input
label="Terms line"
value={config.termsLine ?? ""}
onChange={(e) => set("termsLine", e.target.value)}
placeholder="Valid for 12 months. One reward per visit."
/>
</div>
</div>
</section>
@@ -0,0 +1,247 @@
"use client";
import { useMemo, useState, useTransition } from "react";
import { RefreshCw, Star, StarOff } from "lucide-react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils/cn";
import { setReviewHiddenAction, syncReviewsNowAction, updateReviewsSettingsAction } from "../actions/reviews";
export interface OwnerReviewRow {
id: string;
authorName: string;
rating: number;
text: string | null;
relativeTimeDescription: string | null;
isHidden: boolean;
}
export interface ReviewsManagerProps {
initialGooglePlaceId: string;
initialReviewsMinRating: number | null;
initialReviewsExcludeRatingOnly: boolean;
googleRating: number | null;
googleRatingCount: number | null;
reviews: OwnerReviewRow[];
}
const RATING_FILTERS = [null, 5, 4, 3, 2, 1] as const;
function StarRating({ rating }: { rating: number }) {
return (
<div className="flex items-center gap-0.5" aria-label={`${rating} out of 5 stars`}>
{Array.from({ length: 5 }, (_, i) => (
<Star
key={i}
className={cn("h-3.5 w-3.5", i < rating ? "fill-accent-loyalty text-accent-loyalty" : "text-line")}
aria-hidden="true"
/>
))}
</div>
);
}
export function ReviewsManager({
initialGooglePlaceId,
initialReviewsMinRating,
initialReviewsExcludeRatingOnly,
googleRating,
googleRatingCount,
reviews,
}: ReviewsManagerProps) {
const router = useRouter();
const [googlePlaceId, setGooglePlaceId] = useState(initialGooglePlaceId);
const [reviewsMinRating, setReviewsMinRating] = useState(initialReviewsMinRating);
const [reviewsExcludeRatingOnly, setReviewsExcludeRatingOnly] = useState(initialReviewsExcludeRatingOnly);
const [settingsError, setSettingsError] = useState<string | null>(null);
const [settingsSaved, setSettingsSaved] = useState(false);
const [syncMessage, setSyncMessage] = useState<string | null>(null);
const [isSettingsPending, startSettingsTransition] = useTransition();
const [isSyncPending, startSyncTransition] = useTransition();
const [ratingFilter, setRatingFilter] = useState<number | null>(null);
const [query, setQuery] = useState("");
const [rows, setRows] = useState(reviews);
const [hidingId, setHidingId] = useState<string | null>(null);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return rows.filter((r) => {
if (ratingFilter && r.rating !== ratingFilter) return false;
if (q && !(r.authorName.toLowerCase().includes(q) || (r.text ?? "").toLowerCase().includes(q))) return false;
return true;
});
}, [rows, ratingFilter, query]);
const saveSettings = () => {
setSettingsError(null);
setSettingsSaved(false);
startSettingsTransition(async () => {
const result = await updateReviewsSettingsAction({
googlePlaceId: googlePlaceId.trim() || null,
reviewsMinRating,
reviewsExcludeRatingOnly,
});
if (!result.success) {
setSettingsError(result.error ?? "Couldn't save review settings.");
return;
}
setSettingsSaved(true);
router.refresh();
});
};
const syncNow = () => {
setSyncMessage(null);
startSyncTransition(async () => {
const result = await syncReviewsNowAction();
setSyncMessage(result.success ? "Synced — refreshing…" : (result.error ?? "Sync failed."));
if (result.success) router.refresh();
});
};
const toggleHidden = (review: OwnerReviewRow) => {
setHidingId(review.id);
const nextHidden = !review.isHidden;
setRows((prev) => prev.map((r) => (r.id === review.id ? { ...r, isHidden: nextHidden } : r)));
startSettingsTransition(async () => {
const result = await setReviewHiddenAction(review.id, nextHidden);
if (!result.success) {
// Revert on failure.
setRows((prev) => prev.map((r) => (r.id === review.id ? { ...r, isHidden: review.isHidden } : r)));
}
setHidingId(null);
});
};
return (
<div className="space-y-6">
<section className="rounded-jumbo border-line space-y-4 border bg-white p-6">
<h3 className="text-sm font-bold">Google Reviews Sync</h3>
{googleRating !== null && (
<p className="text-muted text-sm">
Google&apos;s aggregate: <span className="text-ink font-bold">{googleRating.toFixed(1)}</span> from{" "}
{googleRatingCount ?? 0} ratings.
</p>
)}
<Input
label="Google Place ID"
value={googlePlaceId}
onChange={(e) => setGooglePlaceId(e.target.value)}
placeholder="ChIJ…"
hint="Found via Google's Place ID Finder. Leave blank to disable review syncing for this business."
/>
<div className="grid grid-cols-2 gap-4">
<Select
label="Minimum rating shown to customers"
value={reviewsMinRating?.toString() ?? ""}
onChange={(e) => setReviewsMinRating(e.target.value ? Number(e.target.value) : null)}
>
<option value="">No minimum</option>
{[1, 2, 3, 4, 5].map((n) => (
<option key={n} value={n}>
{n}+ stars
</option>
))}
</Select>
<div className="border-line flex items-center rounded-xl border p-4">
<Switch
label="Exclude rating-only reviews"
checked={reviewsExcludeRatingOnly}
onCheckedChange={setReviewsExcludeRatingOnly}
/>
</div>
</div>
{settingsError && (
<p role="alert" className="text-danger text-sm font-medium">
{settingsError}
</p>
)}
{settingsSaved && !settingsError && (
<p role="status" className="text-accent-foreground text-sm font-medium">
Saved.
</p>
)}
<div className="flex flex-wrap gap-3">
<Button isLoading={isSettingsPending} onClick={saveSettings}>
Save
</Button>
<Button variant="secondary" isLoading={isSyncPending} onClick={syncNow}>
<RefreshCw className="h-4 w-4" aria-hidden="true" /> Sync Now
</Button>
</div>
{syncMessage && <p className="text-muted text-xs">{syncMessage}</p>}
</section>
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Imported Reviews</h3>
<div className="flex flex-wrap items-center gap-2">
{RATING_FILTERS.map((r) => (
<button
key={r ?? "all"}
type="button"
onClick={() => setRatingFilter(r)}
className={cn(
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
ratingFilter === r ? "bg-ink text-white" : "border-line text-muted border bg-white",
)}
>
{r ? `${r}★` : "All"}
</button>
))}
</div>
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search by author or review text"
className="border-line focus:ring-ink w-full rounded-xl border bg-white px-4 py-2.5 text-sm outline-none focus:ring-2"
/>
{filtered.length === 0 ? (
<p className="text-muted py-6 text-center text-sm">No reviews match this filter.</p>
) : (
<div className="space-y-3">
{filtered.map((review) => (
<Card key={review.id} className={cn("space-y-2 p-5", review.isHidden && "opacity-60")}>
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-bold">{review.authorName}</p>
<div className="mt-1 flex items-center gap-2">
<StarRating rating={review.rating} />
{review.relativeTimeDescription && (
<span className="text-muted text-xs">{review.relativeTimeDescription}</span>
)}
</div>
</div>
<Button
size="sm"
variant="secondary"
isLoading={hidingId === review.id}
onClick={() => toggleHidden(review)}
>
{review.isHidden ? (
<>
<Star className="h-3.5 w-3.5" aria-hidden="true" /> Unhide
</>
) : (
<>
<StarOff className="h-3.5 w-3.5" aria-hidden="true" /> Hide
</>
)}
</Button>
</div>
{review.text && <p className="text-muted text-sm">{review.text}</p>}
</Card>
))}
</div>
)}
</section>
</div>
);
}
@@ -8,6 +8,7 @@ import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { MediaPicker } from "@/features/media/components/media-picker";
import { createServiceAction, updateServiceAction } from "../actions/catalogue";
export interface EditableService {
@@ -15,6 +16,7 @@ export interface EditableService {
categoryId: string;
name: string;
description: string;
imageUrl: string | null;
durationMinutes: number;
price: string;
bufferBeforeMinutes: number;
@@ -39,6 +41,7 @@ export function ServiceFormSheet({
const [categoryId, setCategoryId] = useState(existing?.categoryId ?? defaultCategoryId);
const [name, setName] = useState(existing?.name ?? "");
const [description, setDescription] = useState(existing?.description ?? "");
const [imageUrl, setImageUrl] = useState(existing?.imageUrl ?? "");
const [duration, setDuration] = useState(String(existing?.durationMinutes ?? 30));
const [price, setPrice] = useState(existing?.price ?? "");
const [bufferBefore, setBufferBefore] = useState(String(existing?.bufferBeforeMinutes ?? 0));
@@ -61,6 +64,7 @@ export function ServiceFormSheet({
categoryId,
name,
description: description || undefined,
imageUrl: imageUrl || undefined,
durationMinutes,
price,
bufferBeforeMinutes,
@@ -89,6 +93,7 @@ export function ServiceFormSheet({
</Select>
<Input label="Name" value={name} onChange={(e) => setName(e.target.value)} />
<Textarea label="Description (optional)" value={description} onChange={(e) => setDescription(e.target.value)} />
<MediaPicker label="Photo (optional)" value={imageUrl} onChange={(url) => setImageUrl(url ?? "")} category="service" />
<div className="grid grid-cols-2 gap-4">
<Input label="Duration (min)" type="number" min={1} value={duration} onChange={(e) => setDuration(e.target.value)} />
<Input label="Price" placeholder="22.50" value={price} onChange={(e) => setPrice(e.target.value)} />
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { MediaPicker } from "@/features/media/components/media-picker";
import { updateProfileAction } from "../actions/profile";
export interface ProfileFormInitial {
@@ -51,12 +52,7 @@ export function ProfileForm({ initial }: { initial: ProfileFormInitial }) {
value={phone}
onChange={(e) => setPhone(e.target.value)}
/>
<Input
label="Photo URL"
placeholder="https://…"
value={image}
onChange={(e) => setImage(e.target.value)}
/>
<MediaPicker label="Photo" value={image} onChange={(url) => setImage(url ?? "")} category="staff" />
<Textarea
label="Bio"
placeholder="A short introduction customers will see on your profile"
+20
View File
@@ -26,7 +26,27 @@ export const getStaffPermissions = cache(async (userId: string): Promise<Set<Per
return new Set(staffProfile.role.permissions.map((rolePermission) => rolePermission.permission));
});
/**
* Phase 9 — the platform-level Super Administrator (see
* scripts/ensure-super-admin.ts). Deliberately re-checks the database
* rather than trusting the JWT, same rationale as getStaffPermissions.
*/
export const isSuperAdmin = cache(async (userId: string): Promise<boolean> => {
const user = await prisma.user.findUnique({ where: { id: userId }, select: { isSuperAdmin: true } });
return user?.isSuperAdmin ?? false;
});
/**
* A Super Administrator has every permission an Owner has — OWNER_PERMISSIONS
* (src/lib/roles/default-permissions.ts) already IS the full Permission enum,
* so short-circuiting true here rather than granting a StaffProfile/Role is
* exactly equivalent, without giving the Super Admin a StaffProfile row at
* all (see docs/phase-9-summary.md, "Super Administrator" — this is what
* keeps them out of the Team list, barber picker, and appointment FKs
* structurally rather than via a filter).
*/
export async function hasPermission(userId: string, permission: Permission): Promise<boolean> {
if (await isSuperAdmin(userId)) return true;
const permissions = await getStaffPermissions(userId);
return permissions.has(permission);
}
+9
View File
@@ -36,6 +36,9 @@ export interface BrandingInfo {
appIconUrl: string | null;
themeColor: string;
backgroundColor: string;
/** Welcome page (/) background — see src/app/page.tsx. Independent images, not one image cropped two ways. */
welcomeBackgroundDesktopUrl: string | null;
welcomeBackgroundMobileUrl: string | null;
email: string | null;
phone: string | null;
}
@@ -55,6 +58,8 @@ const UNCONFIGURED_BRANDING: BrandingInfo = {
appIconUrl: null,
themeColor: DEFAULT_THEME_COLOR,
backgroundColor: DEFAULT_BACKGROUND_COLOR,
welcomeBackgroundDesktopUrl: null,
welcomeBackgroundMobileUrl: null,
email: null,
phone: null,
};
@@ -80,6 +85,8 @@ export const getBranding = cache(async (): Promise<BrandingInfo> => {
appIconUrl: true,
themeColor: true,
backgroundColor: true,
welcomeBackgroundDesktopUrl: true,
welcomeBackgroundMobileUrl: true,
email: true,
phone: true,
},
@@ -100,6 +107,8 @@ export const getBranding = cache(async (): Promise<BrandingInfo> => {
appIconUrl: business.appIconUrl,
themeColor: business.themeColor || DEFAULT_THEME_COLOR,
backgroundColor: business.backgroundColor || DEFAULT_BACKGROUND_COLOR,
welcomeBackgroundDesktopUrl: business.welcomeBackgroundDesktopUrl,
welcomeBackgroundMobileUrl: business.welcomeBackgroundMobileUrl,
email: business.email,
phone: business.phone,
};
@@ -0,0 +1,51 @@
import { cache } from "react";
import { auth } from "@/auth";
import { prisma } from "@/lib/db/prisma";
import { isSuperAdmin } from "@/lib/auth/permissions";
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
/**
* Resolves "which business is the signed-in user acting on behalf of" for
* pages/actions that only need the business id/name/timezone — not full
* staff identity (see getActingStaff for that, which stays the right
* choice for anything that needs a real StaffProfile id, e.g. loyalty
* stamp authorship or appointment reassignment).
*
* A regular staff member/owner resolves via their StaffProfile, same as
* getActingStaff. A Super Administrator (see scripts/ensure-super-admin.ts)
* deliberately has no StaffProfile at all — that's what keeps them out of
* the Team list/barber picker/appointment FKs by construction rather than
* a filter — so they resolve straight to the app's one business instead
* (v1 is single-business, same assumption getPrimaryBusiness() already
* makes everywhere else). Returns null only for a signed-in user who is
* neither staff nor a super admin.
*/
export const getActingBusinessContext = cache(async () => {
const session = await auth();
if (!session?.user?.id) return null;
const staffProfile = await prisma.staffProfile.findUnique({
where: { userId: session.user.id },
select: { businessId: true, business: { select: { name: true, timezone: true } } },
});
if (staffProfile) {
return {
userId: session.user.id,
businessId: staffProfile.businessId,
businessName: staffProfile.business.name,
businessTimezone: staffProfile.business.timezone,
};
}
if (!(await isSuperAdmin(session.user.id))) return null;
const business = await getPrimaryBusiness();
return {
userId: session.user.id,
businessId: business.id,
businessName: business.name,
businessTimezone: business.timezone,
};
});
export type ActingBusinessContext = NonNullable<Awaited<ReturnType<typeof getActingBusinessContext>>>;
+104
View File
@@ -0,0 +1,104 @@
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import {
faAward,
faBell,
faBolt,
faBriefcase,
faBrush,
faCalendarCheck,
faCamera,
faCheck,
faCircleCheck,
faClock,
faCoffee,
faCompass,
faCrown,
faDiamond,
faFire,
faGem,
faGift,
faGlassCheers,
faHandSparkles,
faHatCowboy,
faHeart,
faLeaf,
faLightbulb,
faMedal,
faMugSaucer,
faPaintbrush,
faRibbon,
faScissors,
faShieldHeart,
faSprayCanSparkles,
faStamp,
faStar,
faStarHalfStroke,
faSun,
faTag,
faThumbsUp,
faTicket,
faTrophy,
faUserTie,
faWandMagicSparkles,
} from "@fortawesome/free-solid-svg-icons";
/**
* Curated, not the full ~2000-icon Font Awesome solid set — deliberately:
* a searchable picker over every icon Font Awesome ships (razors,
* spreadsheets, ambulances...) is a worse experience for "pick a loyalty
* stamp icon" than a relevant shortlist, and named imports here (rather
* than `import { fas } from "..."`) keep only these icons' path data in
* the bundle — this component is only ever loaded on the owner Loyalty
* settings page, never the customer bundle, but there's no reason to
* bundle 2000 unused icons regardless. Add to this list as new use cases
* come up; nothing about IconPicker itself is tied to this specific set.
*/
export const ICON_CATALOG: IconDefinition[] = [
faScissors,
faStar,
faStarHalfStroke,
faHeart,
faGift,
faCrown,
faGem,
faDiamond,
faAward,
faMedal,
faTrophy,
faRibbon,
faStamp,
faTag,
faTicket,
faThumbsUp,
faCheck,
faCircleCheck,
faFire,
faBolt,
faSun,
faLeaf,
faCoffee,
faMugSaucer,
faGlassCheers,
faHandSparkles,
faSprayCanSparkles,
faWandMagicSparkles,
faBrush,
faPaintbrush,
faCamera,
faHatCowboy,
faUserTie,
faBriefcase,
faShieldHeart,
faLightbulb,
faCompass,
faClock,
faCalendarCheck,
faBell,
];
const ICON_BY_NAME = new Map<string, IconDefinition>(ICON_CATALOG.map((icon) => [icon.iconName, icon]));
/** Falls back to a neutral default for anything not in the catalogue — covers legacy free-text values from before this picker existed. */
export function findCatalogIcon(iconName: string | null | undefined): IconDefinition {
return (iconName && ICON_BY_NAME.get(iconName)) || faScissors;
}
+68
View File
@@ -0,0 +1,68 @@
import { randomUUID } from "node:crypto";
import { mkdir, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
/**
* Local-disk storage for the Media Manager (see src/services/media) —
* files live under public/media/<category>/..., served for free by
* Next's own static file handling for public/ (no custom GET route
* needed). A deliberate v1 choice for this app's single-container Docker
* deployment model, not a cloud object-storage integration — see
* docker-compose.local.yaml's media volume for why this survives a
* container restart. Swapping to S3-compatible storage later only means
* changing this one file; every caller only ever sees a public URL.
*
* SVG is deliberately not in the allow-list — an uploaded SVG can embed
* <script>, making it a stored-XSS vector if ever served inline.
*/
const ALLOWED_MIME_EXTENSIONS: Record<string, string> = {
"image/png": "png",
"image/jpeg": "jpg",
"image/webp": "webp",
"image/gif": "gif",
};
export const MEDIA_MAX_UPLOAD_BYTES = Number(process.env.MEDIA_MAX_UPLOAD_BYTES ?? 5_000_000);
const MEDIA_ROOT = path.join(process.cwd(), "public", "media");
const MEDIA_URL_PREFIX = "/media/";
export function isAllowedMediaMimeType(mimeType: string): boolean {
return mimeType in ALLOWED_MIME_EXTENSIONS;
}
export interface StoredMediaFile {
/** Public path — what gets saved on MediaAsset.url and rendered directly. */
url: string;
}
function sanitizeCategory(category: string): string {
return category.replace(/[^a-z0-9_-]/gi, "").toLowerCase() || "other";
}
export async function storeMediaFile(params: {
buffer: Buffer;
mimeType: string;
category: string;
}): Promise<StoredMediaFile> {
const extension = ALLOWED_MIME_EXTENSIONS[params.mimeType];
if (!extension) throw new Error(`Unsupported media type: ${params.mimeType}`);
const safeCategory = sanitizeCategory(params.category);
const filename = `${randomUUID()}.${extension}`;
const dir = path.join(MEDIA_ROOT, safeCategory);
await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, filename), params.buffer);
return { url: `${MEDIA_URL_PREFIX}${safeCategory}/${filename}` };
}
/** Best-effort — a missing file (already deleted, or a URL that was never ours) never blocks removing the DB row. */
export async function deleteMediaFile(url: string): Promise<void> {
if (!url.startsWith(MEDIA_URL_PREFIX)) return;
const relative = url.slice(MEDIA_URL_PREFIX.length);
const absolutePath = path.join(MEDIA_ROOT, relative);
// Path-traversal guard — a corrupted/crafted url should never resolve outside MEDIA_ROOT.
if (!absolutePath.startsWith(MEDIA_ROOT + path.sep)) return;
await unlink(absolutePath).catch(() => {});
}
+2
View File
@@ -25,6 +25,8 @@ export const OWNER_PERMISSIONS: Permission[] = [
Permission.VIEW_CUSTOMER_DIRECTORY,
Permission.BOOK_ON_BEHALF_OF_CUSTOMER,
Permission.VIEW_REPORTING,
Permission.MANAGE_MEDIA_LIBRARY,
Permission.MANAGE_REVIEWS,
];
export const BARBER_PERMISSIONS: Permission[] = [
+19 -1
View File
@@ -21,7 +21,22 @@ export type BusinessServiceErrorCode =
export interface BusinessProfileInput {
name: string;
description?: string;
logoUrl?: string;
/**
* Image/colour branding fields are `string | null` (not `| undefined`)
* and always included on the update payload — see updateBusinessProfile
* below — so MediaPicker/ColorPicker's "Remove" actually clears the
* column to NULL instead of silently being skipped.
*/
logoUrl?: string | null;
coverImageUrl?: string | null;
faviconUrl?: string | null;
appIconUrl?: string | null;
themeColor?: string | null;
backgroundColor?: string | null;
welcomeBackgroundDesktopUrl?: string | null;
welcomeBackgroundMobileUrl?: string | null;
/** Google Places Place ID reviews sync from — see src/services/reviews/review-sync.service.ts. Null/empty disables sync for this business. */
googlePlaceId?: string | null;
phone?: string;
email?: string;
website?: string;
@@ -48,6 +63,9 @@ export interface BusinessSettingsInput {
lateArrivalPolicy?: string;
bookingTerms?: string;
privacyPolicyUrl?: string;
/** Reviews visibility policy — see src/services/reviews. Applied on top of each Review's own isHidden flag. */
reviewsMinRating?: number | null;
reviewsExcludeRatingOnly?: boolean;
}
export function validateBusinessProfileInput(
@@ -153,6 +153,8 @@ export interface ServiceInput {
categoryId: string;
name: string;
description?: string;
/** Set via the Media Manager — see src/features/media/components/media-picker.tsx. */
imageUrl?: string;
durationMinutes: number;
/** Decimal string, e.g. "22.50" — never a JS number, see file header. */
price: string;
@@ -226,6 +228,7 @@ export async function createService(
categoryId: params.input.categoryId,
name: params.input.name.trim(),
description: params.input.description?.trim() || null,
imageUrl: params.input.imageUrl?.trim() || null,
durationMinutes: params.input.durationMinutes,
price: params.input.price,
bufferBeforeMinutes: params.input.bufferBeforeMinutes,
@@ -286,6 +289,7 @@ export async function updateService(
categoryId: params.input.categoryId,
name: params.input.name.trim(),
description: params.input.description?.trim() || null,
imageUrl: params.input.imageUrl?.trim() || null,
durationMinutes: params.input.durationMinutes,
price: params.input.price,
bufferBeforeMinutes: params.input.bufferBeforeMinutes,
@@ -0,0 +1,86 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { GalleryPlacement } from "@/generated/prisma/enums";
import {
addGalleryImage,
deleteGalleryImage,
listGalleryImages,
reorderGalleryImages,
updateGalleryImage,
} from "@/services/gallery/gallery.service";
import { cleanupTestBusiness, createTestBusiness, prisma } from "@/services/scheduling/__tests__/fixtures";
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
describe("Gallery service (integration)", () => {
let business: TestBusiness;
beforeEach(async () => {
business = await createTestBusiness();
});
afterEach(async () => {
await cleanupTestBusiness(business);
});
it("keeps HERO and PORTFOLIO images in separate lists for the same business", async () => {
await addGalleryImage(prisma, { businessId: business.businessId, placement: GalleryPlacement.HERO, imageUrl: "/media/gallery/hero-1.png" });
await addGalleryImage(prisma, { businessId: business.businessId, placement: GalleryPlacement.PORTFOLIO, imageUrl: "/media/gallery/portfolio-1.png", category: "Fades" });
const hero = await listGalleryImages(prisma, { businessId: business.businessId, placement: GalleryPlacement.HERO });
const portfolio = await listGalleryImages(prisma, { businessId: business.businessId, placement: GalleryPlacement.PORTFOLIO });
expect(hero).toHaveLength(1);
expect(portfolio).toHaveLength(1);
expect(portfolio[0]?.category).toBe("Fades");
});
it("assigns increasing displayOrder on add, and reorder fully replaces it", async () => {
const a = await addGalleryImage(prisma, { businessId: business.businessId, placement: GalleryPlacement.PORTFOLIO, imageUrl: "/media/a.png" });
const b = await addGalleryImage(prisma, { businessId: business.businessId, placement: GalleryPlacement.PORTFOLIO, imageUrl: "/media/b.png" });
expect(a.displayOrder).toBe(0);
expect(b.displayOrder).toBe(1);
await reorderGalleryImages(prisma, { businessId: business.businessId, placement: GalleryPlacement.PORTFOLIO, orderedIds: [b.id, a.id] });
const reordered = await listGalleryImages(prisma, { businessId: business.businessId, placement: GalleryPlacement.PORTFOLIO });
expect(reordered.map((i) => i.id)).toEqual([b.id, a.id]);
});
it("updates category/caption/isActive, and activeOnly filtering respects isActive", async () => {
const image = await addGalleryImage(prisma, { businessId: business.businessId, placement: GalleryPlacement.PORTFOLIO, imageUrl: "/media/a.png" });
await updateGalleryImage(prisma, { id: image.id, businessId: business.businessId, category: "Beards", caption: "Client X", isActive: false });
const all = await listGalleryImages(prisma, { businessId: business.businessId, placement: GalleryPlacement.PORTFOLIO });
expect(all[0]?.category).toBe("Beards");
expect(all[0]?.caption).toBe("Client X");
expect(all[0]?.isActive).toBe(false);
const activeOnly = await listGalleryImages(prisma, { businessId: business.businessId, placement: GalleryPlacement.PORTFOLIO, activeOnly: true });
expect(activeOnly).toHaveLength(0);
});
it("never lets one business update or delete another business's gallery image", async () => {
const otherBusiness = await createTestBusiness();
try {
const image = await addGalleryImage(prisma, { businessId: otherBusiness.businessId, placement: GalleryPlacement.PORTFOLIO, imageUrl: "/media/other.png" });
const updateResult = await updateGalleryImage(prisma, { id: image.id, businessId: business.businessId, caption: "hijacked" });
expect(updateResult.success).toBe(false);
const deleteResult = await deleteGalleryImage(prisma, { id: image.id, businessId: business.businessId });
expect(deleteResult.success).toBe(false);
const stillThere = await prisma.businessGalleryImage.findUnique({ where: { id: image.id } });
expect(stillThere).not.toBeNull();
expect(stillThere?.caption).not.toBe("hijacked");
} finally {
await cleanupTestBusiness(otherBusiness);
}
});
it("deletes an image it owns", async () => {
const image = await addGalleryImage(prisma, { businessId: business.businessId, placement: GalleryPlacement.PORTFOLIO, imageUrl: "/media/a.png" });
const result = await deleteGalleryImage(prisma, { id: image.id, businessId: business.businessId });
expect(result.success).toBe(true);
expect(await prisma.businessGalleryImage.findUnique({ where: { id: image.id } })).toBeNull();
});
});
+108
View File
@@ -0,0 +1,108 @@
import type { PrismaClient } from "@/generated/prisma/client";
import { GalleryPlacement } from "@/generated/prisma/enums";
/**
* Owner-managed image galleries — one model/service backs both the Home
* page hero carousel (placement: HERO) and the Portfolio tab (placement:
* PORTFOLIO) rather than two near-duplicate "gallery" features. See
* docs/phase-9-summary.md, "Home Screen Redesign".
*/
export interface GalleryImageSummary {
id: string;
imageUrl: string;
category: string | null;
caption: string | null;
displayOrder: number;
isActive: boolean;
}
export async function listGalleryImages(
prisma: PrismaClient,
params: { businessId: string; placement: GalleryPlacement; activeOnly?: boolean },
): Promise<GalleryImageSummary[]> {
const images = await prisma.businessGalleryImage.findMany({
where: {
businessId: params.businessId,
placement: params.placement,
...(params.activeOnly ? { isActive: true } : {}),
},
orderBy: { displayOrder: "asc" },
});
return images.map((i) => ({
id: i.id,
imageUrl: i.imageUrl,
category: i.category,
caption: i.caption,
displayOrder: i.displayOrder,
isActive: i.isActive,
}));
}
export async function addGalleryImage(
prisma: PrismaClient,
params: { businessId: string; placement: GalleryPlacement; imageUrl: string; mediaAssetId?: string; category?: string },
): Promise<GalleryImageSummary> {
const maxOrder = await prisma.businessGalleryImage.aggregate({
where: { businessId: params.businessId, placement: params.placement },
_max: { displayOrder: true },
});
const image = await prisma.businessGalleryImage.create({
data: {
businessId: params.businessId,
placement: params.placement,
imageUrl: params.imageUrl,
mediaAssetId: params.mediaAssetId,
category: params.category?.trim() || null,
displayOrder: (maxOrder._max.displayOrder ?? -1) + 1,
},
});
return {
id: image.id,
imageUrl: image.imageUrl,
category: image.category,
caption: image.caption,
displayOrder: image.displayOrder,
isActive: image.isActive,
};
}
export async function updateGalleryImage(
prisma: PrismaClient,
params: { id: string; businessId: string; category?: string | null; caption?: string | null; isActive?: boolean },
): Promise<{ success: boolean }> {
const result = await prisma.businessGalleryImage.updateMany({
where: { id: params.id, businessId: params.businessId },
data: {
...(params.category !== undefined ? { category: params.category?.trim() || null } : {}),
...(params.caption !== undefined ? { caption: params.caption?.trim() || null } : {}),
...(params.isActive !== undefined ? { isActive: params.isActive } : {}),
},
});
return { success: result.count > 0 };
}
export async function deleteGalleryImage(
prisma: PrismaClient,
params: { id: string; businessId: string },
): Promise<{ success: boolean }> {
const result = await prisma.businessGalleryImage.deleteMany({ where: { id: params.id, businessId: params.businessId } });
return { success: result.count > 0 };
}
/** Full replacement of display order — same "send the whole ordered id list" pattern as reorderCategories/reorderServices in service-catalogue.service.ts. */
export async function reorderGalleryImages(
prisma: PrismaClient,
params: { businessId: string; placement: GalleryPlacement; orderedIds: string[] },
): Promise<void> {
await prisma.$transaction(
params.orderedIds.map((id, index) =>
prisma.businessGalleryImage.updateMany({
where: { id, businessId: params.businessId, placement: params.placement },
data: { displayOrder: index },
}),
),
);
}
+2
View File
@@ -49,6 +49,7 @@ export interface LoyaltySummary {
rewardLabel: string | null;
stampIcon: string | null;
cardBackgroundColor: string | null;
cardBackgroundImageUrl: string | null;
}
/** The customer's loyalty standing for this business — null only if no loyalty programme has ever been configured (distinct from "configured but disabled", which still returns a summary with isEnabled: false). */
@@ -89,6 +90,7 @@ export async function getLoyaltySummary(
rewardLabel: programme.rewardLabel,
stampIcon: programme.stampIcon,
cardBackgroundColor: programme.cardBackgroundColor,
cardBackgroundImageUrl: programme.cardBackgroundImageUrl,
};
}
@@ -0,0 +1,113 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createMediaAsset, deleteMediaAsset, listMediaAssets } from "@/services/media/media.service";
import { cleanupTestBusiness, createTestBusiness, prisma } from "@/services/scheduling/__tests__/fixtures";
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
describe("Media Manager service (integration)", () => {
let business: TestBusiness;
beforeEach(async () => {
business = await createTestBusiness();
});
afterEach(async () => {
await cleanupTestBusiness(business);
});
it("creates a media asset and lists it back for its own business", async () => {
const asset = await createMediaAsset(prisma, {
businessId: business.businessId,
url: "/media/branding/test-1.png",
originalFilename: "logo.png",
mimeType: "image/png",
sizeBytes: 12_345,
width: 512,
height: 512,
category: "branding",
uploadedByUserId: null,
});
expect(asset.url).toBe("/media/branding/test-1.png");
const listed = await listMediaAssets(prisma, { businessId: business.businessId });
expect(listed.map((a) => a.id)).toContain(asset.id);
});
it("filters by category and by filename search", async () => {
await createMediaAsset(prisma, {
businessId: business.businessId,
url: "/media/branding/logo.png",
originalFilename: "shopfront-logo.png",
mimeType: "image/png",
sizeBytes: 100,
width: null,
height: null,
category: "branding",
uploadedByUserId: null,
});
await createMediaAsset(prisma, {
businessId: business.businessId,
url: "/media/portfolio/haircut.jpg",
originalFilename: "fresh-fade.jpg",
mimeType: "image/jpeg",
sizeBytes: 200,
width: null,
height: null,
category: "portfolio",
uploadedByUserId: null,
});
const brandingOnly = await listMediaAssets(prisma, { businessId: business.businessId, category: "branding" });
expect(brandingOnly).toHaveLength(1);
expect(brandingOnly[0]?.category).toBe("branding");
const searched = await listMediaAssets(prisma, { businessId: business.businessId, q: "fade" });
expect(searched).toHaveLength(1);
expect(searched[0]?.originalFilename).toBe("fresh-fade.jpg");
});
it("never lets one business delete another business's asset", async () => {
const otherBusiness = await createTestBusiness();
try {
const asset = await createMediaAsset(prisma, {
businessId: otherBusiness.businessId,
url: "/media/branding/other.png",
originalFilename: null,
mimeType: "image/png",
sizeBytes: 10,
width: null,
height: null,
category: "branding",
uploadedByUserId: null,
});
const result = await deleteMediaAsset(prisma, { id: asset.id, businessId: business.businessId });
expect(result.success).toBe(false);
const stillExists = await prisma.mediaAsset.findUnique({ where: { id: asset.id } });
expect(stillExists).not.toBeNull();
} finally {
await cleanupTestBusiness(otherBusiness);
}
});
it("deletes an asset it owns", async () => {
const asset = await createMediaAsset(prisma, {
businessId: business.businessId,
url: "/media/branding/to-delete.png",
originalFilename: null,
mimeType: "image/png",
sizeBytes: 10,
width: null,
height: null,
category: "branding",
uploadedByUserId: null,
});
const result = await deleteMediaAsset(prisma, { id: asset.id, businessId: business.businessId });
expect(result.success).toBe(true);
const gone = await prisma.mediaAsset.findUnique({ where: { id: asset.id } });
expect(gone).toBeNull();
});
});
+95
View File
@@ -0,0 +1,95 @@
import type { PrismaClient } from "@/generated/prisma/client";
import { deleteMediaFile } from "@/lib/media/media-storage";
/**
* The Media Manager's library — see docs/phase-9-summary.md. Backs every
* image field app-wide (branding, loyalty card, staff/service photos,
* gallery/portfolio) through one reusable picker rather than each
* settings page implementing its own uploader.
*/
export interface MediaAssetSummary {
id: string;
url: string;
originalFilename: string | null;
mimeType: string;
sizeBytes: number;
width: number | null;
height: number | null;
category: string | null;
createdAt: Date;
}
interface MediaAssetRow {
id: string;
url: string;
originalFilename: string | null;
mimeType: string;
sizeBytes: number;
width: number | null;
height: number | null;
category: string | null;
createdAt: Date;
}
function toSummary(asset: MediaAssetRow): MediaAssetSummary {
return {
id: asset.id,
url: asset.url,
originalFilename: asset.originalFilename,
mimeType: asset.mimeType,
sizeBytes: asset.sizeBytes,
width: asset.width,
height: asset.height,
category: asset.category,
createdAt: asset.createdAt,
};
}
export async function listMediaAssets(
prisma: PrismaClient,
params: { businessId: string; category?: string; q?: string },
): Promise<MediaAssetSummary[]> {
const assets = await prisma.mediaAsset.findMany({
where: {
businessId: params.businessId,
...(params.category ? { category: params.category } : {}),
...(params.q ? { originalFilename: { contains: params.q } } : {}),
},
orderBy: { createdAt: "desc" },
});
return assets.map(toSummary);
}
export async function createMediaAsset(
prisma: PrismaClient,
params: {
businessId: string;
url: string;
originalFilename: string | null;
mimeType: string;
sizeBytes: number;
width: number | null;
height: number | null;
category: string | null;
uploadedByUserId: string | null;
},
): Promise<MediaAssetSummary> {
const asset = await prisma.mediaAsset.create({ data: params });
return toSummary(asset);
}
/**
* Scoped to businessId so one business can never delete another's asset —
* not a real risk in v1's single-business deployment, but a free
* guarantee matching the rest of the schema's tenancy discipline.
*/
export async function deleteMediaAsset(
prisma: PrismaClient,
params: { id: string; businessId: string },
): Promise<{ success: boolean }> {
const asset = await prisma.mediaAsset.findFirst({ where: { id: params.id, businessId: params.businessId } });
if (!asset) return { success: false };
await prisma.mediaAsset.delete({ where: { id: asset.id } });
await deleteMediaFile(asset.url);
return { success: true };
}
@@ -0,0 +1,125 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { syncGoogleReviewsForAllBusinesses, syncGoogleReviewsForBusiness } from "@/services/reviews/review-sync.service";
import { DbReviewsProvider } from "@/services/reviews/reviews.service";
import { MockReviewsProvider } from "@/services/reviews/mock-reviews-provider";
import { setReviewHidden } from "@/services/reviews/review-management.service";
import { cleanupTestBusiness, createTestBusiness, prisma } from "@/services/scheduling/__tests__/fixtures";
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
describe("Google Reviews sync + DB-backed provider (integration)", () => {
let business: TestBusiness;
beforeEach(async () => {
business = await createTestBusiness();
});
afterEach(async () => {
await cleanupTestBusiness(business);
});
it("syncs reviews and the place-level aggregate rating, idempotently on re-sync", async () => {
const mock = new MockReviewsProvider();
const first = await syncGoogleReviewsForBusiness(prisma, { businessId: business.businessId, placeId: "place-1" }, mock);
expect(first.synced).toBe(2);
const businessRow = await prisma.business.findUniqueOrThrow({ where: { id: business.businessId } });
expect(Number(businessRow.googleRating)).toBeCloseTo(4.7);
expect(businessRow.googleRatingCount).toBe(128);
const reviews = await prisma.review.findMany({ where: { businessId: business.businessId } });
expect(reviews).toHaveLength(2);
// Re-sync with the same data — upsert, not duplicate.
const second = await syncGoogleReviewsForBusiness(prisma, { businessId: business.businessId, placeId: "place-1" }, mock);
expect(second.synced).toBe(2);
const reviewsAfterResync = await prisma.review.findMany({ where: { businessId: business.businessId } });
expect(reviewsAfterResync).toHaveLength(2);
});
it("only syncs businesses with a configured googlePlaceId", async () => {
const otherBusiness = await createTestBusiness();
try {
await prisma.business.update({ where: { id: business.businessId }, data: { googlePlaceId: "place-configured" } });
// otherBusiness deliberately left without a googlePlaceId.
const mock = new MockReviewsProvider();
const results = await syncGoogleReviewsForAllBusinesses(prisma, mock);
expect(results.map((r) => r.businessId)).toContain(business.businessId);
expect(results.map((r) => r.businessId)).not.toContain(otherBusiness.businessId);
} finally {
await cleanupTestBusiness(otherBusiness);
}
});
it("records a provider error without throwing", async () => {
const failingProvider = { fetchPlaceReviews: async () => { throw new Error("boom"); } };
const result = await syncGoogleReviewsForBusiness(prisma, { businessId: business.businessId, placeId: "place-1" }, failingProvider);
expect(result.error).toBe("boom");
expect(result.synced).toBe(0);
});
describe("DbReviewsProvider (customer-facing reads)", () => {
it("falls back to demo data when no reviews have ever been synced", async () => {
const provider = new DbReviewsProvider(prisma);
const summary = await provider.getBusinessReviews(business.businessId);
expect(summary.isDemoData).toBe(true);
});
it("returns real synced reviews once available, honoring isHidden", async () => {
const mock = new MockReviewsProvider();
await syncGoogleReviewsForBusiness(prisma, { businessId: business.businessId, placeId: "place-1" }, mock);
const provider = new DbReviewsProvider(prisma);
const before = await provider.getBusinessReviews(business.businessId);
expect(before.isDemoData).toBe(false);
expect(before.reviews).toHaveLength(2);
const [firstReview] = await prisma.review.findMany({ where: { businessId: business.businessId } });
await setReviewHidden(prisma, { reviewId: firstReview!.id, businessId: business.businessId, isHidden: true });
const after = await provider.getBusinessReviews(business.businessId);
expect(after.reviews).toHaveLength(1);
expect(after.reviews.some((r) => r.id === firstReview!.id)).toBe(false);
});
it("honors reviewsMinRating and reviewsExcludeRatingOnly from BusinessSettings", async () => {
const mock = new MockReviewsProvider();
await syncGoogleReviewsForBusiness(prisma, { businessId: business.businessId, placeId: "place-1" }, mock);
// MockReviewsProvider's sample: one 5-star review with text, one 3-star review with no text.
await prisma.businessSettings.upsert({
where: { businessId: business.businessId },
create: { businessId: business.businessId, reviewsMinRating: 4 },
update: { reviewsMinRating: 4 },
});
const provider = new DbReviewsProvider(prisma);
const minRatingFiltered = await provider.getBusinessReviews(business.businessId);
expect(minRatingFiltered.reviews.every((r) => r.rating >= 4)).toBe(true);
expect(minRatingFiltered.reviews).toHaveLength(1);
await prisma.businessSettings.update({
where: { businessId: business.businessId },
data: { reviewsMinRating: null, reviewsExcludeRatingOnly: true },
});
const noTextExcluded = await provider.getBusinessReviews(business.businessId);
expect(noTextExcluded.reviews.every((r) => r.text.trim().length > 0)).toBe(true);
});
it("never lets one business's reviews leak into another's summary", async () => {
const otherBusiness = await createTestBusiness();
try {
const mock = new MockReviewsProvider();
await syncGoogleReviewsForBusiness(prisma, { businessId: business.businessId, placeId: "place-1" }, mock);
const provider = new DbReviewsProvider(prisma);
const otherSummary = await provider.getBusinessReviews(otherBusiness.businessId);
expect(otherSummary.isDemoData).toBe(true);
} finally {
await cleanupTestBusiness(otherBusiness);
}
});
});
});
@@ -1,8 +1,16 @@
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createDemoReviewsProvider, getBusinessReviews, setReviewsProvider } from "../reviews.service";
import type { BusinessReviewSummary, ReviewsProvider } from "../reviews.service";
describe("reviews.service", () => {
// The module has no eager default provider (see reviews.service.ts's
// header — it lazily resolves a real, DB-backed provider on first use
// otherwise, which would need a live DATABASE_URL). Every test here
// explicitly opts into the demo provider so none of them ever touch it.
beforeEach(() => {
setReviewsProvider(createDemoReviewsProvider());
});
afterEach(() => {
setReviewsProvider(createDemoReviewsProvider());
});
@@ -0,0 +1,116 @@
/**
* Google Places integration for the Google Reviews feature (Phase 9) —
* mirrors src/services/calendar/google-calendar.provider.ts's own
* pattern deliberately: a small documented-REST-contract interface, a
* plain-`fetch` real implementation, no SDK dependency, and an injectable
* mock for tests (see mock-reviews-provider.ts). Never called from a
* customer-facing request — only from review-sync.service.ts, itself only
* reachable from the /api/cron/sync-google-reviews route.
*
* Uses the legacy Place Details endpoint (not the newer Places API v1) —
* same "documented REST contract, no SDK" choice as the calendar
* provider, and it returns exactly the fields this needs (rating,
* user_ratings_total, reviews) in one call.
*
* Known Google-side limitation, not fixable from this codebase: Place
* Details only ever returns up to 5 "most relevant" individual reviews
* per call, regardless of how many reviews the place actually has. The
* place-level aggregate rating/count (googleRating/googleRatingCount) is
* accurate; the individual review list is always a small sample.
*/
export interface RawGoogleReview {
/**
* Google's legacy API has no stable per-review id — derived from
* author name + the review's own unix timestamp so a re-sync upserts
* the same row instead of duplicating it. Two reviews from the exact
* same author in the exact same second would collide; considered
* acceptable given Google's own API doesn't expose anything better.
*/
externalId: string;
authorName: string;
authorPhotoUrl: string | null;
rating: number;
text: string | null;
relativeTimeDescription: string | null;
publishedAt: Date | null;
}
export interface GooglePlaceReviewsResult {
reviews: RawGoogleReview[];
/** The place's own aggregate rating/count — distinct from (and more accurate than) averaging just the review sample above. */
rating: number | null;
userRatingsTotal: number | null;
}
export interface ReviewsFetchProvider {
fetchPlaceReviews(placeId: string): Promise<GooglePlaceReviewsResult>;
}
export class GooglePlacesProviderError extends Error {
constructor(message: string) {
super(message);
this.name = "GooglePlacesProviderError";
}
}
interface LegacyPlaceDetailsReview {
author_name?: string;
profile_photo_url?: string;
rating?: number;
text?: string;
relative_time_description?: string;
time?: number;
}
interface LegacyPlaceDetailsResponse {
status: string;
error_message?: string;
result?: {
rating?: number;
user_ratings_total?: number;
reviews?: LegacyPlaceDetailsReview[];
};
}
export class GooglePlacesReviewsProvider implements ReviewsFetchProvider {
async fetchPlaceReviews(placeId: string): Promise<GooglePlaceReviewsResult> {
const apiKey = process.env.GOOGLE_PLACES_API_KEY;
if (!apiKey) {
throw new GooglePlacesProviderError("GOOGLE_PLACES_API_KEY is not configured on this deployment.");
}
const url = new URL("https://maps.googleapis.com/maps/api/place/details/json");
url.searchParams.set("place_id", placeId);
url.searchParams.set("fields", "rating,user_ratings_total,reviews");
url.searchParams.set("key", apiKey);
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) {
throw new GooglePlacesProviderError(`Google Places API request failed with HTTP ${response.status}.`);
}
const body = (await response.json()) as LegacyPlaceDetailsResponse;
if (body.status !== "OK") {
throw new GooglePlacesProviderError(
`Google Places API returned status ${body.status}${body.error_message ? `: ${body.error_message}` : ""}.`,
);
}
const reviews: RawGoogleReview[] = (body.result?.reviews ?? []).map((r) => ({
externalId: `${r.author_name ?? "anonymous"}-${r.time ?? 0}`,
authorName: r.author_name ?? "Anonymous",
authorPhotoUrl: r.profile_photo_url ?? null,
rating: typeof r.rating === "number" ? r.rating : 0,
text: r.text || null,
relativeTimeDescription: r.relative_time_description ?? null,
publishedAt: typeof r.time === "number" ? new Date(r.time * 1000) : null,
}));
return {
reviews,
rating: typeof body.result?.rating === "number" ? body.result.rating : null,
userRatingsTotal: typeof body.result?.user_ratings_total === "number" ? body.result.user_ratings_total : null,
};
}
}
@@ -0,0 +1,40 @@
import type { GooglePlaceReviewsResult, ReviewsFetchProvider } from "./google-places.provider";
/**
* In-memory `ReviewsFetchProvider` for tests — never makes a real network
* call, same rationale as MockCalendarProvider (src/services/calendar).
* `result` is settable per-test; defaults to a small realistic sample.
*/
export class MockReviewsProvider implements ReviewsFetchProvider {
result: GooglePlaceReviewsResult = {
rating: 4.7,
userRatingsTotal: 128,
reviews: [
{
externalId: "mock-author-1-1700000000",
authorName: "Mock Author One",
authorPhotoUrl: null,
rating: 5,
text: "Great service, highly recommend.",
relativeTimeDescription: "a week ago",
publishedAt: new Date("2024-01-01T00:00:00Z"),
},
{
externalId: "mock-author-2-1700000100",
authorName: "Mock Author Two",
authorPhotoUrl: null,
rating: 3,
text: null,
relativeTimeDescription: "a month ago",
publishedAt: new Date("2024-01-02T00:00:00Z"),
},
],
};
fetchCallCount = 0;
async fetchPlaceReviews(): Promise<GooglePlaceReviewsResult> {
this.fetchCallCount += 1;
return this.result;
}
}
@@ -0,0 +1,47 @@
import type { PrismaClient } from "@/generated/prisma/client";
/**
* Owner-side review moderation — distinct from reviews.service.ts (the
* customer-facing, policy-filtered read). This always returns everything,
* hidden or not, so the owner can see what they're hiding/unhiding.
*/
export interface OwnerReviewItem {
id: string;
authorName: string;
authorPhotoUrl: string | null;
rating: number;
text: string | null;
relativeTimeDescription: string | null;
publishedAt: Date | null;
isHidden: boolean;
fetchedAt: Date;
}
export async function listReviewsForOwner(prisma: PrismaClient, businessId: string): Promise<OwnerReviewItem[]> {
const reviews = await prisma.review.findMany({
where: { businessId },
orderBy: { publishedAt: "desc" },
});
return reviews.map((r) => ({
id: r.id,
authorName: r.authorName,
authorPhotoUrl: r.authorPhotoUrl,
rating: r.rating,
text: r.text,
relativeTimeDescription: r.relativeTimeDescription,
publishedAt: r.publishedAt,
isHidden: r.isHidden,
fetchedAt: r.fetchedAt,
}));
}
export async function setReviewHidden(
prisma: PrismaClient,
params: { reviewId: string; businessId: string; isHidden: boolean },
): Promise<{ success: boolean }> {
const result = await prisma.review.updateMany({
where: { id: params.reviewId, businessId: params.businessId },
data: { isHidden: params.isHidden },
});
return { success: result.count > 0 };
}
@@ -0,0 +1,86 @@
import type { PrismaClient } from "@/generated/prisma/client";
import { GooglePlacesReviewsProvider } from "./google-places.provider";
import type { ReviewsFetchProvider } from "./google-places.provider";
/**
* The 24h scheduled sync (see /api/cron/sync-google-reviews) — the only
* place that ever calls the Google Places API. Everything customer-facing
* reads the Review table instead (see reviews.service.ts's DbReviewsProvider),
* never Google directly, per the brief's explicit requirement.
*/
function getDefaultProvider(): ReviewsFetchProvider {
return new GooglePlacesReviewsProvider();
}
export interface BusinessReviewSyncResult {
businessId: string;
synced: number;
error?: string;
}
/** One business's sync — upserts every review Google returned (idempotent via the businessId+externalId unique constraint) and refreshes the place-level aggregate rating/count. */
export async function syncGoogleReviewsForBusiness(
prisma: PrismaClient,
params: { businessId: string; placeId: string },
provider: ReviewsFetchProvider = getDefaultProvider(),
): Promise<BusinessReviewSyncResult> {
try {
const result = await provider.fetchPlaceReviews(params.placeId);
const fetchedAt = new Date();
await prisma.$transaction([
prisma.business.update({
where: { id: params.businessId },
data: { googleRating: result.rating, googleRatingCount: result.userRatingsTotal },
}),
...result.reviews.map((review) =>
prisma.review.upsert({
where: { businessId_externalId: { businessId: params.businessId, externalId: review.externalId } },
create: {
businessId: params.businessId,
externalId: review.externalId,
authorName: review.authorName,
authorPhotoUrl: review.authorPhotoUrl,
rating: review.rating,
text: review.text,
relativeTimeDescription: review.relativeTimeDescription,
publishedAt: review.publishedAt,
fetchedAt,
},
update: {
authorName: review.authorName,
authorPhotoUrl: review.authorPhotoUrl,
rating: review.rating,
text: review.text,
relativeTimeDescription: review.relativeTimeDescription,
publishedAt: review.publishedAt,
fetchedAt,
},
}),
),
]);
return { businessId: params.businessId, synced: result.reviews.length };
} catch (error) {
return { businessId: params.businessId, synced: 0, error: error instanceof Error ? error.message : "Unknown error" };
}
}
/** Every business with a configured Google Place ID — businesses without one are silently skipped (review sync is opt-in, see Business.googlePlaceId). */
export async function syncGoogleReviewsForAllBusinesses(
prisma: PrismaClient,
provider: ReviewsFetchProvider = getDefaultProvider(),
): Promise<BusinessReviewSyncResult[]> {
const businesses = await prisma.business.findMany({
where: { googlePlaceId: { not: null } },
select: { id: true, googlePlaceId: true },
});
const results: BusinessReviewSyncResult[] = [];
for (const business of businesses) {
if (!business.googlePlaceId) continue;
results.push(await syncGoogleReviewsForBusiness(prisma, { businessId: business.id, placeId: business.googlePlaceId }, provider));
}
return results;
}
+86 -11
View File
@@ -1,10 +1,16 @@
import type { PrismaClient } from "@/generated/prisma/client";
/**
* Reviews behind a provider abstraction, per the Phase 4 brief — a real
* Google Places/Business Profile integration can implement this same
* interface later without any caller (Customer Home, etc.) changing.
* Backed by clearly-labelled demo data for now; no schema/DB involvement,
* per the Phase 1 plan's recommendation to treat reviews as an external,
* cached read rather than a first-party entity.
* Reviews behind a provider abstraction, per the original Phase 4 brief —
* "a real Google Places/Business Profile integration can implement this
* same interface later without any caller (Customer Home, etc.)
* changing." Phase 9 is that moment: DbReviewsProvider below reads the
* `Review` table (populated by the 24h sync job — see
* review-sync.service.ts) instead of calling Google directly, honoring
* the owner's visibility policy (Review.isHidden,
* BusinessSettings.reviewsMinRating/reviewsExcludeRatingOnly). Falls back
* to demo data only when a business has never had any reviews synced at
* all, so a fresh install doesn't show an empty Reviews tab.
*/
export interface ReviewItem {
@@ -12,7 +18,7 @@ export interface ReviewItem {
authorName: string;
rating: number; // 1–5
text: string;
relativeDate: string; // pre-formatted, e.g. "2 weeks ago" — a real provider would supply this directly
relativeDate: string; // pre-formatted, e.g. "2 weeks ago"
}
export interface BusinessReviewSummary {
@@ -51,7 +57,7 @@ const DEMO_REVIEWS: ReviewItem[] = [
},
];
class DemoReviewsProvider implements ReviewsProvider {
export class DemoReviewsProvider implements ReviewsProvider {
async getBusinessReviews(_businessId: string): Promise<BusinessReviewSummary> {
const totalReviews = 1142; // matches the figure shown in templates/15
const averageRating = 5.0;
@@ -64,13 +70,82 @@ export function createDemoReviewsProvider(): ReviewsProvider {
return new DemoReviewsProvider();
}
let provider: ReviewsProvider = new DemoReviewsProvider();
function averageOf(reviews: { rating: number }[]): number {
if (reviews.length === 0) return 0;
return reviews.reduce((sum, r) => sum + r.rating, 0) / reviews.length;
}
/** Swap the provider (e.g. in a future Google-backed implementation, or a test double) without touching any caller. */
/** The real, DB-backed provider — see this file's header. Never calls Google; only ever reads what review-sync.service.ts already wrote. */
export class DbReviewsProvider implements ReviewsProvider {
constructor(private prisma: PrismaClient) {}
async getBusinessReviews(businessId: string): Promise<BusinessReviewSummary> {
const totalSynced = await this.prisma.review.count({ where: { businessId } });
if (totalSynced === 0) {
return new DemoReviewsProvider().getBusinessReviews(businessId);
}
const [business, settings] = await Promise.all([
this.prisma.business.findUnique({ where: { id: businessId }, select: { googleRating: true, googleRatingCount: true } }),
this.prisma.businessSettings.findUnique({
where: { businessId },
select: { reviewsMinRating: true, reviewsExcludeRatingOnly: true },
}),
]);
const visibleReviews = await this.prisma.review.findMany({
where: {
businessId,
isHidden: false,
...(settings?.reviewsMinRating ? { rating: { gte: settings.reviewsMinRating } } : {}),
...(settings?.reviewsExcludeRatingOnly ? { text: { not: null } } : {}),
},
orderBy: { publishedAt: "desc" },
take: 20,
});
return {
// The place-level aggregate is more accurate than averaging the
// (Google-capped-at-5) sample — see google-places.provider.ts's
// header for why the two are tracked separately.
averageRating: business?.googleRating ? Number(business.googleRating) : averageOf(visibleReviews),
totalReviews: business?.googleRatingCount ?? visibleReviews.length,
reviews: visibleReviews.map((r) => ({
id: r.id,
authorName: r.authorName,
rating: r.rating,
text: r.text ?? "",
relativeDate: r.relativeTimeDescription ?? "",
})),
isDemoData: false,
};
}
}
/**
* `null` = no explicit override; resolved lazily to a real DbReviewsProvider
* on first use. Deliberately NOT constructed eagerly at module load — this
* file must stay importable from a pure unit-test context with no
* DATABASE_URL (see reviews.service.test.ts), and src/lib/db/prisma.ts
* throws at import time if that's unset.
*/
let provider: ReviewsProvider | null = null;
let cachedDefaultProvider: ReviewsProvider | null = null;
/** Swap the provider (e.g. back to demo data, or a test double) without touching any caller. */
export function setReviewsProvider(next: ReviewsProvider): void {
provider = next;
}
async function resolveDefaultProvider(): Promise<ReviewsProvider> {
if (!cachedDefaultProvider) {
const { prisma } = await import("@/lib/db/prisma");
cachedDefaultProvider = new DbReviewsProvider(prisma);
}
return cachedDefaultProvider;
}
export async function getBusinessReviews(businessId: string): Promise<BusinessReviewSummary> {
return provider.getBusinessReviews(businessId);
const active = provider ?? (await resolveDefaultProvider());
return active.getBusinessReviews(businessId);
}
@@ -41,6 +41,10 @@ export interface AvailableService {
id: string;
categoryId: string;
name: string;
/** Collapses in the UI when null/empty — see ServiceCard and ServiceSelector. */
description: string | null;
/** Set via the Media Manager — see src/features/media/components/media-picker.tsx. */
imageUrl: string | null;
durationMinutes: number;
price: string; // Decimal serialized to string — currency formatting is a presentation concern, not this layer's
bufferBeforeMinutes: number;
@@ -66,6 +70,8 @@ export async function getAvailableServices(
id: s.id,
categoryId: s.categoryId,
name: s.name,
description: s.description,
imageUrl: s.imageUrl,
durationMinutes: s.durationMinutes,
price: s.price.toString(),
bufferBeforeMinutes: s.bufferBeforeMinutes,
+3
View File
@@ -25,6 +25,9 @@ export interface StaffSummary {
export interface ServiceSummary {
id: string;
name: string;
/** Collapses in the UI when null/undefined/empty — see ServiceCard. */
description?: string | null;
imageUrl?: string | null;
durationMinutes: number;
/** Formatted for display, e.g. "£22.00" — currency formatting is a
* presentation concern handled once, upstream of these components. */