Post Phase 8 - Fixes 1
Delivered: Super Administrator — env-var provisioned, no StaffProfile at all (structural exclusion from Team/barber-picker/appointments/staff-totals, not a filter). hasPermission short-circuit + new getActingBusinessContext() helper retrofitted across 16 owner pages so it can actually manage the business. Media Manager — one upload/library/external-URL modal + field picker, backing every image field app-wide. Local-disk storage + Docker volume, SVG rejected, size-capped. Welcome backgrounds — desktop/mobile images with CSS breakpoint swap. Business Branding settings — logo/cover/welcome/favicon/icon/colours all editable post-install; cover image finally rendered (was dead config). Service descriptions — now surfaced on Home and in the booking flow. Loyalty card — live preview, ColorPicker/MediaPicker/IconPicker, flip removed for a static premium-styled card, Loyalty added to bottom nav. Fixed a real pre-existing bug where the card's configured colour/image/icon were stored but never actually rendered. Home Screen Redesign — hero gallery + sticky Services/Portfolio/Reviews/Details tabs, backed by a new unified gallery model/service (no hero/portfolio duplication). Details tab surfaces social links and a map that were never shown before. Google Reviews — real sync pipeline (24h cron, CRON_SECRET-gated, mirrors the existing notification cron exactly), DB-only customer reads, owner hide/unhide + policy management page. Architecture cleanup — SettingsPageShell extracted from 6 duplicated page wrappers; fixed a second pre-existing bug where branding image fields could never actually be cleared once set. Verification: 418 tests passing (236 unit + 182 integration, all against a real MySQL database), full typecheck/lint clean across src, and a real Docker build+run cycle — which caught and fixed a genuine bug (scripts/ wasn't copied into the production image, so Super Admin sync would have crashed on every real deployment).
This commit is contained in:
79 files changed
+4121
-450
No files matched your search
@@ -3,18 +3,19 @@ import { Star } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { auth } from "@/auth";
|
||||
import { AppointmentCard } from "@/components/ui/appointment-card";
|
||||
import { BarberCard } from "@/components/ui/barber-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { ServiceCard } from "@/components/ui/service-card";
|
||||
import { Weekday } from "@/generated/prisma/enums";
|
||||
import { ImageGallery } from "@/components/ui/image-gallery";
|
||||
import { GalleryPlacement, Weekday } from "@/generated/prisma/enums";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { computeOpeningStatus } from "@/lib/business/opening-status";
|
||||
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getAvailableServices } from "@/services/scheduling/availability.service";
|
||||
import { listGalleryImages } from "@/services/gallery/gallery.service";
|
||||
import { getLoyaltySummary, type LoyaltySummary } from "@/services/loyalty/loyalty.service";
|
||||
import { getBusinessReviews } from "@/services/reviews/reviews.service";
|
||||
import { HomeTabs } from "@/features/home/components/home-tabs";
|
||||
import type { AppointmentStatus } from "@/types/domain";
|
||||
|
||||
export const metadata: Metadata = { title: "Home" };
|
||||
@@ -49,18 +50,50 @@ const STATUS_MAP: Record<string, AppointmentStatus> = {
|
||||
export default async function CustomerHomePage() {
|
||||
const [business, session] = await Promise.all([getPrimaryBusiness(), auth()]);
|
||||
|
||||
const [services, staff, reviews] = await Promise.all([
|
||||
const [services, categories, staff, reviews, heroImages, portfolioImages] = await Promise.all([
|
||||
getAvailableServices(prisma, { businessId: business.id }),
|
||||
prisma.serviceCategory.findMany({ where: { businessId: business.id, isActive: true }, orderBy: { displayOrder: "asc" } }),
|
||||
prisma.staffProfile.findMany({
|
||||
where: { businessId: business.id, status: "ACTIVE", isBookableOnline: true },
|
||||
include: { user: { select: { name: true, image: true } } },
|
||||
}),
|
||||
getBusinessReviews(business.id),
|
||||
listGalleryImages(prisma, { businessId: business.id, placement: GalleryPlacement.HERO, activeOnly: true }),
|
||||
listGalleryImages(prisma, { businessId: business.id, placement: GalleryPlacement.PORTFOLIO, activeOnly: true }),
|
||||
]);
|
||||
|
||||
const openingStatus = computeOpeningStatus(business.hours, business.timezone);
|
||||
const location = business.locations[0];
|
||||
const previewServices = services.slice(0, 4);
|
||||
|
||||
// Hero: owner-managed gallery first; falls back to a single cover/logo
|
||||
// image (still fully functional with zero gallery rows configured —
|
||||
// see docs/phase-9-summary.md, "Home Screen Redesign").
|
||||
const heroGalleryImages =
|
||||
heroImages.length > 0
|
||||
? heroImages.map((i) => ({ id: i.id, url: i.imageUrl, caption: i.caption }))
|
||||
: business.coverImageUrl || business.logoUrl
|
||||
? [{ id: "fallback", url: (business.coverImageUrl ?? business.logoUrl)!, caption: null }]
|
||||
: [];
|
||||
|
||||
const hoursRows = WEEKDAY_ORDER.map((day) => {
|
||||
const dayHours = business.hours.find((h) => h.dayOfWeek === day);
|
||||
const hoursLabel =
|
||||
!dayHours || dayHours.isClosed || dayHours.opensAtMinutes === null || dayHours.closesAtMinutes === null
|
||||
? "Closed"
|
||||
: `${String(Math.floor(dayHours.opensAtMinutes / 60)).padStart(2, "0")}:${String(dayHours.opensAtMinutes % 60).padStart(2, "0")} – ${String(Math.floor(dayHours.closesAtMinutes / 60)).padStart(2, "0")}:${String(dayHours.closesAtMinutes % 60).padStart(2, "0")}`;
|
||||
return { label: WEEKDAY_LABEL[day], hoursLabel };
|
||||
});
|
||||
|
||||
// Keyless Google Maps embed — no API key/SDK needed for a simple pinned
|
||||
// map, unlike the JS Maps SDK. Prefers exact coordinates; falls back to
|
||||
// the street address when they're not set.
|
||||
const mapQuery =
|
||||
location?.latitude && location?.longitude
|
||||
? `${location.latitude},${location.longitude}`
|
||||
: location
|
||||
? `${location.addressLine1}, ${location.city} ${location.postalCode}`
|
||||
: null;
|
||||
const mapEmbedSrc = mapQuery ? `https://www.google.com/maps?q=${encodeURIComponent(mapQuery)}&output=embed` : null;
|
||||
|
||||
type UpcomingAppointment = Prisma.AppointmentGetPayload<{
|
||||
include: { services: { include: { service: true } }; staff: { include: { user: true } } };
|
||||
@@ -107,13 +140,12 @@ export default async function CustomerHomePage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Hero */}
|
||||
<section className="bg-ink relative h-56 w-full overflow-hidden">
|
||||
{business.logoUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={business.logoUrl} alt="" className="h-full w-full object-cover opacity-80" />
|
||||
) : null}
|
||||
</section>
|
||||
{/* Hero — an owner-managed gallery (see /owner/settings/gallery), matching the content width rather than bleeding edge-to-edge. */}
|
||||
{heroGalleryImages.length > 0 && (
|
||||
<div className="mx-auto max-w-2xl px-6 pt-4">
|
||||
<ImageGallery images={heroGalleryImages} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<main className="mx-auto max-w-2xl bg-white px-6 py-6">
|
||||
{session?.user && (
|
||||
@@ -127,7 +159,7 @@ export default async function CustomerHomePage() {
|
||||
{location.addressLine1}, {location.city} {location.postalCode}
|
||||
</p>
|
||||
)}
|
||||
<div className="mb-4 flex items-center gap-3 text-sm">
|
||||
<div className="mb-6 flex items-center gap-3 text-sm">
|
||||
<div className="text-accent-loyalty flex items-center gap-1">
|
||||
<Star className="h-4 w-4 fill-current" aria-hidden="true" />
|
||||
<span className="text-ink font-bold">{reviews.averageRating.toFixed(1)}</span>
|
||||
@@ -139,9 +171,6 @@ export default async function CustomerHomePage() {
|
||||
{openingStatus.label}
|
||||
</span>
|
||||
</div>
|
||||
{business.description && (
|
||||
<p className="text-muted mb-6 text-sm leading-relaxed">{business.description}</p>
|
||||
)}
|
||||
|
||||
{/* Upcoming appointment */}
|
||||
{upcomingAppointment && (
|
||||
@@ -239,134 +268,26 @@ export default async function CustomerHomePage() {
|
||||
<Link href="/book">Book an Appointment</Link>
|
||||
</Button>
|
||||
|
||||
{/* Services */}
|
||||
<section className="mb-8">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-muted text-xs font-bold tracking-widest uppercase">
|
||||
Popular Services
|
||||
</h2>
|
||||
<Link
|
||||
href="/book"
|
||||
className="text-ink text-[10px] font-bold tracking-widest uppercase underline"
|
||||
>
|
||||
See all
|
||||
</Link>
|
||||
</div>
|
||||
<Card className="divide-line divide-y px-5">
|
||||
{previewServices.map((service) => (
|
||||
<ServiceCard
|
||||
key={service.id}
|
||||
service={{
|
||||
id: service.id,
|
||||
name: service.name,
|
||||
durationMinutes: service.durationMinutes,
|
||||
formattedPrice: `£${Number(service.price).toFixed(2)}`,
|
||||
}}
|
||||
action={
|
||||
<Button asChild size="sm">
|
||||
<Link href="/book">Book</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Barbers */}
|
||||
<section className="mb-8">
|
||||
<h2 className="text-muted mb-3 text-xs font-bold tracking-widest uppercase">
|
||||
Our Barbers
|
||||
</h2>
|
||||
<div className="flex gap-5 overflow-x-auto pb-1">
|
||||
{staff.map((s) => (
|
||||
<BarberCard
|
||||
key={s.id}
|
||||
staff={{
|
||||
id: s.id,
|
||||
name: s.user.name ?? "Barber",
|
||||
avatarUrl: s.user.image ?? undefined,
|
||||
}}
|
||||
isPreferred={customerProfile?.preferredBarberId === s.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Reviews */}
|
||||
<section className="mb-8">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-muted text-xs font-bold tracking-widest uppercase">Reviews</h2>
|
||||
{reviews.isDemoData && (
|
||||
<span className="text-muted text-[9px] font-bold tracking-widest uppercase">
|
||||
Demo data
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{reviews.reviews.map((review) => (
|
||||
<Card key={review.id} className="p-4">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<p className="text-sm font-bold">{review.authorName}</p>
|
||||
<span className="text-muted text-[10px]">{review.relativeDate}</span>
|
||||
</div>
|
||||
<div className="text-accent-loyalty mb-2 flex gap-0.5">
|
||||
{Array.from({ length: review.rating }, (_, i) => (
|
||||
<Star key={i} className="h-3 w-3 fill-current" aria-hidden="true" />
|
||||
))}
|
||||
</div>
|
||||
<p className="text-muted text-xs">{review.text}</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Hours */}
|
||||
<section className="mb-4">
|
||||
<h2 className="text-muted mb-3 text-xs font-bold tracking-widest uppercase">
|
||||
Opening Hours
|
||||
</h2>
|
||||
<Card className="divide-line divide-y px-5">
|
||||
{WEEKDAY_ORDER.map((day) => {
|
||||
const dayHours = business.hours.find((h) => h.dayOfWeek === day);
|
||||
return (
|
||||
<div key={day} className="flex items-center justify-between py-3 text-sm">
|
||||
<span className="text-muted">{WEEKDAY_LABEL[day]}</span>
|
||||
<span className="font-medium">
|
||||
{!dayHours ||
|
||||
dayHours.isClosed ||
|
||||
dayHours.opensAtMinutes === null ||
|
||||
dayHours.closesAtMinutes === null
|
||||
? "Closed"
|
||||
: `${String(Math.floor(dayHours.opensAtMinutes / 60)).padStart(2, "0")}:${String(dayHours.opensAtMinutes % 60).padStart(2, "0")} – ${String(Math.floor(dayHours.closesAtMinutes / 60)).padStart(2, "0")}:${String(dayHours.closesAtMinutes % 60).padStart(2, "0")}`}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Contact */}
|
||||
<section>
|
||||
<h2 className="text-muted mb-3 text-xs font-bold tracking-widest uppercase">Contact</h2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{business.phone && (
|
||||
<a
|
||||
href={`tel:${business.phone}`}
|
||||
className="border-line rounded-xl border bg-white py-4 text-center text-sm font-bold"
|
||||
>
|
||||
Call
|
||||
</a>
|
||||
)}
|
||||
{business.email && (
|
||||
<a
|
||||
href={`mailto:${business.email}`}
|
||||
className="border-line rounded-xl border bg-white py-4 text-center text-sm font-bold"
|
||||
>
|
||||
Email
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
<HomeTabs
|
||||
services={services}
|
||||
categories={categories.map((c) => ({ id: c.id, name: c.name }))}
|
||||
portfolioImages={portfolioImages}
|
||||
reviews={reviews}
|
||||
description={business.description}
|
||||
phone={business.phone}
|
||||
email={business.email}
|
||||
website={business.website}
|
||||
instagramUrl={business.instagramUrl}
|
||||
facebookUrl={business.facebookUrl}
|
||||
hoursRows={hoursRows}
|
||||
staff={staff.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.user.name ?? "Barber",
|
||||
avatarUrl: s.user.image ?? undefined,
|
||||
isPreferred: customerProfile?.preferredBarberId === s.id,
|
||||
}))}
|
||||
mapEmbedSrc={mapEmbedSrc}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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))],
|
||||
});
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,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 (
|
||||
|
||||
@@ -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
@@ -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,7 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { CalendarCheck, CalendarDays, ChartLine, House, Scissors, Settings, User, Users } from "lucide-react";
|
||||
import { CalendarCheck, CalendarDays, ChartLine, Gift, House, Scissors, Settings, User, Users } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
|
||||
@@ -18,6 +18,7 @@ import { cn } from "@/lib/utils/cn";
|
||||
export const NAV_ICON_MAP = {
|
||||
house: House,
|
||||
"calendar-check": CalendarCheck,
|
||||
gift: Gift,
|
||||
user: User,
|
||||
"calendar-days": CalendarDays,
|
||||
scissors: Scissors,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { TopNav } from "@/components/navigation/top-nav";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
|
||||
export interface SettingsPageShellProps {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
/** Merged onto the default `<main>` classes via cn()/tailwind-merge — pass e.g. "lg:max-w-2xl" to opt out of the default wide desktop layout, or extra utilities like "space-y-8". */
|
||||
mainClassName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The TopNav + centered container wrapper repeated verbatim across every
|
||||
* owner settings page (Business Details, Gallery, Loyalty, Booking
|
||||
* Rules, Opening Hours, Reviews, ...) — extracted here in Phase 9 once
|
||||
* enough pages shared it to be worth the indirection. See
|
||||
* docs/phase-9-summary.md, "Architecture Review".
|
||||
*/
|
||||
export function SettingsPageShell({ title, children, mainClassName }: SettingsPageShellProps) {
|
||||
return (
|
||||
<div className="pb-12">
|
||||
<TopNav title={title} onBack sticky className="bg-stone" />
|
||||
<main className={cn("mx-auto max-w-2xl px-6 py-6 lg:max-w-4xl", mainClassName)}>{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useState } from "react";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
|
||||
const DEFAULT_PRESETS = ["#111b15", "#1d4ed8", "#b91c1c", "#15803d", "#a16207", "#374151", "#ffffff"];
|
||||
const HEX_PATTERN = /^#[0-9a-f]{6}$/i;
|
||||
|
||||
export interface ColorPickerProps {
|
||||
label?: string;
|
||||
value: string | null | undefined;
|
||||
onChange: (hex: string) => void;
|
||||
presets?: string[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single reusable colour input — native swatch + hex text field + presets
|
||||
* — shared by theme/background colour (Business Branding) and the
|
||||
* loyalty card designer rather than each screen rolling its own. See
|
||||
* docs/phase-9-summary.md.
|
||||
*/
|
||||
export function ColorPicker({ label, value, onChange, presets = DEFAULT_PRESETS, className }: ColorPickerProps) {
|
||||
const id = useId();
|
||||
const [text, setText] = useState(value ?? "");
|
||||
const swatchColor = value && HEX_PATTERN.test(value) ? value : "#000000";
|
||||
|
||||
function commit(next: string) {
|
||||
setText(next);
|
||||
if (HEX_PATTERN.test(next)) onChange(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("w-full", className)}>
|
||||
{label && (
|
||||
<label htmlFor={id} className="text-muted mb-2 ml-1 block text-[10px] font-bold tracking-widest uppercase">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<label
|
||||
className="border-line relative h-11 w-11 shrink-0 overflow-hidden rounded-xl border"
|
||||
style={{ backgroundColor: swatchColor }}
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
value={swatchColor}
|
||||
onChange={(e) => commit(e.target.value)}
|
||||
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
|
||||
aria-label={label ? `${label} colour swatch` : "Colour swatch"}
|
||||
/>
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
type="text"
|
||||
value={text}
|
||||
onChange={(e) => commit(e.target.value)}
|
||||
placeholder="#111b15"
|
||||
spellCheck={false}
|
||||
className="border-line placeholder:text-muted focus:ring-ink w-full rounded-xl border bg-white px-4 py-3 font-mono text-sm outline-none focus:ring-2"
|
||||
/>
|
||||
</div>
|
||||
{presets.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{presets.map((preset) => (
|
||||
<button
|
||||
key={preset}
|
||||
type="button"
|
||||
onClick={() => commit(preset)}
|
||||
aria-label={`Use ${preset}`}
|
||||
className={cn(
|
||||
"h-6 w-6 rounded-full border-2",
|
||||
swatchColor.toLowerCase() === preset.toLowerCase() ? "border-ink" : "border-transparent",
|
||||
)}
|
||||
style={{ backgroundColor: preset }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useMemo, useState } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Search } from "lucide-react";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import { ICON_CATALOG, findCatalogIcon } from "@/lib/icons/icon-catalog";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
|
||||
export interface IconPickerProps {
|
||||
label?: string;
|
||||
value: string | null | undefined;
|
||||
onChange: (iconName: string) => void;
|
||||
}
|
||||
|
||||
/** Searchable Font Awesome icon picker — replaces free-text icon-name input (e.g. the loyalty stamp icon). See src/lib/icons/icon-catalog.ts. */
|
||||
export function IconPicker({ label, value, onChange }: IconPickerProps) {
|
||||
const labelId = useId();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const selected = findCatalogIcon(value);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return ICON_CATALOG;
|
||||
return ICON_CATALOG.filter((icon) => icon.iconName.toLowerCase().includes(q));
|
||||
}, [query]);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<span id={labelId} className="text-muted mb-2 ml-1 block text-[10px] font-bold tracking-widest uppercase">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
aria-labelledby={label ? labelId : undefined}
|
||||
className="border-line flex w-full items-center gap-3 rounded-xl border bg-white px-5 py-4 text-left text-sm"
|
||||
>
|
||||
<span className="bg-stone flex h-9 w-9 shrink-0 items-center justify-center rounded-full">
|
||||
<FontAwesomeIcon icon={selected} className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="flex-1 capitalize">{selected.iconName.replace(/-/g, " ")}</span>
|
||||
<span className="text-muted text-xs font-bold tracking-widest uppercase">Change</span>
|
||||
</button>
|
||||
|
||||
<Modal open={open} onClose={() => setOpen(false)} title="Choose an icon">
|
||||
<div className="relative mb-4">
|
||||
<Search className="text-muted pointer-events-none absolute top-1/2 left-4 h-4 w-4 -translate-y-1/2" aria-hidden="true" />
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search icons"
|
||||
className="border-line focus:ring-ink w-full rounded-xl border bg-white py-3 pr-4 pl-11 text-sm outline-none focus:ring-2"
|
||||
/>
|
||||
</div>
|
||||
{filtered.length === 0 ? (
|
||||
<p className="text-muted py-6 text-center text-sm">No icons match “{query}”.</p>
|
||||
) : (
|
||||
<div className="grid max-h-80 grid-cols-5 gap-2 overflow-y-auto">
|
||||
{filtered.map((icon) => (
|
||||
<button
|
||||
key={icon.iconName}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(icon.iconName);
|
||||
setOpen(false);
|
||||
}}
|
||||
aria-label={icon.iconName}
|
||||
aria-pressed={icon.iconName === selected.iconName}
|
||||
className={cn(
|
||||
"flex aspect-square flex-col items-center justify-center rounded-xl border",
|
||||
icon.iconName === selected.iconName ? "border-ink bg-stone" : "border-line hover:bg-stone",
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={icon} className="h-4 w-4" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
|
||||
export interface ImageGalleryProps {
|
||||
images: { id: string; url: string; caption?: string | null }[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Home page hero — a real gallery with thumbnails (Phase 9), replacing
|
||||
* the old single full-bleed logo banner. Matches the content width rather
|
||||
* than bleeding edge-to-edge, per the brief. Plain click-to-swap + arrow
|
||||
* buttons; no swipe-gesture library, since the thumbnail strip already
|
||||
* covers "jump to any image" and arrows cover "next/previous" — a
|
||||
* touch-drag carousel would be a reasonable future upgrade, not required
|
||||
* for this to be a real gallery.
|
||||
*/
|
||||
export function ImageGallery({ images, className }: ImageGalleryProps) {
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
if (images.length === 0) return null;
|
||||
|
||||
const active = images[Math.min(activeIndex, images.length - 1)]!;
|
||||
const goTo = (index: number) => setActiveIndex((index + images.length) % images.length);
|
||||
|
||||
return (
|
||||
<div className={cn("w-full", className)}>
|
||||
<div className="bg-stone relative aspect-[16/9] w-full overflow-hidden rounded-jumbo">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- local/user-supplied URL, same pattern used app-wide */}
|
||||
<img src={active.url} alt={active.caption ?? ""} className="h-full w-full object-cover" />
|
||||
{images.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => goTo(activeIndex - 1)}
|
||||
aria-label="Previous image"
|
||||
className="bg-ink/60 hover:bg-ink/80 absolute top-1/2 left-3 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full text-white"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => goTo(activeIndex + 1)}
|
||||
aria-label="Next image"
|
||||
className="bg-ink/60 hover:bg-ink/80 absolute top-1/2 right-3 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full text-white"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{images.length > 1 && (
|
||||
<div className="mt-2 flex gap-2 overflow-x-auto pb-1">
|
||||
{images.map((image, index) => (
|
||||
<button
|
||||
key={image.id}
|
||||
type="button"
|
||||
onClick={() => setActiveIndex(index)}
|
||||
aria-label={`Show image ${index + 1}`}
|
||||
aria-current={index === activeIndex}
|
||||
className={cn(
|
||||
"h-14 w-20 shrink-0 overflow-hidden rounded-lg border-2",
|
||||
index === activeIndex ? "border-ink" : "border-transparent opacity-70",
|
||||
)}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- local/user-supplied URL, same pattern used app-wide */}
|
||||
<img src={image.url} alt="" className="h-full w-full object-cover" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Scissors } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { findCatalogIcon } from "@/lib/icons/icon-catalog";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
|
||||
export interface LoyaltyCardProps {
|
||||
@@ -11,15 +11,23 @@ export interface LoyaltyCardProps {
|
||||
stampsEarned: number;
|
||||
rewardLabel: string;
|
||||
termsLine?: string;
|
||||
/** Owner-configured card appearance (LoyaltyProgramme) — see the loyalty settings designer. All optional; sensible defaults apply when unset. */
|
||||
cardBackgroundColor?: string | null;
|
||||
cardBackgroundImageUrl?: string | null;
|
||||
stampIcon?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ports the 3D flip loyalty card from templates/11 — the best-executed
|
||||
* visual in the whole template set (see docs/implementation-plan.md §2) —
|
||||
* but fixes the gap flagged in §8: the original had no actual flip
|
||||
* trigger wired up. Here the card is a real <button> with
|
||||
* `aria-pressed`, and the flip transition is skipped for users who
|
||||
* prefer reduced motion.
|
||||
* Static, non-flipping card — see docs/phase-9-summary.md ("Loyalty Card
|
||||
* Improvements"). Previously a 3D flip whose back face just repeated the
|
||||
* business name in a generic panel; the brief asked to drop that and only
|
||||
* ever show the stamped side, restyled to feel like a real plastic
|
||||
* loyalty card (diagonal shine + soft depth via layered CSS
|
||||
* gradients/shadows only — no extra image asset, so this stays cheap to
|
||||
* render). Also, previously, cardBackgroundColor/cardBackgroundImageUrl/
|
||||
* stampIcon were stored on LoyaltyProgramme but never actually reached
|
||||
* this component — every business rendered the same hardcoded card
|
||||
* regardless of configuration. Fixed here.
|
||||
*/
|
||||
export function LoyaltyCard({
|
||||
businessName,
|
||||
@@ -28,80 +36,74 @@ export function LoyaltyCard({
|
||||
stampsEarned,
|
||||
rewardLabel,
|
||||
termsLine,
|
||||
cardBackgroundColor,
|
||||
cardBackgroundImageUrl,
|
||||
stampIcon,
|
||||
}: LoyaltyCardProps) {
|
||||
const [flipped, setFlipped] = useState(false);
|
||||
const icon = findCatalogIcon(stampIcon);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFlipped((v) => !v)}
|
||||
aria-pressed={flipped}
|
||||
aria-label={flipped ? "Show stamp side" : "Show card back"}
|
||||
className="w-full [perspective:1000px]"
|
||||
style={{ aspectRatio: "1.65 / 1" }}
|
||||
<div
|
||||
className="ring-accent-loyalty relative w-full overflow-hidden rounded-2xl shadow-[0_30px_60px_rgba(0,0,0,0.45)] ring-[3px]"
|
||||
style={{ aspectRatio: "1.65 / 1", backgroundColor: cardBackgroundColor || "#141414" }}
|
||||
role="img"
|
||||
aria-label={`${businessName} loyalty card, ${stampsEarned} of ${stampsRequired} stamps`}
|
||||
>
|
||||
{cardBackgroundImageUrl && (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- local/user-supplied URL, same pattern used app-wide
|
||||
<img src={cardBackgroundImageUrl} alt="" className="absolute inset-0 h-full w-full object-cover" />
|
||||
)}
|
||||
{/* Dark scrim so stamps/text stay legible over an arbitrary background image. */}
|
||||
{cardBackgroundImageUrl && <div className="absolute inset-0 bg-black/35" aria-hidden="true" />}
|
||||
{/* Premium plastic-card shine — a soft diagonal highlight, CSS-only. */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative h-full w-full rounded-2xl shadow-[0_30px_60px_rgba(0,0,0,0.45)] transition-transform duration-700 [transform-style:preserve-3d] motion-reduce:transition-none",
|
||||
"ring-[3px] ring-accent-loyalty",
|
||||
)}
|
||||
style={{ transform: flipped ? "rotateY(180deg)" : "rotateY(0deg)" }}
|
||||
>
|
||||
{/* Front — stamps */}
|
||||
<div className="absolute inset-0 flex flex-col justify-between overflow-hidden rounded-2xl bg-[#141414] p-4 [backface-visibility:hidden]">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-black italic tracking-tight text-white">Loyalty Card</h2>
|
||||
<span className="rounded border border-accent-loyalty/30 px-2 py-0.5 text-[8px] font-bold uppercase tracking-widest text-accent-loyalty">
|
||||
Member #{memberNumber}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{Array.from({ length: stampsRequired }, (_, i) => {
|
||||
const earned = i < stampsEarned;
|
||||
// Only the final slot gets reward styling, and only once it's
|
||||
// actually been earned — otherwise an in-progress card (e.g.
|
||||
// 3/10 stamps) would show the reward label on slot 10 already.
|
||||
const isRewardSlot = earned && i === stampsRequired - 1;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"flex aspect-square items-center justify-center rounded-full",
|
||||
isRewardSlot
|
||||
? "scale-[1.08] bg-accent-loyalty shadow-lg"
|
||||
: earned
|
||||
? "bg-white shadow-inner"
|
||||
: "border-2 border-dashed border-white/20 bg-[#2a2a2a]",
|
||||
)}
|
||||
>
|
||||
{isRewardSlot ? (
|
||||
<span className="text-center text-[7px] font-black leading-tight text-ink">
|
||||
{rewardLabel}
|
||||
</span>
|
||||
) : earned ? (
|
||||
<Scissors className="h-3.5 w-3.5 text-ink" aria-hidden="true" />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{termsLine && (
|
||||
<p className="text-center text-[7px] font-bold uppercase tracking-widest text-white/40">
|
||||
{termsLine}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
className="pointer-events-none absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(115deg, rgba(255,255,255,0.16) 0%, rgba(255,255,255,0) 30%, rgba(255,255,255,0) 70%, rgba(255,255,255,0.08) 100%)",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Back — brand */}
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded-2xl bg-ink [backface-visibility:hidden] [transform:rotateY(180deg)]">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-white text-3xl text-ink">
|
||||
<Scissors className="h-7 w-7" aria-hidden="true" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-black uppercase tracking-tighter text-white">{businessName}</h3>
|
||||
</div>
|
||||
<div className="relative flex h-full flex-col justify-between p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-black italic tracking-tight text-white">Loyalty Card</h2>
|
||||
<span className="border-accent-loyalty/30 text-accent-loyalty rounded border px-2 py-0.5 text-[8px] font-bold tracking-widest uppercase">
|
||||
Member #{memberNumber}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{Array.from({ length: stampsRequired }, (_, i) => {
|
||||
const earned = i < stampsEarned;
|
||||
// Only the final slot gets reward styling, and only once it's
|
||||
// actually been earned — otherwise an in-progress card (e.g.
|
||||
// 3/10 stamps) would show the reward label on slot 10 already.
|
||||
const isRewardSlot = earned && i === stampsRequired - 1;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"flex aspect-square items-center justify-center rounded-full",
|
||||
isRewardSlot
|
||||
? "bg-accent-loyalty scale-[1.08] shadow-lg"
|
||||
: earned
|
||||
? "bg-white shadow-inner"
|
||||
: "border-2 border-dashed border-white/20 bg-white/10",
|
||||
)}
|
||||
>
|
||||
{isRewardSlot ? (
|
||||
<span className="text-ink text-center text-[7px] leading-tight font-black">{rewardLabel}</span>
|
||||
) : earned ? (
|
||||
<FontAwesomeIcon icon={icon} className="text-ink h-3.5 w-3.5" />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{termsLine && (
|
||||
<p className="text-center text-[7px] font-bold tracking-widest text-white/40 uppercase">{termsLine}</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,9 +10,16 @@ export interface ServiceCardProps {
|
||||
export function ServiceCard({ service, action }: ServiceCardProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 py-4">
|
||||
{service.imageUrl && (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- local/user-supplied URL, same pattern used app-wide
|
||||
<img src={service.imageUrl} alt="" className="bg-stone h-14 w-14 shrink-0 rounded-xl object-cover" />
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-medium">{service.name}</h4>
|
||||
<p className="mt-1 text-xs text-muted">{service.durationMinutes}m</p>
|
||||
{service.description && (
|
||||
<p className="mt-1 text-xs text-muted line-clamp-2">{service.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="font-bold">{service.formattedPrice}</span>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { AtSign, Globe, Mail, Phone, Search, Share2, Star } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { BarberCard } from "@/components/ui/barber-card";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { ServiceCard } from "@/components/ui/service-card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
import type { AvailableService } from "@/services/scheduling/availability.service";
|
||||
import type { BusinessReviewSummary } from "@/services/reviews/reviews.service";
|
||||
import type { GalleryImageSummary } from "@/services/gallery/gallery.service";
|
||||
import type { StaffSummary } from "@/types/domain";
|
||||
|
||||
export interface HomeTabsProps {
|
||||
services: AvailableService[];
|
||||
categories: { id: string; name: string }[];
|
||||
portfolioImages: GalleryImageSummary[];
|
||||
reviews: BusinessReviewSummary;
|
||||
description: string | null;
|
||||
phone: string | null;
|
||||
email: string | null;
|
||||
website: string | null;
|
||||
instagramUrl: string | null;
|
||||
facebookUrl: string | null;
|
||||
hoursRows: { label: string; hoursLabel: string }[];
|
||||
staff: (StaffSummary & { isPreferred: boolean })[];
|
||||
mapEmbedSrc: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sticky Services/Portfolio/Reviews/Details tabs below the Home hero —
|
||||
* Phase 9's Home Screen Redesign. All data is pre-fetched server-side by
|
||||
* the Home page and passed in as plain props; tab switching and the
|
||||
* Services search/category filter are pure client-side state over that
|
||||
* already-fetched data, not new network round trips per interaction.
|
||||
*/
|
||||
export function HomeTabs(props: HomeTabsProps) {
|
||||
return (
|
||||
<Tabs defaultValue="services">
|
||||
<TabsList className="bg-stone border-line sticky top-0 z-30 -mx-6 rounded-none border-b px-6 py-3">
|
||||
<TabsTrigger value="services">Services</TabsTrigger>
|
||||
<TabsTrigger value="portfolio">Portfolio</TabsTrigger>
|
||||
<TabsTrigger value="reviews">Reviews</TabsTrigger>
|
||||
<TabsTrigger value="details">Details</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="services">
|
||||
<ServicesTab services={props.services} categories={props.categories} />
|
||||
</TabsContent>
|
||||
<TabsContent value="portfolio">
|
||||
<PortfolioTab images={props.portfolioImages} />
|
||||
</TabsContent>
|
||||
<TabsContent value="reviews">
|
||||
<ReviewsTab reviews={props.reviews} />
|
||||
</TabsContent>
|
||||
<TabsContent value="details">
|
||||
<DetailsTab
|
||||
description={props.description}
|
||||
phone={props.phone}
|
||||
email={props.email}
|
||||
website={props.website}
|
||||
instagramUrl={props.instagramUrl}
|
||||
facebookUrl={props.facebookUrl}
|
||||
hoursRows={props.hoursRows}
|
||||
staff={props.staff}
|
||||
mapEmbedSrc={props.mapEmbedSrc}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
function ServicesTab({
|
||||
services,
|
||||
categories,
|
||||
}: {
|
||||
services: AvailableService[];
|
||||
categories: { id: string; name: string }[];
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeCategoryId, setActiveCategoryId] = useState<string | null>(null);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return services.filter((s) => {
|
||||
if (activeCategoryId && s.categoryId !== activeCategoryId) return false;
|
||||
if (q && !s.name.toLowerCase().includes(q)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [services, query, activeCategoryId]);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const byCategory = new Map<string, AvailableService[]>();
|
||||
for (const service of filtered) {
|
||||
const list = byCategory.get(service.categoryId) ?? [];
|
||||
list.push(service);
|
||||
byCategory.set(service.categoryId, list);
|
||||
}
|
||||
return categories
|
||||
.map((c) => ({ ...c, services: byCategory.get(c.id) ?? [] }))
|
||||
.filter((g) => g.services.length > 0);
|
||||
}, [filtered, categories]);
|
||||
|
||||
const categoriesInUse = useMemo(() => categories.filter((c) => services.some((s) => s.categoryId === c.id)), [categories, services]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="relative">
|
||||
<Search className="text-muted pointer-events-none absolute top-1/2 left-4 h-4 w-4 -translate-y-1/2" aria-hidden="true" />
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search services"
|
||||
className="border-line focus:ring-ink w-full rounded-xl border bg-white py-2.5 pr-4 pl-11 text-sm outline-none focus:ring-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{categoriesInUse.length > 1 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveCategoryId(null)}
|
||||
className={cn(
|
||||
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
|
||||
activeCategoryId === null ? "bg-ink text-white" : "border-line text-muted border bg-white",
|
||||
)}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{categoriesInUse.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
onClick={() => setActiveCategoryId(c.id)}
|
||||
className={cn(
|
||||
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
|
||||
activeCategoryId === c.id ? "bg-ink text-white" : "border-line text-muted border bg-white",
|
||||
)}
|
||||
>
|
||||
{c.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{grouped.length === 0 ? (
|
||||
<p className="text-muted py-6 text-center text-sm">No services match.</p>
|
||||
) : (
|
||||
grouped.map((group) => (
|
||||
<section key={group.id}>
|
||||
<h3 className="text-muted mb-2 text-xs font-bold tracking-widest uppercase">{group.name}</h3>
|
||||
<Card className="divide-line divide-y px-5">
|
||||
{group.services.map((service) => (
|
||||
<ServiceCard
|
||||
key={service.id}
|
||||
service={{
|
||||
id: service.id,
|
||||
name: service.name,
|
||||
description: service.description,
|
||||
imageUrl: service.imageUrl,
|
||||
durationMinutes: service.durationMinutes,
|
||||
formattedPrice: `£${Number(service.price).toFixed(2)}`,
|
||||
}}
|
||||
action={
|
||||
<Button asChild size="sm">
|
||||
<Link href="/book">Book</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Card>
|
||||
</section>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PortfolioTab({ images }: { images: GalleryImageSummary[] }) {
|
||||
const categories = useMemo(
|
||||
() => Array.from(new Set(images.map((i) => i.category).filter((c): c is string => !!c))),
|
||||
[images],
|
||||
);
|
||||
const [activeCategory, setActiveCategory] = useState<string | null>(null);
|
||||
|
||||
const filtered = activeCategory ? images.filter((i) => i.category === activeCategory) : images;
|
||||
|
||||
if (images.length === 0) {
|
||||
return <p className="text-muted py-10 text-center text-sm">No portfolio images yet.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{categories.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveCategory(null)}
|
||||
className={cn(
|
||||
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
|
||||
activeCategory === null ? "bg-ink text-white" : "border-line text-muted border bg-white",
|
||||
)}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{categories.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => setActiveCategory(c)}
|
||||
className={cn(
|
||||
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
|
||||
activeCategory === c ? "bg-ink text-white" : "border-line text-muted border bg-white",
|
||||
)}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{filtered.map((image) => (
|
||||
<div key={image.id} className="bg-stone aspect-square overflow-hidden rounded-xl">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- local/user-supplied URL, same pattern used app-wide */}
|
||||
<img src={image.imageUrl} alt={image.caption ?? ""} className="h-full w-full object-cover" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewsTab({ reviews }: { reviews: BusinessReviewSummary }) {
|
||||
if (reviews.reviews.length === 0) {
|
||||
return <p className="text-muted py-10 text-center text-sm">No reviews yet.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{reviews.isDemoData && (
|
||||
<p className="text-muted text-right text-[9px] font-bold tracking-widest uppercase">Demo data</p>
|
||||
)}
|
||||
{reviews.reviews.map((review) => (
|
||||
<Card key={review.id} className="p-4">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<p className="text-sm font-bold">{review.authorName}</p>
|
||||
<span className="text-muted text-[10px]">{review.relativeDate}</span>
|
||||
</div>
|
||||
<div className="text-accent-loyalty mb-2 flex gap-0.5">
|
||||
{Array.from({ length: review.rating }, (_, i) => (
|
||||
<Star key={i} className="h-3 w-3 fill-current" aria-hidden="true" />
|
||||
))}
|
||||
</div>
|
||||
<p className="text-muted text-xs">{review.text}</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailsTab({
|
||||
description,
|
||||
phone,
|
||||
email,
|
||||
website,
|
||||
instagramUrl,
|
||||
facebookUrl,
|
||||
hoursRows,
|
||||
staff,
|
||||
mapEmbedSrc,
|
||||
}: {
|
||||
description: string | null;
|
||||
phone: string | null;
|
||||
email: string | null;
|
||||
website: string | null;
|
||||
instagramUrl: string | null;
|
||||
facebookUrl: string | null;
|
||||
hoursRows: { label: string; hoursLabel: string }[];
|
||||
staff: (StaffSummary & { isPreferred: boolean })[];
|
||||
mapEmbedSrc: string | null;
|
||||
}) {
|
||||
const hasSocial = website || instagramUrl || facebookUrl;
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{mapEmbedSrc && (
|
||||
<section>
|
||||
<div className="bg-stone aspect-[16/9] w-full overflow-hidden rounded-jumbo">
|
||||
<iframe
|
||||
src={mapEmbedSrc}
|
||||
title="Business location map"
|
||||
className="h-full w-full border-0"
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer-when-downgrade"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{description && (
|
||||
<section>
|
||||
<h3 className="text-muted mb-2 text-xs font-bold tracking-widest uppercase">About Us</h3>
|
||||
<p className="text-muted text-sm leading-relaxed">{description}</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{staff.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-muted mb-3 text-xs font-bold tracking-widest uppercase">Staff</h3>
|
||||
<div className="flex gap-5 overflow-x-auto pb-1">
|
||||
{staff.map((s) => (
|
||||
<BarberCard key={s.id} staff={s} isPreferred={s.isPreferred} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{hoursRows.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-muted mb-3 text-xs font-bold tracking-widest uppercase">Opening Hours</h3>
|
||||
<Card className="divide-line divide-y px-5">
|
||||
{hoursRows.map((row) => (
|
||||
<div key={row.label} className="flex items-center justify-between py-3 text-sm">
|
||||
<span className="text-muted">{row.label}</span>
|
||||
<span className="font-medium">{row.hoursLabel}</span>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{(hasSocial || phone || email) && (
|
||||
<section>
|
||||
<h3 className="text-muted mb-3 text-xs font-bold tracking-widest uppercase">Social & Contact</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{phone && (
|
||||
<a href={`tel:${phone}`} className="border-line flex items-center justify-center gap-2 rounded-xl border bg-white py-4 text-sm font-bold">
|
||||
<Phone className="text-accent h-4 w-4" aria-hidden="true" /> Call
|
||||
</a>
|
||||
)}
|
||||
{email && (
|
||||
<a href={`mailto:${email}`} className="border-line flex items-center justify-center gap-2 rounded-xl border bg-white py-4 text-sm font-bold">
|
||||
<Mail className="text-accent h-4 w-4" aria-hidden="true" /> Email
|
||||
</a>
|
||||
)}
|
||||
{website && (
|
||||
<a
|
||||
href={website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="border-line flex items-center justify-center gap-2 rounded-xl border bg-white py-4 text-sm font-bold"
|
||||
>
|
||||
<Globe className="text-accent h-4 w-4" aria-hidden="true" /> Website
|
||||
</a>
|
||||
)}
|
||||
{instagramUrl && (
|
||||
<a
|
||||
href={instagramUrl.startsWith("http") ? instagramUrl : `https://instagram.com/${instagramUrl.replace(/^@/, "")}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="border-line flex items-center justify-center gap-2 rounded-xl border bg-white py-4 text-sm font-bold"
|
||||
>
|
||||
<AtSign className="text-accent h-4 w-4" aria-hidden="true" /> Instagram
|
||||
</a>
|
||||
)}
|
||||
{facebookUrl && (
|
||||
<a
|
||||
href={facebookUrl.startsWith("http") ? facebookUrl : `https://${facebookUrl}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="border-line flex items-center justify-center gap-2 rounded-xl border bg-white py-4 text-sm font-bold"
|
||||
>
|
||||
<Share2 className="text-accent h-4 w-4" aria-hidden="true" /> Facebook
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 } });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 & 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'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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>>>;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(() => {});
|
||||
}
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user