Post Phase 8 - Fixes 1

Delivered:

Super Administrator — env-var provisioned, no StaffProfile at all (structural exclusion from Team/barber-picker/appointments/staff-totals, not a filter). hasPermission short-circuit + new getActingBusinessContext() helper retrofitted across 16 owner pages so it can actually manage the business.
Media Manager — one upload/library/external-URL modal + field picker, backing every image field app-wide. Local-disk storage + Docker volume, SVG rejected, size-capped.
Welcome backgrounds — desktop/mobile images with CSS breakpoint swap.
Business Branding settings — logo/cover/welcome/favicon/icon/colours all editable post-install; cover image finally rendered (was dead config).
Service descriptions — now surfaced on Home and in the booking flow.
Loyalty card — live preview, ColorPicker/MediaPicker/IconPicker, flip removed for a static premium-styled card, Loyalty added to bottom nav. Fixed a real pre-existing bug where the card's configured colour/image/icon were stored but never actually rendered.
Home Screen Redesign — hero gallery + sticky Services/Portfolio/Reviews/Details tabs, backed by a new unified gallery model/service (no hero/portfolio duplication). Details tab surfaces social links and a map that were never shown before.
Google Reviews — real sync pipeline (24h cron, CRON_SECRET-gated, mirrors the existing notification cron exactly), DB-only customer reads, owner hide/unhide + policy management page.
Architecture cleanup — SettingsPageShell extracted from 6 duplicated page wrappers; fixed a second pre-existing bug where branding image fields could never actually be cleared once set.
Verification: 418 tests passing (236 unit + 182 integration, all against a real MySQL database), full typecheck/lint clean across src, and a real Docker build+run cycle — which caught and fixed a genuine bug (scripts/ wasn't copied into the production image, so Super Admin sync would have crashed on every real deployment).
This commit is contained in:
Stefan Lazic committed 2026-08-04 17:10:30 +01:00
1 parent 77f25c0489
commit 9e39908ea7
79 files changed
+4121 -450

No files matched your search

@@ -1,11 +1,11 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { TopNav } from "@/components/navigation/top-nav";
import { SettingsPageShell } from "@/components/settings/settings-page-shell";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { getBookingRule } from "@/services/scheduling/booking-rule.service";
import { BookingRulesForm } from "@/features/owner/components/booking-rules-form";
@@ -19,33 +19,30 @@ export default async function OwnerBookingRulesPage() {
const canManage = await hasPermission(session.user.id, Permission.MANAGE_BOOKING_RULES);
if (!canManage) redirect("/staff");
const owner = await getActingStaff();
if (!owner) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const rule = await getBookingRule(prisma, owner.businessId);
const rule = await getBookingRule(prisma, context.businessId);
if (!rule) redirect("/owner");
return (
<div className="pb-12">
<TopNav title="Booking Rules" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl px-6 py-6 lg:max-w-4xl">
<BookingRulesForm
initial={{
minNoticeMinutes: rule.minNoticeMinutes,
maxAdvanceDays: rule.maxAdvanceDays,
cancellationWindowHours: rule.cancellationWindowHours,
rescheduleWindowHours: rule.rescheduleWindowHours,
slotIntervalMinutes: rule.slotIntervalMinutes,
bufferBeforeMinutes: rule.bufferBeforeMinutes,
bufferAfterMinutes: rule.bufferAfterMinutes,
maxAppointmentsPerDay: rule.maxAppointmentsPerDay,
maxAppointmentsPerStaffPerDay: rule.maxAppointmentsPerStaffPerDay,
customerCanChooseStaff: rule.customerCanChooseStaff,
allowGuestBooking: rule.allowGuestBooking,
requireApproval: rule.requireApproval,
}}
/>
</main>
</div>
<SettingsPageShell title="Booking Rules">
<BookingRulesForm
initial={{
minNoticeMinutes: rule.minNoticeMinutes,
maxAdvanceDays: rule.maxAdvanceDays,
cancellationWindowHours: rule.cancellationWindowHours,
rescheduleWindowHours: rule.rescheduleWindowHours,
slotIntervalMinutes: rule.slotIntervalMinutes,
bufferBeforeMinutes: rule.bufferBeforeMinutes,
bufferAfterMinutes: rule.bufferAfterMinutes,
maxAppointmentsPerDay: rule.maxAppointmentsPerDay,
maxAppointmentsPerStaffPerDay: rule.maxAppointmentsPerStaffPerDay,
customerCanChooseStaff: rule.customerCanChooseStaff,
allowGuestBooking: rule.allowGuestBooking,
requireApproval: rule.requireApproval,
}}
/>
</SettingsPageShell>
);
}
@@ -1,11 +1,11 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { TopNav } from "@/components/navigation/top-nav";
import { SettingsPageShell } from "@/components/settings/settings-page-shell";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { getBusinessProfile } from "@/services/business/business.service";
import { BusinessDetailsForm } from "@/features/owner/components/business-details-form";
@@ -19,23 +19,28 @@ export default async function OwnerBusinessDetailsPage() {
const canManage = await hasPermission(session.user.id, Permission.MANAGE_BUSINESS_PROFILE);
if (!canManage) redirect("/staff");
const staff = await getActingStaff();
if (!staff) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const business = await getBusinessProfile(prisma, staff.businessId);
const business = await getBusinessProfile(prisma, context.businessId);
if (!business) redirect("/owner");
const location = business.locations[0];
return (
<div className="pb-12">
<TopNav title="Business Details" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl px-6 py-6 lg:max-w-4xl">
<BusinessDetailsForm
<SettingsPageShell title="Business Details">
<BusinessDetailsForm
initial={{
name: business.name,
description: business.description ?? "",
logoUrl: business.logoUrl ?? "",
logoUrl: business.logoUrl,
coverImageUrl: business.coverImageUrl,
welcomeBackgroundDesktopUrl: business.welcomeBackgroundDesktopUrl,
welcomeBackgroundMobileUrl: business.welcomeBackgroundMobileUrl,
faviconUrl: business.faviconUrl,
appIconUrl: business.appIconUrl,
themeColor: business.themeColor,
backgroundColor: business.backgroundColor,
phone: business.phone ?? "",
email: business.email ?? "",
website: business.website ?? "",
@@ -54,9 +59,8 @@ export default async function OwnerBusinessDetailsPage() {
lateArrivalPolicy: business.settings?.lateArrivalPolicy ?? "",
bookingTerms: business.settings?.bookingTerms ?? "",
privacyPolicyUrl: business.settings?.privacyPolicyUrl ?? "",
}}
/>
</main>
</div>
}}
/>
</SettingsPageShell>
);
}
@@ -0,0 +1,49 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { SettingsPageShell } from "@/components/settings/settings-page-shell";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { GalleryPlacement } from "@/generated/prisma/enums";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { listGalleryImages } from "@/services/gallery/gallery.service";
import { GalleryManager } from "@/features/owner/components/gallery-manager";
export const metadata: Metadata = { title: "Gallery" };
export const dynamic = "force-dynamic";
export default async function OwnerGalleryPage() {
const session = await auth();
if (!session?.user) redirect("/sign-in");
const canManage = await hasPermission(session.user.id, Permission.MANAGE_BUSINESS_PROFILE);
if (!canManage) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const [heroImages, portfolioImages] = await Promise.all([
listGalleryImages(prisma, { businessId: context.businessId, placement: GalleryPlacement.HERO }),
listGalleryImages(prisma, { businessId: context.businessId, placement: GalleryPlacement.PORTFOLIO }),
]);
return (
<SettingsPageShell title="Gallery" mainClassName="space-y-8">
<GalleryManager
placement={GalleryPlacement.HERO}
title="Home Hero Gallery"
description="Shown at the top of the customer Home page. Falls back to your cover image, then your logo, when empty."
showCategory={false}
initialImages={heroImages}
/>
<GalleryManager
placement={GalleryPlacement.PORTFOLIO}
title="Portfolio"
description="Shown in the Portfolio tab on Home. Add a category to each image to power the filter pills there."
showCategory
initialImages={portfolioImages}
/>
</SettingsPageShell>
);
}
+8 -11
View File
@@ -1,11 +1,11 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { TopNav } from "@/components/navigation/top-nav";
import { SettingsPageShell } from "@/components/settings/settings-page-shell";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { getOpeningHours } from "@/services/business/business.service";
import { OpeningHoursForm } from "@/features/owner/components/opening-hours-form";
@@ -19,17 +19,14 @@ export default async function OwnerOpeningHoursPage() {
const canManage = await hasPermission(session.user.id, Permission.MANAGE_BUSINESS_PROFILE);
if (!canManage) redirect("/staff");
const staff = await getActingStaff();
if (!staff) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const hours = await getOpeningHours(prisma, staff.businessId);
const hours = await getOpeningHours(prisma, context.businessId);
return (
<div className="pb-12">
<TopNav title="Opening Hours" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl px-6 py-6">
<OpeningHoursForm initialRows={hours} />
</main>
</div>
<SettingsPageShell title="Opening Hours" mainClassName="lg:max-w-2xl">
<OpeningHoursForm initialRows={hours} />
</SettingsPageShell>
);
}
+17 -19
View File
@@ -1,11 +1,11 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { TopNav } from "@/components/navigation/top-nav";
import { SettingsPageShell } from "@/components/settings/settings-page-shell";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { getProgrammeConfig } from "@/services/loyalty/loyalty.service";
import { LoyaltyConfigForm } from "@/features/owner/components/loyalty-config-form";
@@ -19,14 +19,14 @@ export default async function OwnerLoyaltyConfigPage() {
const canManage = await hasPermission(session.user.id, Permission.MANAGE_LOYALTY_PROGRAMME);
if (!canManage) redirect("/staff");
const owner = await getActingStaff();
if (!owner) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const [programme, services, staff] = await Promise.all([
getProgrammeConfig(prisma, owner.businessId),
prisma.service.findMany({ where: { businessId: owner.businessId, isActive: true }, orderBy: { displayOrder: "asc" } }),
getProgrammeConfig(prisma, context.businessId),
prisma.service.findMany({ where: { businessId: context.businessId, isActive: true }, orderBy: { displayOrder: "asc" } }),
prisma.staffProfile.findMany({
where: { businessId: owner.businessId, status: "ACTIVE" },
where: { businessId: context.businessId, status: "ACTIVE" },
include: { user: { select: { name: true } } },
}),
]);
@@ -62,17 +62,15 @@ export default async function OwnerLoyaltyConfigPage() {
};
return (
<div className="pb-12">
<TopNav title="Loyalty Programme" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl px-6 py-6 lg:max-w-4xl">
<LoyaltyConfigForm
initialConfig={initialConfig}
initialEligibleServiceIds={programme?.eligibleServices.map((s) => s.serviceId) ?? []}
initialEligibleStaffIds={programme?.eligibleStaff.map((s) => s.staffId) ?? []}
services={services.map((s) => ({ id: s.id, name: s.name }))}
staff={staff.map((s) => ({ id: s.id, name: s.user.name ?? "Staff" }))}
/>
</main>
</div>
<SettingsPageShell title="Loyalty Programme">
<LoyaltyConfigForm
businessName={context.businessName}
initialConfig={initialConfig}
initialEligibleServiceIds={programme?.eligibleServices.map((s) => s.serviceId) ?? []}
initialEligibleStaffIds={programme?.eligibleStaff.map((s) => s.staffId) ?? []}
services={services.map((s) => ({ id: s.id, name: s.name }))}
staff={staff.map((s) => ({ id: s.id, name: s.user.name ?? "Staff" }))}
/>
</SettingsPageShell>
);
}
+3 -1
View File
@@ -1,6 +1,6 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Bell, Building2, CalendarClock, ClipboardList, Crown, ScissorsSquare } from "lucide-react";
import { Bell, Building2, CalendarClock, ClipboardList, Crown, Images, ScissorsSquare, Star } from "lucide-react";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { ProfileCard } from "@/components/ui/profile-card";
@@ -15,9 +15,11 @@ export const dynamic = "force-dynamic";
const SETTINGS_LINKS = [
{ href: "/owner/settings/business", label: "Business Details", icon: Building2 },
{ href: "/owner/settings/gallery", label: "Gallery", icon: Images },
{ href: "/owner/settings/hours", label: "Opening Hours", icon: CalendarClock },
{ href: "/owner/settings/booking-rules", label: "Booking Rules", icon: ClipboardList },
{ href: "/owner/settings/loyalty", label: "Loyalty Programme", icon: Crown },
{ href: "/owner/settings/reviews", label: "Reviews", icon: Star },
{ href: "/staff/settings/notifications", label: "Notifications", icon: Bell },
] as const;
@@ -0,0 +1,51 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { SettingsPageShell } from "@/components/settings/settings-page-shell";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { getBusinessProfile } from "@/services/business/business.service";
import { listReviewsForOwner } from "@/services/reviews/review-management.service";
import { ReviewsManager } from "@/features/owner/components/reviews-manager";
export const metadata: Metadata = { title: "Reviews" };
export const dynamic = "force-dynamic";
export default async function OwnerReviewsPage() {
const session = await auth();
if (!session?.user) redirect("/sign-in");
const canManage = await hasPermission(session.user.id, Permission.MANAGE_REVIEWS);
if (!canManage) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const [business, reviews] = await Promise.all([
getBusinessProfile(prisma, context.businessId),
listReviewsForOwner(prisma, context.businessId),
]);
if (!business) redirect("/owner");
return (
<SettingsPageShell title="Reviews">
<ReviewsManager
initialGooglePlaceId={business.googlePlaceId ?? ""}
initialReviewsMinRating={business.settings?.reviewsMinRating ?? null}
initialReviewsExcludeRatingOnly={business.settings?.reviewsExcludeRatingOnly ?? false}
googleRating={business.googleRating ? Number(business.googleRating) : null}
googleRatingCount={business.googleRatingCount}
reviews={reviews.map((r) => ({
id: r.id,
authorName: r.authorName,
rating: r.rating,
text: r.text,
relativeTimeDescription: r.relativeTimeDescription,
isHidden: r.isHidden,
}))}
/>
</SettingsPageShell>
);
}