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