Fixed an issue where media manager only shows files uploaded in specific sections.

Fixed an issue with icon picker names.

Removed jumbo rounding and a few other style tweaks.
This commit is contained in:
Stefan Lazic committed 2026-08-05 09:25:59 +01:00
1 parent 9e39908ea7
commit d3bbad09c6
74 files changed
+1622 -195

No files matched your search

@@ -157,14 +157,14 @@ export default async function AppointmentDetailsPage({
<div className="flex flex-wrap gap-3">
<a
href={`/api/appointments/${appointment.id}/calendar.ics${token ? `?token=${token}` : ""}`}
className="rounded-jumbo border-line flex items-center gap-2 border bg-white px-5 py-3 text-sm font-bold"
className="rounded-md border-line flex items-center gap-2 border bg-white px-5 py-3 text-sm font-bold"
>
<CalendarPlus className="text-accent h-4 w-4" aria-hidden="true" /> Add to Calendar
</a>
{business.phone && (
<a
href={`tel:${business.phone}`}
className="rounded-jumbo border-line flex items-center gap-2 border bg-white px-5 py-3 text-sm font-bold"
className="rounded-md border-line flex items-center gap-2 border bg-white px-5 py-3 text-sm font-bold"
>
<Phone className="text-accent h-4 w-4" aria-hidden="true" /> Contact Business
</a>
@@ -175,7 +175,7 @@ export default async function AppointmentDetailsPage({
{RESCHEDULABLE_STATUSES.has(appointment.status) && (
<Link
href={`/appointments/${appointment.id}/reschedule${token ? `?token=${token}` : ""}`}
className="rounded-jumbo border-line border bg-white px-5 py-3 text-sm font-bold"
className="rounded-md border-line border bg-white px-5 py-3 text-sm font-bold"
>
Reschedule
</Link>
+6 -5
View File
@@ -14,6 +14,7 @@ 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 { resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
import { getBusinessReviews } from "@/services/reviews/reviews.service";
import { HomeTabs } from "@/features/home/components/home-tabs";
import type { AppointmentStatus } from "@/types/domain";
@@ -142,7 +143,7 @@ export default async function CustomerHomePage() {
<div>
{/* 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">
<div className="mx-auto max-w-2xl px-6 pt-4 bg-white">
<ImageGallery images={heroGalleryImages} />
</div>
)}
@@ -214,8 +215,8 @@ export default async function CustomerHomePage() {
href="/loyalty"
className={
loyalty.availableReward
? "rounded-jumbo border-accent-loyalty bg-accent-loyalty/10 mb-6 block border p-5 shadow-sm"
: "rounded-jumbo border-line mb-6 block border bg-white p-5 shadow-sm"
? "rounded-md border-accent-loyalty bg-accent-loyalty/10 mb-6 block border p-5 shadow-sm"
: "rounded-md border-line mb-6 block border bg-white p-5 shadow-sm"
}
>
{loyalty.availableReward ? (
@@ -228,14 +229,14 @@ export default async function CustomerHomePage() {
<div className="mb-3 flex items-center justify-between">
<p className="text-sm font-bold">Loyalty Progress</p>
<span className="text-muted text-xs">
{loyalty.currentStamps}/{loyalty.stampsRequired} stamps
{loyalty.currentStamps}/{resolveRewardThreshold(loyalty)} stamps
</span>
</div>
<div className="bg-stone h-2 w-full overflow-hidden rounded-full">
<div
className="bg-accent-loyalty h-full rounded-full"
style={{
width: `${Math.min(100, (loyalty.currentStamps / loyalty.stampsRequired) * 100)}%`,
width: `${Math.min(100, (loyalty.currentStamps / resolveRewardThreshold(loyalty)) * 100)}%`,
}}
/>
</div>
+14 -3
View File
@@ -10,6 +10,7 @@ import { TopNav } from "@/components/navigation/top-nav";
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
import { prisma } from "@/lib/db/prisma";
import { getLoyaltyHistory, getLoyaltySummary } from "@/services/loyalty/loyalty.service";
import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
export const metadata: Metadata = { title: "Loyalty" };
export const dynamic = "force-dynamic";
@@ -65,6 +66,9 @@ export default async function LoyaltyPage() {
const expiresLabel = loyalty.availableReward?.expiresAt
? loyalty.availableReward.expiresAt.toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" })
: null;
// The number of stamps that actually earns the reward — not always the
// same as the card's stampsRequired slot count, see resolveRewardThreshold.
const rewardThreshold = resolveRewardThreshold(loyalty);
return (
<div className="pb-12">
@@ -77,10 +81,17 @@ export default async function LoyaltyPage() {
stampsRequired={loyalty.stampsRequired}
stampsEarned={loyalty.currentStamps}
rewardLabel={loyalty.rewardLabel ?? "Reward"}
termsLine={loyalty.availableReward ? undefined : `${loyalty.stampsRequired - loyalty.currentStamps} more to your reward`}
termsLine={loyalty.termsLine ?? undefined}
cardBackgroundColor={loyalty.cardBackgroundColor}
cardBackgroundImageUrl={loyalty.cardBackgroundImageUrl}
stampIcon={loyalty.stampIcon}
rewardPosition={loyalty.rewardPosition}
cardTopRightText={loyalty.cardTopRightText}
cardFont={loyalty.cardFont}
cardTopRightTextSize={loyalty.cardTopRightTextSize}
cardTopRightTextColor={loyalty.cardTopRightTextColor}
cardBorderColor={loyalty.cardBorderColor}
cardMemberNumberColor={loyalty.cardMemberNumberColor}
/>
{loyalty.availableReward ? (
@@ -96,13 +107,13 @@ export default async function LoyaltyPage() {
<div className="flex items-center justify-between">
<p className="text-sm font-bold">Progress</p>
<span className="text-muted text-xs">
{loyalty.currentStamps}/{loyalty.stampsRequired} stamps
{loyalty.currentStamps}/{rewardThreshold} stamps
</span>
</div>
<div className="bg-stone h-2 w-full overflow-hidden rounded-full">
<div
className="bg-accent-loyalty h-full rounded-full"
style={{ width: `${Math.min(100, (loyalty.currentStamps / loyalty.stampsRequired) * 100)}%` }}
style={{ width: `${Math.min(100, (loyalty.currentStamps / rewardThreshold) * 100)}%` }}
/>
</div>
</Card>
@@ -31,7 +31,7 @@ export default async function CustomerNotificationSettingsPage() {
<TopNav title="Notifications" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl space-y-6 px-6 py-6">
<NotificationPreferencesForm initial={cells} action={saveCustomerNotificationPreferencesAction} />
<div className="rounded-jumbo border-line border bg-white p-5">
<div className="rounded-md border-line border bg-white p-5">
<h2 className="mb-3 text-sm font-bold">Push Notifications</h2>
<PushSubscribeToggle />
</div>
+1 -1
View File
@@ -65,7 +65,7 @@ export default async function CustomerSettingsPage() {
<Bell className="h-4 w-4" aria-hidden="true" /> Notifications
</Link>
</div>
<div className="border-line rounded-jumbo border bg-white p-5">
<div className="border-line rounded-md border bg-white p-5">
<p className="mb-3 text-sm font-bold">Get the app</p>
<InstallAppButton businessName={branding.name} />
</div>
@@ -11,6 +11,7 @@ import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { getLoyaltySummary } from "@/services/loyalty/loyalty.service";
import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
import { getAppointmentById } from "@/services/scheduling/appointment.service";
import { assertAppointmentInBusiness, StaffPermissionDeniedError } from "@/features/staff/authorization";
import { AppointmentStatusActions } from "@/features/staff/components/appointment-status-actions";
@@ -197,7 +198,7 @@ export default async function OwnerAppointmentDetailsPage({
<div className="border-line border-t pt-4">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">Loyalty</span>
<p className="text-sm font-bold">
{loyalty.currentStamps}/{loyalty.stampsRequired} stamps
{loyalty.currentStamps}/{resolveRewardThreshold(loyalty)} stamps
{loyalty.availableReward && <span className="text-accent-loyalty"> · Reward available</span>}
</p>
{loyaltyTransactions.length > 0 && (
@@ -231,14 +232,14 @@ export default async function OwnerAppointmentDetailsPage({
<div className="flex flex-wrap gap-3">
<a
href={`/api/appointments/${appointment.id}/calendar.ics`}
className="rounded-jumbo border-line flex items-center gap-2 border bg-white px-5 py-3 text-sm font-bold"
className="rounded-md border-line flex items-center gap-2 border bg-white px-5 py-3 text-sm font-bold"
>
<CalendarPlus className="text-accent h-4 w-4" aria-hidden="true" /> Add to Calendar
</a>
{RESCHEDULABLE.has(appointment.status) && (
<Link
href={`/owner/appointments/${appointment.id}/reschedule`}
className="rounded-jumbo border-line border bg-white px-5 py-3 text-sm font-bold"
className="rounded-md border-line border bg-white px-5 py-3 text-sm font-bold"
>
Reschedule / Reassign
</Link>
+4 -4
View File
@@ -67,7 +67,7 @@ export default async function OwnerDashboardPage() {
<div
key={`${w.code}-${i}`}
role="alert"
className="bg-warning/10 text-warning-foreground border-warning/30 flex items-start gap-3 rounded-jumbo border p-4 text-sm"
className="bg-warning/10 text-warning-foreground border-warning/30 flex items-start gap-3 rounded-md border p-4 text-sm"
>
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
<p>{w.message}</p>
@@ -171,19 +171,19 @@ export default async function OwnerDashboardPage() {
<div className="flex flex-wrap gap-3">
<Link
href="/owner/reports"
className="rounded-jumbo border-line flex-1 border bg-white px-5 py-4 text-center text-sm font-bold"
className="rounded-md border-line flex-1 border bg-white px-5 py-4 text-center text-sm font-bold"
>
View Reports
</Link>
<Link
href="/owner/team"
className="rounded-jumbo border-line flex-1 border bg-white px-5 py-4 text-center text-sm font-bold"
className="rounded-md border-line flex-1 border bg-white px-5 py-4 text-center text-sm font-bold"
>
Manage Team
</Link>
<Link
href="/owner/settings/booking-rules"
className="rounded-jumbo bg-ink flex-1 px-5 py-4 text-center text-sm font-bold text-white"
className="rounded-md bg-ink flex-1 px-5 py-4 text-center text-sm font-bold text-white"
>
Update Booking Rules
</Link>
@@ -4,7 +4,7 @@ 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 { LoyaltyCardFont, LoyaltyCardTextSize, Permission, RewardPositionMode } from "@/generated/prisma/enums";
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";
@@ -35,6 +35,7 @@ export default async function OwnerLoyaltyConfigPage() {
? {
isEnabled: programme.isEnabled,
stampsRequired: programme.stampsRequired,
rewardPosition: programme.rewardPosition,
rewardType: programme.rewardType,
rewardValue: programme.rewardValue?.toString(),
rewardLabel: programme.rewardLabel ?? undefined,
@@ -50,15 +51,24 @@ export default async function OwnerLoyaltyConfigPage() {
cardBackgroundImageUrl: programme.cardBackgroundImageUrl ?? undefined,
stampIcon: programme.stampIcon ?? undefined,
termsLine: programme.termsLine ?? undefined,
cardTopRightText: programme.cardTopRightText ?? undefined,
cardFont: programme.cardFont,
cardTopRightTextSize: programme.cardTopRightTextSize,
cardTopRightTextColor: programme.cardTopRightTextColor ?? undefined,
cardBorderColor: programme.cardBorderColor ?? undefined,
cardMemberNumberColor: programme.cardMemberNumberColor ?? undefined,
}
: {
isEnabled: false,
stampsRequired: 10,
rewardPosition: RewardPositionMode.AFTER_ALL_STAMPS,
rewardType: "PERCENTAGE" as const,
autoApplyReward: true,
requiresStaffApproval: false,
earnOnCancelled: false,
earnOnNoShow: false,
cardFont: LoyaltyCardFont.SANS,
cardTopRightTextSize: LoyaltyCardTextSize.MD,
};
return (
+3 -3
View File
@@ -38,13 +38,13 @@ export default async function OwnerSettingsPage() {
<Link
href="/staff/settings"
className="rounded-jumbo border-line flex items-center gap-3 border bg-white p-5 text-sm font-bold hover:bg-stone"
className="rounded-md border-line flex items-center gap-3 border bg-white p-5 text-sm font-bold hover:bg-stone"
>
<ScissorsSquare className="text-accent h-5 w-5" aria-hidden="true" />
Switch to Barber Tools
</Link>
<ul className="divide-line rounded-jumbo border-line divide-y border bg-white">
<ul className="divide-line rounded-md border-line divide-y border bg-white">
{SETTINGS_LINKS.map(({ href, label, icon: Icon }) => (
<li key={href}>
<Link href={href} className="hover:bg-stone flex items-center gap-3 px-5 py-4 text-sm font-bold">
@@ -55,7 +55,7 @@ export default async function OwnerSettingsPage() {
))}
</ul>
<div className="rounded-jumbo border-line border bg-white p-5">
<div className="rounded-md border-line border bg-white p-5">
<p className="mb-3 text-sm font-bold">Get the app</p>
<InstallAppButton businessName={branding.name} />
</div>
+2 -2
View File
@@ -60,7 +60,7 @@ export default async function OwnerTeamPage({
<div className="grid gap-3 lg:grid-cols-2">
{staff.length === 0 ? (
<p className="text-muted col-span-full rounded-jumbo border border-line bg-white p-5 text-center text-sm">
<p className="text-muted col-span-full rounded-md border border-line bg-white p-5 text-center text-sm">
No staff match &ldquo;{q}&rdquo;.
</p>
) : (
@@ -69,7 +69,7 @@ export default async function OwnerTeamPage({
key={s.id}
href={`/owner/team/${s.id}`}
className={cn(
"rounded-jumbo border-line flex items-center gap-4 border bg-white p-5",
"rounded-md border-line flex items-center gap-4 border bg-white p-5",
s.status === "INACTIVE" && "opacity-60",
)}
>
@@ -8,6 +8,7 @@ import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { getLoyaltySummary } from "@/services/loyalty/loyalty.service";
import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
import { getAppointmentById } from "@/services/scheduling/appointment.service";
import { assertOwnAppointment, StaffPermissionDeniedError } from "@/features/staff/authorization";
import { AppointmentStatusActions } from "@/features/staff/components/appointment-status-actions";
@@ -216,7 +217,7 @@ export default async function StaffAppointmentDetailsPage({
Loyalty
</span>
<p className="text-sm font-bold">
{loyalty.currentStamps}/{loyalty.stampsRequired} stamps
{loyalty.currentStamps}/{resolveRewardThreshold(loyalty)} stamps
{loyalty.availableReward && (
<span className="text-accent-loyalty"> · Reward available</span>
)}
@@ -257,14 +258,14 @@ export default async function StaffAppointmentDetailsPage({
<div className="flex flex-wrap gap-3">
<a
href={`/api/appointments/${appointment.id}/calendar.ics`}
className="rounded-jumbo border-line flex items-center gap-2 border bg-white px-5 py-3 text-sm font-bold"
className="rounded-md border-line flex items-center gap-2 border bg-white px-5 py-3 text-sm font-bold"
>
<CalendarPlus className="text-accent h-4 w-4" aria-hidden="true" /> Add to Calendar
</a>
{RESCHEDULABLE.has(appointment.status) && (
<Link
href={`/staff/appointments/${appointment.id}/reschedule`}
className="rounded-jumbo border-line border bg-white px-5 py-3 text-sm font-bold"
className="rounded-md border-line border bg-white px-5 py-3 text-sm font-bold"
>
Reschedule
</Link>
+2 -1
View File
@@ -12,6 +12,7 @@ import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { getLoyaltyHistory, getLoyaltySummary } from "@/services/loyalty/loyalty.service";
import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
import { listAppointments } from "@/services/scheduling/appointment.service";
import { AddStampButton } from "@/features/staff/components/add-stamp-button";
import { CorrectStampButton } from "@/features/staff/components/correct-stamp-button";
@@ -201,7 +202,7 @@ export default async function StaffCustomerDetailsPage({
Loyalty
</span>
<p className="mb-3 text-sm font-bold">
{loyalty.currentStamps}/{loyalty.stampsRequired} stamps
{loyalty.currentStamps}/{resolveRewardThreshold(loyalty)} stamps
{loyalty.availableReward && (
<span className="text-accent-loyalty"> · Reward available</span>
)}
+2 -2
View File
@@ -136,7 +136,7 @@ export default async function StaffClientsPage({
<Link
key={customer.id}
href={`/staff/clients/${customer.id}`}
className="rounded-jumbo border-line hover:bg-stone flex items-center gap-3 border bg-white px-5 py-4"
className="rounded-md border-line hover:bg-stone flex items-center gap-3 border bg-white px-5 py-4"
>
<div className="bg-stone text-muted flex h-11 w-11 shrink-0 items-center justify-center rounded-full text-sm font-bold">
{customer.firstName.charAt(0)}
@@ -157,7 +157,7 @@ export default async function StaffClientsPage({
{loyalty && (
<span className={loyalty.hasAvailableReward ? "text-accent-loyalty" : ""}>
{" "}
· {loyalty.currentStamps}/{loyalty.stampsRequired} stamps
· {loyalty.currentStamps}/{loyalty.rewardThreshold} stamps
</span>
)}
</p>
+2 -1
View File
@@ -9,6 +9,7 @@ import { prisma } from "@/lib/db/prisma";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { todayIn } from "@/lib/time/business-time";
import { getLoyaltySummary } from "@/services/loyalty/loyalty.service";
import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
import {
listAppointments,
selectCurrentAppointment,
@@ -187,7 +188,7 @@ export default async function StaffCurrentPage() {
Loyalty
</span>
<p className="text-sm font-bold">
{loyalty.currentStamps}/{loyalty.stampsRequired} stamps
{loyalty.currentStamps}/{resolveRewardThreshold(loyalty)} stamps
{loyalty.availableReward && (
<span className="text-accent-loyalty"> · Reward available</span>
)}
+1 -1
View File
@@ -225,7 +225,7 @@ export default async function StaffCalendarPage({
appointment alongside the calendar instead of requiring a
separate tab, since there's room for both at once. */}
<aside className="hidden shrink-0 lg:block lg:w-80">
<div className="rounded-jumbo border-line sticky top-6 border bg-white p-5">
<div className="rounded-md border-line sticky top-6 border bg-white p-5">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Right Now
</span>
@@ -32,7 +32,7 @@ export default async function StaffNotificationsPage() {
<TopNav title="Notifications" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl space-y-6 px-6 py-6">
<NotificationPreferencesForm initial={cells} action={saveNotificationPreferencesAction} />
<div className="rounded-jumbo border-line border bg-white p-5">
<div className="rounded-md border-line border bg-white p-5">
<h2 className="mb-3 text-sm font-bold">Push Notifications</h2>
<PushSubscribeToggle />
</div>
+3 -3
View File
@@ -40,14 +40,14 @@ export default async function StaffSettingsPage() {
{canManageStaff && (
<Link
href="/owner"
className="flex items-center gap-3 rounded-jumbo border border-line bg-white p-5 text-sm font-bold hover:bg-stone"
className="flex items-center gap-3 rounded-md border border-line bg-white p-5 text-sm font-bold hover:bg-stone"
>
<ShieldCheck className="h-5 w-5 text-accent" aria-hidden="true" />
Switch to Owner Admin
</Link>
)}
<ul className="divide-y divide-line rounded-jumbo border border-line bg-white">
<ul className="divide-y divide-line rounded-md border border-line bg-white">
{SETTINGS_LINKS.map(({ href, label, icon: Icon }) => (
<li key={href}>
<Link href={href} className="flex items-center gap-3 px-5 py-4 text-sm font-bold hover:bg-stone">
@@ -58,7 +58,7 @@ export default async function StaffSettingsPage() {
))}
</ul>
<div className="rounded-jumbo border border-line bg-white p-5">
<div className="rounded-md border border-line bg-white p-5">
<p className="mb-3 text-sm font-bold">Get the app</p>
<InstallAppButton businessName={branding.name} />
</div>
@@ -35,11 +35,11 @@ export default async function StaffProfilePage() {
Services You Offer
</p>
{profile.services.length === 0 ? (
<p className="text-muted rounded-jumbo border border-line bg-white p-5 text-center text-sm">
<p className="text-muted rounded-md border border-line bg-white p-5 text-center text-sm">
No services assigned yet. Ask the owner to add you to a service.
</p>
) : (
<ul className="divide-y divide-line rounded-jumbo border border-line bg-white">
<ul className="divide-y divide-line rounded-md border border-line bg-white">
{profile.services.map((s) => (
<li key={s.service.id} className="px-5 py-3 text-sm font-bold">
{s.service.name}
@@ -0,0 +1,65 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
/** Exercises the actual Route Handler function, not just the storage helper it wraps — real temp-dir I/O, same MEDIA_STORAGE_ROOT override as media-storage.test.ts. */
describe("GET /api/media/[...path]", () => {
let tempRoot: string;
let storage: typeof import("@/lib/media/media-storage");
let route: typeof import("../route");
beforeAll(async () => {
tempRoot = await mkdtemp(path.join(tmpdir(), "nextchair-media-route-test-"));
process.env.MEDIA_STORAGE_ROOT = tempRoot;
storage = await import("@/lib/media/media-storage");
route = await import("../route");
});
afterAll(async () => {
delete process.env.MEDIA_STORAGE_ROOT;
await rm(tempRoot, { recursive: true, force: true });
});
const PNG_BYTES = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"base64",
);
it("returns 200 with the correct content type and bytes for a stored file", async () => {
const stored = await storage.storeMediaFile({ buffer: PNG_BYTES, mimeType: "image/png", category: "branding" });
const segments = stored.url.replace(storage.MEDIA_URL_PREFIX, "").split("/");
const response = await route.GET(new Request(`http://localhost${stored.url}`), { params: Promise.resolve({ path: segments }) });
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe("image/png");
expect(response.headers.get("cache-control")).toContain("immutable");
const body = Buffer.from(await response.arrayBuffer());
expect(body.equals(PNG_BYTES)).toBe(true);
});
it("returns 404 for a file that doesn't exist", async () => {
const response = await route.GET(new Request("http://localhost/api/media/branding/missing.png"), {
params: Promise.resolve({ path: ["branding", "missing.png"] }),
});
expect(response.status).toBe(404);
});
it("returns 404 (not the file) for a path-traversal attempt", async () => {
const response = await route.GET(new Request("http://localhost/api/media/..%2F..%2Fpackage.json"), {
params: Promise.resolve({ path: ["..", "..", "package.json"] }),
});
expect(response.status).toBe(404);
});
it("returns 404 for a deleted file — it no longer resolves", async () => {
const stored = await storage.storeMediaFile({ buffer: PNG_BYTES, mimeType: "image/png", category: "gallery" });
const segments = stored.url.replace(storage.MEDIA_URL_PREFIX, "").split("/");
await storage.deleteMediaFile(stored.url);
const response = await route.GET(new Request(`http://localhost${stored.url}`), { params: Promise.resolve({ path: segments }) });
expect(response.status).toBe(404);
});
});
+41
View File
@@ -0,0 +1,41 @@
import { NextResponse } from "next/server";
import { readStoredMediaFile } from "@/lib/media/media-storage";
/**
* Serves uploaded media files directly — see media-storage.ts's header
* for why this route exists at all (Next's own `public/` static handling
* does not serve files added after the build, verified against a real
* production container). This is the only public entry point for
* uploaded media; nothing else touches the filesystem to serve one.
*
* Deliberately unauthenticated — every image this ever serves (business
* logo, gallery/portfolio, staff/service photos, loyalty card
* background) is already customer-facing public data, same as the
* `public/` assets it replaces. Path safety (not access control) is the
* only thing this route needs to get right — see
* resolveMediaFilePath/readStoredMediaFile for the traversal guard.
*
* `/api/*` is already excluded from src/proxy.ts's matcher, so this
* route is reachable before installation completes and without a
* session — necessary, since branding previews and the installer itself
* may reference these URLs.
*/
export async function GET(_request: Request, context: { params: Promise<{ path: string[] }> }) {
const { path: segments } = await context.params;
const file = await readStoredMediaFile(segments);
if (!file) {
return NextResponse.json({ error: "Not found." }, { status: 404 });
}
return new NextResponse(new Uint8Array(file.buffer), {
status: 200,
headers: {
"Content-Type": file.mimeType,
// Safe to cache indefinitely — each URL's filename is a random
// id assigned once at upload time; a given URL's content never
// changes in place (delete + re-upload always gets a new URL).
"Cache-Control": "public, max-age=31536000, immutable",
"Content-Length": String(file.buffer.byteLength),
},
});
}
+1 -1
View File
@@ -82,7 +82,7 @@ export default async function BookConfirmationPage() {
<a
href={`/api/appointments/${appointment.id}/calendar.ics${reference.guestAccessToken ? `?token=${reference.guestAccessToken}` : ""}`}
className="flex w-full items-center justify-center gap-2 rounded-jumbo border border-line bg-white py-4 text-sm font-bold"
className="flex w-full items-center justify-center gap-2 rounded-md border border-line bg-white py-4 text-sm font-bold"
>
<CalendarPlus className="h-4 w-4 text-accent" aria-hidden="true" /> Add to Calendar
</a>
+1 -1
View File
@@ -83,7 +83,7 @@ export default async function StaffBookCustomerPage({
</form>
{q && (
<div className="divide-line rounded-jumbo border-line divide-y border bg-white">
<div className="divide-line rounded-md border-line divide-y border bg-white">
{results.length === 0 ? (
<p className="text-muted px-5 py-4 text-sm">
No customers match &ldquo;{q}&rdquo;.
+1 -1
View File
@@ -7,7 +7,7 @@ export function Skeleton({ className }: { className?: string }) {
export function CardSkeleton() {
return (
<div className="rounded-jumbo border border-line bg-white p-5">
<div className="rounded-md border border-line bg-white p-5">
<div className="mb-4 flex items-center gap-3">
<Skeleton className="h-10 w-10 rounded-full" />
<div className="flex-1 space-y-2">
+1 -1
View File
@@ -9,7 +9,7 @@ import { cn } from "@/lib/utils/cn";
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("rounded-jumbo border border-line bg-white shadow-sm", className)}
className={cn("rounded-md border border-line bg-white shadow-sm", className)}
{...props}
/>
);
+3 -3
View File
@@ -27,7 +27,7 @@ export function ImageGallery({ images, className }: ImageGalleryProps) {
return (
<div className={cn("w-full", className)}>
<div className="bg-stone relative aspect-[16/9] w-full overflow-hidden rounded-jumbo">
<div className="bg-stone relative aspect-[16/9] w-full overflow-hidden rounded-md">
{/* 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 && (
@@ -36,7 +36,7 @@ export function ImageGallery({ images, className }: ImageGalleryProps) {
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"
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-md text-white"
>
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
</button>
@@ -44,7 +44,7 @@ export function ImageGallery({ images, className }: ImageGalleryProps) {
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"
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-md text-white"
>
<ChevronRight className="h-4 w-4" aria-hidden="true" />
</button>
+74 -14
View File
@@ -1,8 +1,23 @@
"use client";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Gift } from "lucide-react";
import { LoyaltyCardFont, LoyaltyCardTextSize, RewardPositionMode } from "@/generated/prisma/enums";
import { findCatalogIcon } from "@/lib/icons/icon-catalog";
import { cn } from "@/lib/utils/cn";
import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
const FONT_CLASSES: Record<LoyaltyCardFont, string> = {
[LoyaltyCardFont.SANS]: "font-sans",
[LoyaltyCardFont.SERIF]: "font-serif",
[LoyaltyCardFont.MONO]: "font-mono",
};
const TOP_RIGHT_TEXT_SIZE_CLASSES: Record<LoyaltyCardTextSize, string> = {
[LoyaltyCardTextSize.SM]: "text-[8px]",
[LoyaltyCardTextSize.MD]: "text-[10px]",
[LoyaltyCardTextSize.LG]: "text-[13px]",
};
export interface LoyaltyCardProps {
businessName: string;
@@ -15,6 +30,14 @@ export interface LoyaltyCardProps {
cardBackgroundColor?: string | null;
cardBackgroundImageUrl?: string | null;
stampIcon?: string | null;
/** See resolveRewardThreshold's doc comment — determines whether the final stamp position renders as a normal stamp or as the reward slot itself. */
rewardPosition?: RewardPositionMode;
cardTopRightText?: string | null;
cardFont?: LoyaltyCardFont;
cardTopRightTextSize?: LoyaltyCardTextSize;
cardTopRightTextColor?: string | null;
cardBorderColor?: string | null;
cardMemberNumberColor?: string | null;
}
/**
@@ -39,13 +62,34 @@ export function LoyaltyCard({
cardBackgroundColor,
cardBackgroundImageUrl,
stampIcon,
rewardPosition = RewardPositionMode.AFTER_ALL_STAMPS,
cardTopRightText,
cardFont = LoyaltyCardFont.SANS,
cardTopRightTextSize = LoyaltyCardTextSize.MD,
cardTopRightTextColor,
cardBorderColor,
cardMemberNumberColor,
}: LoyaltyCardProps) {
const icon = findCatalogIcon(stampIcon);
const threshold = resolveRewardThreshold({ stampsRequired, rewardPosition });
// Only FINAL_SLOT_IS_REWARD sets aside a dedicated slot — under
// AFTER_ALL_STAMPS every position is a plain stamp and the reward is
// shown separately (see the "Reward available" banner on the loyalty
// page), not by any one slot.
const rewardSlotIndex = rewardPosition === RewardPositionMode.FINAL_SLOT_IS_REWARD ? stampsRequired - 1 : null;
const borderColor = cardBorderColor || "var(--color-accent-loyalty)";
return (
<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" }}
className={cn(
"relative w-full overflow-hidden rounded-2xl",
FONT_CLASSES[cardFont],
)}
style={{
aspectRatio: "1.65 / 1",
backgroundColor: cardBackgroundColor || "#141414",
boxShadow: `0 30px 60px rgba(0,0,0,0.45), 0 0 0 3px ${borderColor}`,
}}
role="img"
aria-label={`${businessName} loyalty card, ${stampsEarned} of ${stampsRequired} stamps`}
>
@@ -66,33 +110,49 @@ export function LoyaltyCard({
/>
<div className="relative flex h-full flex-col justify-between p-4">
<div className="flex items-center justify-between">
<div className="flex items-start justify-between gap-2">
<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 className="flex flex-col items-end gap-1">
{cardTopRightText && (
<span
className={cn(TOP_RIGHT_TEXT_SIZE_CLASSES[cardTopRightTextSize], "text-right font-bold text-white")}
style={{ color: cardTopRightTextColor || undefined }}
>
{cardTopRightText}
</span>
)}
<span
className="border-accent-loyalty/30 text-accent-loyalty rounded border px-2 py-0.5 text-[8px] font-bold tracking-widest uppercase"
style={{ color: cardMemberNumberColor || undefined, borderColor: cardMemberNumberColor ? `${cardMemberNumberColor}4d` : undefined }}
>
Member #{memberNumber}
</span>
</div>
</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;
const isRewardSlot = i === rewardSlotIndex;
const earned = isRewardSlot ? stampsEarned >= threshold : i < stampsEarned;
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-accent-loyalty scale-[1.08] shadow-lg"
: "border-accent-loyalty/50 bg-accent-loyalty/10 border-2 border-dashed"
: 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 ? (
<span className="text-ink text-center text-[7px] leading-tight font-black">{rewardLabel}</span>
) : (
<Gift className="text-accent-loyalty h-3.5 w-3.5" aria-hidden="true" />
)
) : earned ? (
<FontAwesomeIcon icon={icon} className="text-ink h-3.5 w-3.5" />
) : null}
@@ -101,7 +161,7 @@ export function LoyaltyCard({
})}
</div>
{termsLine && (
<p className="text-center text-[7px] font-bold tracking-widest text-white/40 uppercase">{termsLine}</p>
<p className="text-center text-[7px] mt-2 font-bold tracking-widest text-white/40 uppercase">{termsLine}</p>
)}
</div>
</div>
+1 -1
View File
@@ -40,7 +40,7 @@ export function Modal({ open, onClose, title, children, className }: ModalProps)
}}
aria-labelledby="modal-title"
className={cn(
"m-auto w-[calc(100%-2rem)] max-w-md rounded-jumbo border border-line bg-white p-0 shadow-xl backdrop:bg-ink/40 backdrop:backdrop-blur-sm",
"m-auto w-[calc(100%-2rem)] max-w-md rounded-md border border-line bg-white p-0 shadow-xl backdrop:bg-ink/40 backdrop:backdrop-blur-sm",
className,
)}
>
+52 -1
View File
@@ -9,7 +9,13 @@ import { getAppointmentById } from "@/services/scheduling/appointment.service";
import { validateBookingSlot } from "@/services/scheduling/availability.service";
import { assertCanAccessAppointment, PermissionDeniedError } from "../authorization";
import { getFirstFriendlyMessage } from "../error-messages";
import { saveBarberTimeStepSchema, saveDetailsStepSchema, saveServicesStepSchema, startRebookSchema } from "../schemas";
import {
preselectServiceSchema,
saveBarberTimeStepSchema,
saveDetailsStepSchema,
saveServicesStepSchema,
startRebookSchema,
} from "../schemas";
/**
* Step-by-step booking draft actions. Each one: parses input, calls a
@@ -49,6 +55,51 @@ export async function saveServicesStepAction(input: unknown): Promise<StepAction
redirect("/book/barber-time");
}
/**
* Home's Services tab "Book" button (see BookServiceButton) — adds one
* service to the existing booking draft and sends the customer straight
* to /book with it already selected, per the follow-up brief's "Home
* Service Book Preselection" requirement. Only ever receives a service
* id from the client; everything else (does it exist, is it active, is
* it online-bookable, which business it belongs to) is re-read from the
* database here, never trusted from the caller — same discipline as
* every other draft action in this file.
*
* Reuses the exact same "only clear staff/date/time if the service
* selection actually changed" rule saveServicesStepAction already uses
* above, rather than inventing a second one.
*/
export async function preselectServiceAction(
input: unknown,
): Promise<{ success: false; error: string } | never> {
const parsed = preselectServiceSchema.safeParse(input);
if (!parsed.success) {
return { success: false, error: "That service couldn't be selected." };
}
const service = await prisma.service.findUnique({ where: { id: parsed.data.serviceId } });
if (!service || !service.isActive || !service.allowOnlineBooking) {
return { success: false, error: "This service isn't available for online booking right now." };
}
const draft = await readDraft(service.businessId);
const alreadySelected = draft.serviceIds.includes(service.id);
const nextServiceIds = alreadySelected ? draft.serviceIds : [...draft.serviceIds, service.id];
await writeDraft({
...draft,
businessId: service.businessId,
serviceIds: nextServiceIds,
// Adding a genuinely new service can change total duration/eligible
// barbers, invalidating a previously chosen slot — clear it. Re-clicking
// Book for a service that's already selected changes nothing, so leave
// any in-progress barber/date/time alone.
...(alreadySelected ? {} : { staffId: undefined, date: undefined, startAt: undefined }),
});
redirect("/book");
}
export async function saveBarberTimeStepAction(input: unknown): Promise<StepActionResult> {
const parsed = saveBarberTimeStepSchema.safeParse(input);
if (!parsed.success) return { success: false, error: "Please choose a valid time." };
@@ -0,0 +1,39 @@
"use client";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { preselectServiceAction } from "../actions/draft";
/**
* Home's Services tab "Book" button — preselects this service in the
* booking draft (see preselectServiceAction) before sending the customer
* to /book, instead of the plain `<Link href="/book">` it used to be.
* `preselectServiceAction` redirects on success; this only ever has
* something to render when it instead returns a friendly error (an
* inactive/removed service, or one no longer online-bookable).
*/
export function BookServiceButton({ serviceId }: { serviceId: string }) {
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const handleClick = () => {
setError(null);
startTransition(async () => {
const result = await preselectServiceAction({ serviceId });
if (result && !result.success) setError(result.error);
});
};
return (
<div className="flex flex-col items-end gap-1">
<Button type="button" size="sm" isLoading={isPending} onClick={handleClick}>
Book
</Button>
{error && (
<p role="alert" className="text-danger max-w-[10rem] text-right text-[10px] font-medium">
{error}
</p>
)}
</div>
);
}
@@ -106,7 +106,7 @@ export function DetailsForm(props: {
)}
{isAuthenticated && prefill && (
<div className="rounded-jumbo border-line border bg-white p-5">
<div className="rounded-md border-line border bg-white p-5">
<p className="text-muted text-[10px] font-bold tracking-widest uppercase">Booking as</p>
<p className="mt-1 text-sm font-bold">
{prefill.firstName} {prefill.lastName}
@@ -77,7 +77,7 @@ export function ServiceSelector(props: {
{grouped.map((group) => (
<section key={group.id}>
<h2 className="mb-3 text-sm font-bold">{group.name}</h2>
<div className="divide-line rounded-jumbo border-line divide-y border bg-white">
<div className="divide-line rounded-md border-line divide-y border bg-white">
{group.services.map((service) => {
const selected = selectedIds.includes(service.id);
return (
+5
View File
@@ -13,6 +13,11 @@ export const getAvailableServicesSchema = z.object({
staffId: z.string().optional(),
});
/** Home's Services tab "Book" button — see preselectServiceAction. Only a service id ever crosses this boundary; price/duration/eligibility are always re-read from the database server-side, never trusted from the client. */
export const preselectServiceSchema = z.object({
serviceId: z.string().min(1),
});
export const getAvailableBarbersSchema = z.object({
businessId: z.string(),
serviceIds: z.array(z.string()).min(1),
@@ -66,7 +66,7 @@ export function CalendarConnectionPanel({ initial, configured }: { initial: Conn
if (!state.connected) {
return (
<div className="border-line rounded-jumbo border bg-white p-5">
<div className="border-line rounded-md border bg-white p-5">
<p className="mb-1 text-sm font-bold">Google Calendar</p>
<p className="text-muted mb-4 text-xs">
Sync your confirmed appointments to your own Google Calendar. This connects a Google account separately
@@ -85,7 +85,7 @@ export function CalendarConnectionPanel({ initial, configured }: { initial: Conn
}
return (
<div className="border-line rounded-jumbo border bg-white p-5">
<div className="border-line rounded-md border bg-white p-5">
<p className="mb-1 text-sm font-bold">Google Calendar</p>
<div className="text-muted mb-4 space-y-1 text-xs">
<p>Connected as {state.accountEmail}</p>
+5 -10
View File
@@ -1,13 +1,12 @@
"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 { BookServiceButton } from "@/features/booking/components/book-service-button";
import { cn } from "@/lib/utils/cn";
import type { AvailableService } from "@/services/scheduling/availability.service";
import type { BusinessReviewSummary } from "@/services/reviews/reviews.service";
@@ -40,7 +39,7 @@ export interface HomeTabsProps {
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">
<TabsList className="bg-white border-t border-b border-line sticky top-0 z-30 -mx-6 rounded-none px-6 py-3">
<TabsTrigger value="services">Services</TabsTrigger>
<TabsTrigger value="portfolio">Portfolio</TabsTrigger>
<TabsTrigger value="reviews">Reviews</TabsTrigger>
@@ -165,11 +164,7 @@ function ServicesTab({
durationMinutes: service.durationMinutes,
formattedPrice: `£${Number(service.price).toFixed(2)}`,
}}
action={
<Button asChild size="sm">
<Link href="/book">Book</Link>
</Button>
}
action={<BookServiceButton serviceId={service.id} />}
/>
))}
</Card>
@@ -289,7 +284,7 @@ function DetailsTab({
<div className="space-y-8">
{mapEmbedSrc && (
<section>
<div className="bg-stone aspect-[16/9] w-full overflow-hidden rounded-jumbo">
<div className="bg-stone aspect-[16/9] w-full overflow-hidden rounded-md">
<iframe
src={mapEmbedSrc}
title="Business location map"
@@ -336,7 +331,7 @@ function DetailsTab({
{(hasSocial || phone || email) && (
<section>
<h3 className="text-muted mb-3 text-xs font-bold tracking-widest uppercase">Social &amp; Contact</h3>
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-3 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
@@ -401,7 +401,7 @@ export function InstallWizard() {
Copy Monday to all days
</button>
</div>
<div className="divide-line rounded-jumbo border-line divide-y border bg-white">
<div className="divide-line rounded-md border-line divide-y border bg-white">
{hours.map((day) => (
<div key={day.dayOfWeek} className="flex items-center justify-between gap-4 px-5 py-4">
<div className="flex items-center gap-3">
@@ -427,7 +427,7 @@ export function InstallWizard() {
{step === 6 && (
<div className="space-y-4">
<h2 className="text-xl font-bold">Booking settings</h2>
<div className="rounded-jumbo border-line border bg-white p-5">
<div className="rounded-md border-line border bg-white p-5">
<Switch
checked={bookingRules.allowGuestBooking}
onCheckedChange={(checked) => setBookingRules({ ...bookingRules, allowGuestBooking: checked })}
@@ -467,7 +467,7 @@ export function InstallWizard() {
onChange={(e) => setBookingRules({ ...bookingRules, slotIntervalMinutes: Number(e.target.value) })}
/>
</div>
<div className="rounded-jumbo border-line space-y-3 border bg-white p-5">
<div className="rounded-md border-line space-y-3 border bg-white p-5">
<Switch
checked={bookingRules.customerCanChooseStaff}
onCheckedChange={(checked) => setBookingRules({ ...bookingRules, customerCanChooseStaff: checked })}
@@ -509,7 +509,7 @@ export function InstallWizard() {
</p>
<div className="space-y-3">
{starterServices.map((s) => (
<div key={s.key} className="rounded-jumbo border-line space-y-3 border bg-white p-4">
<div key={s.key} className="rounded-md border-line space-y-3 border bg-white p-4">
<div className="flex items-center justify-between">
<p className="text-muted text-[10px] font-bold tracking-widest uppercase">Service</p>
<button type="button" onClick={() => removeStarter(s.key)} className="text-danger text-xs font-bold uppercase">
@@ -590,7 +590,7 @@ function StepNav({ onBack, onNext, nextDisabled }: { onBack: () => void; onNext:
function ReviewSection({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="rounded-jumbo border-line border bg-white p-4">
<div className="rounded-md border-line border bg-white p-4">
<p className="text-muted text-[10px] font-bold tracking-widest uppercase">{title}</p>
<p className="mt-1 text-sm">{children}</p>
</div>
@@ -20,7 +20,7 @@ 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". */
/** Tags new uploads for this field — e.g. "branding", "loyalty", "staff", "service", "portfolio", "welcome". The Library tab is one shared pool across every field by default; this only narrows it down if "This Field Only" is picked. */
category: string;
}
@@ -29,12 +29,21 @@ export interface MediaManagerModalProps {
* 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).
*
* The Library is a single, business-wide pool: an image uploaded from the
* Gallery manager shows up when picking a logo, and vice versa — "category"
* only tags where an upload came from and lets the "This Field Only" filter
* narrow the view, it never hides an image from other fields by default.
*/
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");
// Defaults to "all" — the library is one shared pool across every image
// field (logo, cover, gallery, staff/service photos, ...), so an image
// uploaded anywhere should be visible everywhere by default. "category"
// is an opt-in narrowing filter, not the default view.
const [scope, setScope] = useState<"all" | "category">("all");
const [selectedAsset, setSelectedAsset] = useState<MediaAssetSummary | null>(null);
const [externalUrl, setExternalUrl] = useState("");
const [error, setError] = useState<string | null>(null);
@@ -97,8 +106,8 @@ export function MediaManagerModal({ open, onClose, onSelect, category }: MediaMa
)}
<TabsContent value="library">
<div className="mb-4 flex items-center gap-2">
<div className="relative flex-1">
<div className="mb-4 space-y-3">
<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"
@@ -108,13 +117,27 @@ export function MediaManagerModal({ open, onClose, onSelect, category }: MediaMa
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 className="flex gap-2">
{(
[
{ value: "all", label: "All Images" },
{ value: "category", label: "This Field Only" },
] as const
).map((option) => (
<button
key={option.value}
type="button"
onClick={() => setScope(option.value)}
aria-pressed={scope === option.value}
className={cn(
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
scope === option.value ? "bg-ink text-white" : "border-line text-muted border bg-white",
)}
>
{option.label}
</button>
))}
</div>
</div>
{isPending && assets.length === 0 && !error ? (
@@ -9,7 +9,7 @@ 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". */
/** Tags new uploads with where they came from — e.g. "branding", "loyalty", "staff", "service", "portfolio", "welcome". The library itself is shared across every field; this doesn't hide other fields' images by default (see MediaManagerModal). */
category: string;
hint?: string;
}
@@ -74,7 +74,7 @@ export function NotificationList({ notifications }: { notifications: Notificatio
const unread = !notification.readAt;
const content = (
<div
className={`rounded-jumbo border p-4 transition-colors ${
className={`rounded-md border p-4 transition-colors ${
unread ? "border-accent/30 bg-accent/5" : "border-line bg-white"
}`}
>
+35 -7
View File
@@ -5,6 +5,7 @@ import { prisma } from "@/lib/db/prisma";
import { Permission } from "@/generated/prisma/enums";
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { writeAuditLog } from "@/lib/audit/write-audit-log";
import { updateBusinessProfile, updateBusinessSettings } from "@/services/business/business.service";
import { setReviewHidden } from "@/services/reviews/review-management.service";
import { syncGoogleReviewsForBusiness } from "@/services/reviews/review-sync.service";
@@ -16,28 +17,42 @@ export interface ReviewsActionResult {
async function requireReviewsManager() {
const session = await auth();
if (!session?.user) return { businessId: null, error: "You must be signed in." } as const;
if (!session?.user) return { businessId: null, userId: 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;
if (error instanceof OwnerPermissionDeniedError) return { businessId: null, userId: 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;
if (!context) return { businessId: null, userId: null, error: "You must be signed in as staff." } as const;
return { businessId: context.businessId, error: null } as const;
return { businessId: context.businessId, userId: context.userId, 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. */
/**
* 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. Deliberately no format regex on googlePlaceId beyond
* trimming — Google's own Place ID formats vary (place, postal code,
* plus-code IDs all look different) and this app has no need to validate
* more than "the owner typed something."
*
* Writes an audit log only when the Place ID actually changed — existing
* imported Review rows are deliberately left as-is when it changes (kept
* for history/audit, per docs/phase-10-summary.md's "Google Place ID"
* section); the next sync (scheduled or manual, via syncReviewsNowAction)
* naturally reconciles what's currently associated with the new Place ID.
*/
export async function updateReviewsSettingsAction(input: {
googlePlaceId: string | null;
reviewsMinRating: number | null;
reviewsExcludeRatingOnly: boolean;
}): Promise<ReviewsActionResult> {
const { businessId, error } = await requireReviewsManager();
const { businessId, userId, error } = await requireReviewsManager();
if (!businessId) return { success: false, error: error ?? "Not authorized." };
const business = await prisma.business.findUnique({ where: { id: businessId } });
@@ -45,6 +60,9 @@ export async function updateReviewsSettingsAction(input: {
const location = await prisma.businessLocation.findFirst({ where: { businessId, isPrimary: true } });
if (!location) return { success: false, error: "Business location not found." };
const nextPlaceId = input.googlePlaceId?.trim() || null;
const placeIdChanged = nextPlaceId !== business.googlePlaceId;
const profileResult = await updateBusinessProfile(prisma, {
businessId,
profile: {
@@ -52,7 +70,7 @@ export async function updateReviewsSettingsAction(input: {
timezone: business.timezone,
currency: business.currency,
locale: business.locale,
googlePlaceId: input.googlePlaceId,
googlePlaceId: nextPlaceId,
},
location: {
addressLine1: location.addressLine1,
@@ -70,6 +88,16 @@ export async function updateReviewsSettingsAction(input: {
settings: { reviewsMinRating: input.reviewsMinRating, reviewsExcludeRatingOnly: input.reviewsExcludeRatingOnly },
});
if (placeIdChanged) {
await writeAuditLog(prisma, {
actorUserId: userId,
action: "GOOGLE_PLACE_ID_CHANGED",
entityType: "Business",
entityId: businessId,
metadata: { previousPlaceId: business.googlePlaceId, nextPlaceId },
});
}
return { success: true };
}
@@ -58,7 +58,7 @@ export function AddStaffForm({ roles, services }: AddStaffFormProps) {
if (inviteLink) {
return (
<div className="space-y-4 rounded-jumbo border-line border bg-white p-6">
<div className="space-y-4 rounded-md border-line border bg-white p-6">
<p className="text-sm font-bold">{name} has been added.</p>
<p className="text-muted text-sm">
Email delivery isn&apos;t wired up yet — copy this invite link and send it to them yourself. It lets them
@@ -79,7 +79,7 @@ export function AddStaffForm({ roles, services }: AddStaffFormProps) {
return (
<div className="space-y-4">
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
<div className="rounded-md border-line space-y-4 border bg-white p-6">
<Input label="Name" required value={name} onChange={(e) => setName(e.target.value)} />
<Input label="Email" type="email" required value={email} onChange={(e) => setEmail(e.target.value)} />
<Input label="Phone (optional)" type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} />
@@ -95,7 +95,7 @@ export function AddStaffForm({ roles, services }: AddStaffFormProps) {
<Switch label="Visible for online booking" checked={isBookableOnline} onCheckedChange={setIsBookableOnline} />
</div>
<div className="rounded-jumbo border-line border bg-white p-6">
<div className="rounded-md border-line border bg-white p-6">
<p className="mb-3 text-xs font-bold tracking-widest uppercase">Services Offered</p>
<div className="space-y-3">
{services.map((s) => (
@@ -38,7 +38,7 @@ export function BookingRulesForm({ initial }: { initial: BookingRuleInput }) {
<div className="space-y-6 lg:grid lg:grid-cols-2 lg:items-start lg:gap-6 lg:space-y-0">
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Booking Window</h3>
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
<div className="rounded-md border-line space-y-4 border bg-white p-6">
<Input
label="Minimum notice (minutes)"
hint="How soon before a slot a booking can be made."
@@ -60,7 +60,7 @@ export function BookingRulesForm({ initial }: { initial: BookingRuleInput }) {
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Cancellation &amp; Rescheduling</h3>
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
<div className="rounded-md border-line space-y-4 border bg-white p-6">
<Input
label="Free cancellation window (hours)"
hint="Cancelling later than this may count as a late cancellation."
@@ -81,7 +81,7 @@ export function BookingRulesForm({ initial }: { initial: BookingRuleInput }) {
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Scheduling</h3>
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
<div className="rounded-md border-line space-y-4 border bg-white p-6">
<Select
label="Appointment slot interval"
value={String(form.slotIntervalMinutes)}
@@ -128,7 +128,7 @@ export function BookingRulesForm({ initial }: { initial: BookingRuleInput }) {
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Customer Options</h3>
<div className="rounded-jumbo border-line space-y-5 border bg-white p-6">
<div className="rounded-md border-line space-y-5 border bg-white p-6">
<Switch
label="Customers can choose a barber"
description='If off, "Anyone" is assigned automatically.'
@@ -115,7 +115,7 @@ export function BusinessDetailsForm({ initial }: { initial: BusinessDetailsIniti
<div className="space-y-6 lg:grid lg:grid-cols-2 lg:items-start lg:gap-6 lg:space-y-0">
<section className="space-y-3">
<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">
<div className="rounded-md 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)} />
<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)} />
@@ -124,7 +124,7 @@ export function BusinessDetailsForm({ initial }: { initial: BusinessDetailsIniti
<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">
<div className="rounded-md border-line space-y-4 border bg-white p-6">
<MediaPicker
label="Cover Image"
value={form.coverImageUrl}
@@ -168,7 +168,7 @@ export function BusinessDetailsForm({ initial }: { initial: BusinessDetailsIniti
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Contact &amp; Address</h3>
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
<div className="rounded-md border-line space-y-4 border bg-white p-6">
<Input label="Street Address" required value={form.addressLine1} onChange={(e) => set("addressLine1", e.target.value)} />
<Input label="Address Line 2 (optional)" value={form.addressLine2} onChange={(e) => set("addressLine2", e.target.value)} />
<div className="grid grid-cols-2 gap-4">
@@ -187,7 +187,7 @@ export function BusinessDetailsForm({ initial }: { initial: BusinessDetailsIniti
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Social Links</h3>
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
<div className="rounded-md border-line space-y-4 border bg-white p-6">
<Input label="Instagram" placeholder="@yourshop" value={form.instagramUrl} onChange={(e) => set("instagramUrl", e.target.value)} />
<Input label="Facebook" placeholder="facebook.com/yourshop" value={form.facebookUrl} onChange={(e) => set("facebookUrl", e.target.value)} />
</div>
@@ -195,7 +195,7 @@ export function BusinessDetailsForm({ initial }: { initial: BusinessDetailsIniti
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Regional Settings</h3>
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
<div className="rounded-md border-line space-y-4 border bg-white p-6">
<Select label="Time Zone" value={form.timezone} onChange={(e) => set("timezone", e.target.value)}>
{TIMEZONES.map((tz) => (
<option key={tz} value={tz}>
@@ -224,7 +224,7 @@ export function BusinessDetailsForm({ initial }: { initial: BusinessDetailsIniti
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Policies</h3>
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
<div className="rounded-md border-line space-y-4 border bg-white p-6">
<Textarea
label="Cancellation Policy"
value={form.cancellationPolicy}
@@ -199,7 +199,7 @@ export function CatalogueManager({ categories }: { categories: CatalogueCategory
</div>
</div>
<div className="rounded-jumbo border-line divide-line divide-y border bg-white">
<div className="rounded-md border-line divide-line divide-y border bg-white">
{category.services.map((service, serviceIndex) => (
<div key={service.id} className="flex items-center gap-3 px-5 py-4">
<div className="flex flex-col">
@@ -11,6 +11,9 @@ 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 { LoyaltyCardFont, LoyaltyCardTextSize, RewardPositionMode } from "@/generated/prisma/enums";
import { cn } from "@/lib/utils/cn";
import { MAX_STAMPS_REQUIRED, resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
import { updateLoyaltyConfigAction } from "../actions/loyalty-config";
import type { ProgrammeConfigInput } from "@/services/loyalty/loyalty.service";
@@ -20,6 +23,18 @@ const REWARD_TYPES: { value: ProgrammeConfigInput["rewardType"]; label: string }
{ value: "FREE_SERVICE", label: "Free Service" },
];
const FONT_OPTIONS: { value: LoyaltyCardFont; label: string }[] = [
{ value: LoyaltyCardFont.SANS, label: "Sans Serif" },
{ value: LoyaltyCardFont.SERIF, label: "Serif" },
{ value: LoyaltyCardFont.MONO, label: "Monospace" },
];
const TEXT_SIZE_OPTIONS: { value: LoyaltyCardTextSize; label: string }[] = [
{ value: LoyaltyCardTextSize.SM, label: "Small" },
{ value: LoyaltyCardTextSize.MD, label: "Medium" },
{ value: LoyaltyCardTextSize.LG, label: "Large" },
];
export interface LoyaltyConfigFormProps {
businessName: string;
initialConfig: ProgrammeConfigInput;
@@ -70,7 +85,7 @@ export function LoyaltyConfigForm({
return (
<div className="space-y-6 lg:grid lg:grid-cols-2 lg:items-start lg:gap-6 lg:space-y-0">
<section className="rounded-jumbo border-line border bg-white p-6 lg:col-span-2">
<section className="rounded-md border-line border bg-white p-6 lg:col-span-2">
<Switch
label="Loyalty programme enabled"
description="Turn off to stop earning/redeeming everywhere, without losing the configuration below."
@@ -81,14 +96,56 @@ export function LoyaltyConfigForm({
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Reward Rules</h3>
<div className="rounded-jumbo border-line space-y-5 border bg-white p-6">
<div className="rounded-md border-line space-y-5 border bg-white p-6">
<Input
label="Stamps required for reward"
label={`Stamps required for reward (1–${MAX_STAMPS_REQUIRED})`}
type="number"
min={1}
max={MAX_STAMPS_REQUIRED}
value={config.stampsRequired}
onChange={(e) => set("stampsRequired", Number(e.target.value))}
onChange={(e) => {
const next = Number(e.target.value);
set("stampsRequired", Number.isFinite(next) ? Math.min(Math.max(next, 1), MAX_STAMPS_REQUIRED) : 1);
}}
/>
<div>
<p className="mb-2 ml-1 text-[10px] font-bold tracking-widest uppercase text-muted">Reward Timing</p>
<div className="space-y-2">
{(
[
{
value: RewardPositionMode.AFTER_ALL_STAMPS,
title: "Reward after all stamps",
description: `Collect all ${config.stampsRequired} stamps, then the reward is given as a separate perk — every position on the card is a normal stamp.`,
},
{
value: RewardPositionMode.FINAL_SLOT_IS_REWARD,
title: "Final position is the reward",
description: `Collect ${resolveRewardThreshold({ stampsRequired: config.stampsRequired, rewardPosition: RewardPositionMode.FINAL_SLOT_IS_REWARD })} stamps — the last position on the card is the reward itself, marked visually, not a stamp to fill.`,
},
] as const
).map((option) => {
const selected = config.rewardPosition === option.value;
return (
<button
key={option.value}
type="button"
onClick={() => set("rewardPosition", option.value)}
aria-pressed={selected}
className={cn(
"w-full rounded-xl border p-4 text-left transition-colors",
selected ? "border-accent-loyalty bg-accent-loyalty/10" : "border-line bg-white",
)}
>
<p className="text-sm font-bold">{option.title}</p>
<p className="text-muted mt-1 text-xs">{option.description}</p>
</button>
);
})}
</div>
</div>
<Select label="Reward Type" value={config.rewardType} onChange={(e) => set("rewardType", e.target.value as ProgrammeConfigInput["rewardType"])}>
{REWARD_TYPES.map((t) => (
<option key={t.value} value={t.value}>
@@ -131,7 +188,7 @@ export function LoyaltyConfigForm({
<section className="space-y-3 lg:col-span-2">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Eligibility</h3>
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6 lg:grid lg:grid-cols-2 lg:gap-6 lg:space-y-0">
<div className="rounded-md border-line space-y-4 border bg-white p-6 lg:grid lg:grid-cols-2 lg:gap-6 lg:space-y-0">
<div>
<p className="mb-2 text-xs font-bold">Eligible services (none checked = all services earn stamps)</p>
<div className="space-y-2">
@@ -163,7 +220,7 @@ export function LoyaltyConfigForm({
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Rules &amp; Expiry</h3>
<div className="rounded-jumbo border-line space-y-5 border bg-white p-6">
<div className="rounded-md border-line space-y-5 border bg-white p-6">
<Switch
label="Apply reward automatically"
description="Redemption is always staff-initiated this phase — this setting is saved but has no automatic effect yet."
@@ -199,19 +256,29 @@ export function LoyaltyConfigForm({
<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 lg:grid lg:grid-cols-[minmax(0,320px)_1fr] lg:gap-8 lg:space-y-0">
<div className="rounded-md 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))}
stampsEarned={Math.max(
resolveRewardThreshold({ stampsRequired: Math.max(config.stampsRequired, 1), rewardPosition: config.rewardPosition }) - 1,
0,
)}
rewardLabel={config.rewardLabel || "Reward"}
termsLine={config.termsLine}
cardBackgroundColor={config.cardBackgroundColor}
cardBackgroundImageUrl={config.cardBackgroundImageUrl}
stampIcon={config.stampIcon}
rewardPosition={config.rewardPosition}
cardTopRightText={config.cardTopRightText}
cardFont={config.cardFont}
cardTopRightTextSize={config.cardTopRightTextSize}
cardTopRightTextColor={config.cardTopRightTextColor}
cardBorderColor={config.cardBorderColor}
cardMemberNumberColor={config.cardMemberNumberColor}
/>
</div>
<div className="mt-4 space-y-4 lg:mt-0">
@@ -226,6 +293,11 @@ export function LoyaltyConfigForm({
onChange={(url) => set("cardBackgroundImageUrl", url ?? undefined)}
category="loyalty"
/>
<ColorPicker
label="Border colour"
value={config.cardBorderColor}
onChange={(hex) => set("cardBorderColor", hex)}
/>
<IconPicker label="Stamp icon" value={config.stampIcon} onChange={(iconName) => set("stampIcon", iconName)} />
<Input
label="Terms line"
@@ -233,6 +305,55 @@ export function LoyaltyConfigForm({
onChange={(e) => set("termsLine", e.target.value)}
placeholder="Valid for 12 months. One reward per visit."
/>
<div className="border-line border-t pt-4">
<p className="mb-3 text-xs font-bold">Top-right card text</p>
<div className="space-y-4">
<Input
label="Text"
value={config.cardTopRightText ?? ""}
onChange={(e) => set("cardTopRightText", e.target.value)}
placeholder="e.g. VIP Member"
hint="Shown above the member number. Leave blank to show only the member number, as today."
/>
<div className="grid grid-cols-2 gap-4">
<Select
label="Font"
value={config.cardFont}
onChange={(e) => set("cardFont", e.target.value as ProgrammeConfigInput["cardFont"])}
>
{FONT_OPTIONS.map((f) => (
<option key={f.value} value={f.value}>
{f.label}
</option>
))}
</Select>
<Select
label="Text size"
value={config.cardTopRightTextSize}
onChange={(e) => set("cardTopRightTextSize", e.target.value as ProgrammeConfigInput["cardTopRightTextSize"])}
>
{TEXT_SIZE_OPTIONS.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</Select>
</div>
<p className="text-muted -mt-2 text-[10px]">Font applies to the whole card, not just this text.</p>
<ColorPicker
label="Text colour"
value={config.cardTopRightTextColor}
onChange={(hex) => set("cardTopRightTextColor", hex)}
/>
</div>
</div>
<ColorPicker
label="Member number colour"
value={config.cardMemberNumberColor}
onChange={(hex) => set("cardMemberNumberColor", hex)}
/>
</div>
</div>
</section>
@@ -147,7 +147,7 @@ export function OpeningHoursForm({ initialRows }: { initialRows: InitialOpeningH
Copy Monday to all days
</button>
<div className="divide-line rounded-jumbo border-line divide-y border bg-white">
<div className="divide-line rounded-md border-line divide-y border bg-white">
{days.map((day) => (
<div key={day.dayOfWeek} className="flex items-center justify-between gap-4 px-5 py-4">
<div className="flex items-center gap-3">
@@ -120,7 +120,7 @@ export function ReviewsManager({
return (
<div className="space-y-6">
<section className="rounded-jumbo border-line space-y-4 border bg-white p-6">
<section className="rounded-md 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">
@@ -133,7 +133,7 @@ export function ReviewsManager({
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."
hint="Used to import this business's reviews from Google. Find it by searching your business on Google Maps, opening Share → Embed a map, and copying the id from the embed code — or search “Google Place ID Finder” for Google's own lookup tool. Leave blank to disable review syncing."
/>
<div className="grid grid-cols-2 gap-4">
<Select
@@ -125,7 +125,7 @@ export function StaffDetailPanel({
return (
<div className="space-y-6">
{hasPendingInvitation && (
<section className="rounded-jumbo border-warning/30 bg-warning/10 border p-5">
<section className="rounded-md border-warning/30 bg-warning/10 border p-5">
<p className="text-warning-foreground text-sm font-bold">Invitation pending</p>
<p className="text-muted mt-1 text-xs">
This person hasn&apos;t set a password yet. Email delivery isn&apos;t wired up — send them the link
@@ -148,7 +148,7 @@ export function StaffDetailPanel({
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Role &amp; Permissions</h3>
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
<div className="rounded-md border-line space-y-4 border bg-white p-6">
<Select label="Role" value={roleId} onChange={(e) => setRoleId(e.target.value)}>
{roles.map((r) => (
<option key={r.id} value={r.id}>
@@ -174,7 +174,7 @@ export function StaffDetailPanel({
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Services Provided</h3>
<div className="rounded-jumbo border-line space-y-3 border bg-white p-6">
<div className="rounded-md border-line space-y-3 border bg-white p-6">
{allServices.map((s) => (
<Checkbox key={s.id} label={s.name} checked={serviceIds.includes(s.id)} onChange={() => toggleService(s.id)} />
))}
@@ -220,7 +220,7 @@ export function StaffDetailPanel({
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Availability</h3>
<div className="rounded-jumbo border-line divide-line divide-y overflow-hidden border bg-white">
<div className="rounded-md border-line divide-line divide-y overflow-hidden border bg-white">
<Link href={`/owner/team/${staffId}/hours`} className="hover:bg-stone flex items-center justify-between px-6 py-4">
<span className="text-sm font-bold">Working Hours</span>
<span className="text-muted text-xs">Edit</span>
@@ -242,7 +242,7 @@ export function StaffDetailPanel({
Reactivate Staff Member
</Button>
) : deactivateConflicts && deactivateConflicts.length > 0 ? (
<div className="border-danger/30 bg-danger/5 space-y-3 rounded-jumbo border p-5">
<div className="border-danger/30 bg-danger/5 space-y-3 rounded-md border p-5">
<p role="alert" className="text-danger text-sm font-medium">
This staff member still has {deactivateConflicts.length} upcoming appointment
{deactivateConflicts.length > 1 ? "s" : ""}. Deactivating won&apos;t cancel them — they&apos;ll need to
@@ -56,7 +56,7 @@ export function ServiceWorkerManager() {
return (
<div
role="status"
className="rounded-jumbo bg-ink fixed inset-x-6 top-4 z-50 mx-auto flex max-w-sm items-center justify-between gap-3 px-5 py-3 text-sm text-white shadow-xl"
className="rounded-md bg-ink fixed inset-x-6 top-4 z-50 mx-auto flex max-w-sm items-center justify-between gap-3 px-5 py-3 text-sm text-white shadow-xl"
>
<span>A new version is available.</span>
<div className="flex items-center gap-3">
@@ -42,7 +42,7 @@ export function WelcomeInstallCard({ businessName }: { businessName: string }) {
if (installed || dismissed || (!canPromptNatively && !ios)) return null;
return (
<div className="rounded-jumbo mb-6 border border-white/10 bg-white/5 p-5 text-white">
<div className="rounded-md mb-6 border border-white/10 bg-white/5 p-5 text-white">
<p className="text-sm font-bold">Add {businessName} to your Home Screen</p>
<p className="mt-1 text-sm text-white/70">Book appointments, manage your visits and receive reminders more easily.</p>
@@ -10,7 +10,7 @@ export function BookFollowUpButton({ appointmentId }: { appointmentId: string })
return (
<Button
variant="secondary"
className="rounded-jumbo border-line border bg-white px-5 py-3 text-sm font-bold"
className="rounded-md border-line border bg-white px-5 py-3 text-sm font-bold"
isLoading={isPending}
onClick={() => startTransition(() => startStaffFollowUpAction(appointmentId))}
>
@@ -69,11 +69,11 @@ export function BreaksList({
</Button>
{breaks.length === 0 ? (
<p className="text-muted rounded-jumbo border border-line bg-white p-5 text-center text-sm">
<p className="text-muted rounded-md border border-line bg-white p-5 text-center text-sm">
No recurring breaks yet.
</p>
) : (
<ul className="divide-y divide-line rounded-jumbo border border-line bg-white">
<ul className="divide-y divide-line rounded-md border border-line bg-white">
{breaks.map((b) => (
<li key={b.id}>
<button
@@ -57,7 +57,7 @@ export function NotificationPreferencesForm({ initial, action }: NotificationPre
optional. Choose how you&apos;d like to hear about upcoming appointment reminders.
</p>
<div className="rounded-jumbo border-line divide-line divide-y overflow-hidden border bg-white">
<div className="rounded-md border-line divide-line divide-y overflow-hidden border bg-white">
{CHANNEL_ORDER.map((channel) => (
<div key={channel} className="flex items-center justify-between px-4 py-3">
<span className="text-sm font-medium">Reminders via {CHANNEL_LABEL[channel]}</span>
@@ -60,7 +60,7 @@ export function ProfileForm({ initial }: { initial: ProfileFormInitial }) {
onChange={(e) => setBio(e.target.value)}
/>
<div className="rounded-jumbo border border-line bg-white p-5">
<div className="rounded-md border border-line bg-white p-5">
<Switch
label="Visible for online booking"
description="Customers can find and book you when this is on."
@@ -48,11 +48,11 @@ export function TimeOffList({
<div>
<p className="mb-2 text-xs font-bold uppercase tracking-widest text-muted">Upcoming</p>
{upcoming.length === 0 ? (
<p className="text-muted rounded-jumbo border border-line bg-white p-5 text-center text-sm">
<p className="text-muted rounded-md border border-line bg-white p-5 text-center text-sm">
No upcoming time off.
</p>
) : (
<ul className="divide-y divide-line rounded-jumbo border border-line bg-white">
<ul className="divide-y divide-line rounded-md border border-line bg-white">
{upcoming.map((t) => (
<li key={t.id}>
<button
@@ -74,7 +74,7 @@ export function TimeOffList({
{past.length > 0 && (
<div>
<p className="mb-2 text-xs font-bold uppercase tracking-widest text-muted">Past</p>
<ul className="divide-y divide-line rounded-jumbo border border-line bg-stone/50">
<ul className="divide-y divide-line rounded-md border border-line bg-stone/50">
{past.map((t) => (
<li key={t.id} className="px-5 py-4">
<p className="text-sm font-bold text-muted">{formatRange(t.startDate, t.endDate)}</p>
@@ -160,7 +160,7 @@ export function WorkingHoursForm({
Copy Monday to all days
</button>
<div className="divide-line rounded-jumbo border-line divide-y border bg-white">
<div className="divide-line rounded-md border-line divide-y border bg-white">
{days.map((day) => (
<div key={day.dayOfWeek} className="flex items-center justify-between gap-4 px-5 py-4">
<div className="flex items-center gap-3">
-2
View File
@@ -10,7 +10,6 @@ import {
faCheck,
faCircleCheck,
faClock,
faCoffee,
faCompass,
faCrown,
faDiamond,
@@ -76,7 +75,6 @@ export const ICON_CATALOG: IconDefinition[] = [
faBolt,
faSun,
faLeaf,
faCoffee,
faMugSaucer,
faGlassCheers,
faHandSparkles,
@@ -0,0 +1,98 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
/**
* Real filesystem I/O against a throwaway temp directory (via
* MEDIA_STORAGE_ROOT), not mocks — this module's whole job is getting
* real file paths right, so a mocked `fs` would test nothing useful. No
* DATABASE_URL/Prisma dependency here (media-storage.ts is DB-free), so
* this stays safe to run in the plain unit suite. The module is
* dynamically imported after MEDIA_STORAGE_ROOT is set, since it reads
* that env var once at module-load time.
*/
describe("media-storage", () => {
let tempRoot: string;
let storage: typeof import("../media-storage");
beforeAll(async () => {
tempRoot = await mkdtemp(path.join(tmpdir(), "nextchair-media-test-"));
process.env.MEDIA_STORAGE_ROOT = tempRoot;
storage = await import("../media-storage");
});
afterAll(async () => {
delete process.env.MEDIA_STORAGE_ROOT;
await rm(tempRoot, { recursive: true, force: true });
});
const PNG_BYTES = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"base64",
);
it("stores a file and returns a stable /api/media/ URL", async () => {
const result = await storage.storeMediaFile({ buffer: PNG_BYTES, mimeType: "image/png", category: "branding" });
expect(result.url).toMatch(/^\/api\/media\/branding\/[a-f0-9-]+\.png$/);
});
it("readStoredMediaFile resolves the stored file back with the right bytes and content type", async () => {
const stored = await storage.storeMediaFile({ buffer: PNG_BYTES, mimeType: "image/png", category: "gallery" });
const segments = stored.url.replace(storage.MEDIA_URL_PREFIX, "").split("/");
const file = await storage.readStoredMediaFile(segments);
expect(file).not.toBeNull();
expect(file?.mimeType).toBe("image/png");
expect(file?.buffer.equals(PNG_BYTES)).toBe(true);
});
it("readStoredMediaFile returns null for a file that was never written", async () => {
const file = await storage.readStoredMediaFile(["branding", "does-not-exist.png"]);
expect(file).toBeNull();
});
it("deleteMediaFile removes the file — a subsequent read returns null", async () => {
const stored = await storage.storeMediaFile({ buffer: PNG_BYTES, mimeType: "image/png", category: "staff" });
const segments = stored.url.replace(storage.MEDIA_URL_PREFIX, "").split("/");
expect(await storage.readStoredMediaFile(segments)).not.toBeNull();
await storage.deleteMediaFile(stored.url);
expect(await storage.readStoredMediaFile(segments)).toBeNull();
});
it("deleteMediaFile on an external (non-/api/media/) URL is a safe no-op", async () => {
await expect(storage.deleteMediaFile("https://example.com/logo.png")).resolves.toBeUndefined();
});
it("rejects unsupported mime types before ever touching the filesystem", async () => {
await expect(storage.storeMediaFile({ buffer: PNG_BYTES, mimeType: "image/svg+xml", category: "branding" })).rejects.toThrow();
});
it("blocks path traversal via '..' segments", () => {
expect(storage.resolveMediaFilePath(["..", "..", "etc", "passwd"])).toBeNull();
expect(storage.resolveMediaFilePath(["branding", ".."])).toBeNull();
expect(storage.resolveMediaFilePath(["branding", "..", "..", "package.json"])).toBeNull();
});
it("blocks segments that smuggle a path separator", () => {
expect(storage.resolveMediaFilePath(["branding/../../../etc/passwd"])).toBeNull();
expect(storage.resolveMediaFilePath(["a\\b"])).toBeNull();
});
it("readStoredMediaFile returns null for a path-traversal attempt even if a file exists there", async () => {
// Prove the guard rejects it via the real read path, not just the resolver in isolation.
const file = await storage.readStoredMediaFile(["..", "..", "..", "etc", "passwd"]);
expect(file).toBeNull();
});
it("mimeTypeForFilename covers every allowed extension and rejects unknown ones", () => {
expect(storage.mimeTypeForFilename("x.png")).toBe("image/png");
expect(storage.mimeTypeForFilename("x.jpg")).toBe("image/jpeg");
expect(storage.mimeTypeForFilename("x.jpeg")).toBe("image/jpeg");
expect(storage.mimeTypeForFilename("x.webp")).toBe("image/webp");
expect(storage.mimeTypeForFilename("x.gif")).toBe("image/gif");
expect(storage.mimeTypeForFilename("x.svg")).toBeNull();
expect(storage.mimeTypeForFilename("x")).toBeNull();
});
});
+96 -16
View File
@@ -1,19 +1,30 @@
import { randomUUID } from "node:crypto";
import { mkdir, unlink, writeFile } from "node:fs/promises";
import { access, mkdir, readFile, stat, 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.
* Local-disk storage for the Media Manager (see src/services/media).
*
* Files live under a storage root OUTSIDE `public/` (`storage/media` by
* default — `/app/storage/media` in the Docker image, matching the
* `next-chair-media-data` volume in docker-compose.local.yaml). This was
* previously `public/media/`, served by Next's own static file handling
* — that turned out not to work: verified directly against a real
* production build/container that Next.js 16's `next start` does NOT
* dynamically serve files added to `public/` after the build (a
* build-time-present file served 200; a file written into `public/media`
* at runtime served 404, even though it existed on disk with correct
* permissions). See src/app/api/media/[...path]/route.ts, the dedicated
* route that now serves these files itself instead of relying on Next's
* public-asset resolution.
*
* SVG is deliberately not in the allow-list — an uploaded SVG can embed
* <script>, making it a stored-XSS vector if ever served inline.
*
* Kept provider-neutral by design: every caller only ever sees a
* MediaAsset.url string and calls storeMediaFile/readStoredMediaFile/
* deleteMediaFile — swapping this file for an S3-compatible backend
* later doesn't require touching any caller.
*/
const ALLOWED_MIME_EXTENSIONS: Record<string, string> = {
"image/png": "png",
@@ -22,17 +33,27 @@ const ALLOWED_MIME_EXTENSIONS: Record<string, string> = {
"image/gif": "gif",
};
const EXTENSION_MIME_TYPES: Record<string, string> = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
webp: "image/webp",
gif: "image/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/";
/** Overridable so a non-Docker deployment can point this somewhere else; defaults to a directory next to the app, outside public/. */
const MEDIA_ROOT = path.resolve(process.env.MEDIA_STORAGE_ROOT || path.join(process.cwd(), "storage", "media"));
export const MEDIA_URL_PREFIX = "/api/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. */
/** Public path — what gets saved on MediaAsset.url and rendered directly. Resolved by src/app/api/media/[...path]/route.ts, not Next's static handling. */
url: string;
}
@@ -40,6 +61,54 @@ function sanitizeCategory(category: string): string {
return category.replace(/[^a-z0-9_-]/gi, "").toLowerCase() || "other";
}
/**
* Resolves a `/api/media/<...segments>` route param array to an absolute
* path, refusing anything that would escape MEDIA_ROOT — the only
* defense that actually matters against path traversal (`..` segments,
* absolute-path segments, encoded separators once decoded by Next's own
* routing). Returns null for anything unsafe rather than throwing, so
* callers can turn that straight into a 404 without distinguishing
* "malicious" from "malformed."
*/
export function resolveMediaFilePath(segments: string[]): string | null {
if (segments.length === 0) return null;
if (segments.some((segment) => !segment || segment === "." || segment === ".." || segment.includes("/") || segment.includes("\\"))) {
return null;
}
const absolutePath = path.resolve(MEDIA_ROOT, ...segments);
if (absolutePath !== MEDIA_ROOT && !absolutePath.startsWith(MEDIA_ROOT + path.sep)) return null;
return absolutePath;
}
export function mimeTypeForFilename(filename: string): string | null {
const extension = filename.split(".").pop()?.toLowerCase();
if (!extension) return null;
return EXTENSION_MIME_TYPES[extension] ?? null;
}
export interface StoredMediaFileContent {
buffer: Buffer;
mimeType: string;
}
/** Used only by GET /api/media/[...path] — returns null for "doesn't exist" or "not a regular file," never throws, so the route can turn that into a clean 404. */
export async function readStoredMediaFile(segments: string[]): Promise<StoredMediaFileContent | null> {
const absolutePath = resolveMediaFilePath(segments);
if (!absolutePath) return null;
const mimeType = mimeTypeForFilename(absolutePath);
if (!mimeType) return null;
try {
const fileStat = await stat(absolutePath);
if (!fileStat.isFile()) return null;
const buffer = await readFile(absolutePath);
return { buffer, mimeType };
} catch {
return null;
}
}
export async function storeMediaFile(params: {
buffer: Buffer;
mimeType: string;
@@ -51,8 +120,20 @@ export async function storeMediaFile(params: {
const safeCategory = sanitizeCategory(params.category);
const filename = `${randomUUID()}.${extension}`;
const dir = path.join(MEDIA_ROOT, safeCategory);
const absolutePath = path.join(dir, filename);
await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, filename), params.buffer);
await writeFile(absolutePath, params.buffer);
// Never report success on a write we can't actually read back — see
// this file's header. A silent permission/filesystem issue on write
// (e.g. a volume mounted read-only) should surface as a failed upload,
// not a broken URL discovered later.
try {
await access(absolutePath);
} catch {
throw new Error("Upload failed: the file could not be verified after writing.");
}
return { url: `${MEDIA_URL_PREFIX}${safeCategory}/${filename}` };
}
@@ -61,8 +142,7 @@ export async function storeMediaFile(params: {
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;
const absolutePath = resolveMediaFilePath(relative.split("/"));
if (!absolutePath) return;
await unlink(absolutePath).catch(() => {});
}
@@ -1,16 +1,20 @@
import { describe, expect, it } from "vitest";
import { validateProgrammeConfig } from "../loyalty.service";
import { LoyaltyCardFont, LoyaltyCardTextSize, RewardPositionMode } from "@/generated/prisma/enums";
import { resolveRewardThreshold, validateProgrammeConfig } from "../loyalty.service";
import type { ProgrammeConfigInput } from "../loyalty.service";
const base: ProgrammeConfigInput = {
isEnabled: true,
stampsRequired: 10,
rewardPosition: RewardPositionMode.AFTER_ALL_STAMPS,
rewardType: "PERCENTAGE",
rewardValue: "50",
autoApplyReward: true,
requiresStaffApproval: false,
earnOnCancelled: false,
earnOnNoShow: false,
cardFont: LoyaltyCardFont.SANS,
cardTopRightTextSize: LoyaltyCardTextSize.MD,
};
describe("validateProgrammeConfig", () => {
@@ -63,3 +67,21 @@ describe("validateProgrammeConfig", () => {
expect(result.issues[0]?.code).toBe("FREE_SERVICE_REQUIRED");
});
});
describe("resolveRewardThreshold", () => {
it("AFTER_ALL_STAMPS requires every position filled", () => {
expect(resolveRewardThreshold({ stampsRequired: 10, rewardPosition: RewardPositionMode.AFTER_ALL_STAMPS })).toBe(10);
});
it("FINAL_SLOT_IS_REWARD is earned one stamp early — the last position is the reward, not a stamp to collect", () => {
expect(
resolveRewardThreshold({ stampsRequired: 10, rewardPosition: RewardPositionMode.FINAL_SLOT_IS_REWARD }),
).toBe(9);
});
it("clamps FINAL_SLOT_IS_REWARD at a minimum threshold of 1, even for a single-stamp card", () => {
expect(
resolveRewardThreshold({ stampsRequired: 1, rewardPosition: RewardPositionMode.FINAL_SLOT_IS_REWARD }),
).toBe(1);
});
});
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { LoyaltyCardFont, LoyaltyCardTextSize, RewardPositionMode } from "@/generated/prisma/enums";
import {
expireOverdueRewards,
redeemReward,
@@ -32,12 +33,15 @@ describe("LoyaltyService — owner configuration and redemption (integration)",
input: {
isEnabled: true,
stampsRequired: 8,
rewardPosition: RewardPositionMode.AFTER_ALL_STAMPS,
rewardType: "PERCENTAGE",
rewardValue: "25",
autoApplyReward: true,
requiresStaffApproval: false,
earnOnCancelled: false,
earnOnNoShow: false,
cardFont: LoyaltyCardFont.SANS,
cardTopRightTextSize: LoyaltyCardTextSize.MD,
},
});
expect(first.success).toBe(true);
@@ -50,19 +54,22 @@ describe("LoyaltyService — owner configuration and redemption (integration)",
businessId: business.businessId,
input: {
isEnabled: true,
stampsRequired: 12,
stampsRequired: 10, // max allowed — see validateProgrammeConfig's 1-10 range (Phase 10)
rewardPosition: RewardPositionMode.AFTER_ALL_STAMPS,
rewardType: "PERCENTAGE",
rewardValue: "40",
autoApplyReward: true,
requiresStaffApproval: false,
earnOnCancelled: false,
earnOnNoShow: false,
cardFont: LoyaltyCardFont.SANS,
cardTopRightTextSize: LoyaltyCardTextSize.MD,
},
});
expect(second.success).toBe(true);
const updated = await prisma.loyaltyProgramme.findUniqueOrThrow({ where: { businessId: business.businessId } });
expect(updated.stampsRequired).toBe(12);
expect(updated.stampsRequired).toBe(10);
expect(Number(updated.rewardValue)).toBe(40);
});
@@ -77,12 +84,15 @@ describe("LoyaltyService — owner configuration and redemption (integration)",
input: {
isEnabled: true,
stampsRequired: 10,
rewardPosition: RewardPositionMode.AFTER_ALL_STAMPS,
rewardType: "FREE_SERVICE",
freeServiceId: inactiveService.id,
autoApplyReward: true,
requiresStaffApproval: false,
earnOnCancelled: false,
earnOnNoShow: false,
cardFont: LoyaltyCardFont.SANS,
cardTopRightTextSize: LoyaltyCardTextSize.MD,
},
});
expect(result.success).toBe(false);
@@ -1,11 +1,12 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { RewardType } from "@/generated/prisma/enums";
import { RewardPositionMode, RewardType } from "@/generated/prisma/enums";
import {
addStamp,
awardStampForCompletedAppointment,
correctStamp,
getLoyaltyHistory,
getLoyaltySummary,
redeemReward,
} from "../loyalty.service";
import {
cleanupTestBusiness,
@@ -24,12 +25,13 @@ import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
describe("LoyaltyService (integration)", () => {
let business: TestBusiness;
async function enableLoyalty(overrides: { stampsRequired?: number } = {}) {
async function enableLoyalty(overrides: { stampsRequired?: number; rewardPosition?: RewardPositionMode } = {}) {
return prisma.loyaltyProgramme.create({
data: {
businessId: business.businessId,
isEnabled: true,
stampsRequired: overrides.stampsRequired ?? 5,
rewardPosition: overrides.rewardPosition ?? RewardPositionMode.AFTER_ALL_STAMPS,
rewardType: RewardType.PERCENTAGE,
rewardValue: 50,
rewardLabel: "50% Off",
@@ -144,6 +146,67 @@ describe("LoyaltyService (integration)", () => {
expect(account.currentStamps).toBe(5);
});
describe("reward position modes", () => {
it("FINAL_SLOT_IS_REWARD earns the reward one stamp early via a manual add — the same threshold AFTER_ALL_STAMPS would need one more stamp to cross", async () => {
const programme = await enableLoyalty({ stampsRequired: 3, rewardPosition: RewardPositionMode.FINAL_SLOT_IS_REWARD });
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const shortOfAfterAllStamps = await addStamp(prisma, { customerId, businessId: business.businessId, amount: 2, staffId });
expect(shortOfAfterAllStamps.rewardCreated).toBe(true); // 2 stamps == threshold (stampsRequired - 1)
const account = await prisma.loyaltyAccount.findUniqueOrThrow({
where: { customerId_loyaltyProgrammeId: { customerId, loyaltyProgrammeId: programme.id } },
});
expect(account.currentStamps).toBe(2);
});
it("FINAL_SLOT_IS_REWARD uses the same threshold for the automatic completed-appointment path as for a manual add", async () => {
await enableLoyalty({ stampsRequired: 3, rewardPosition: RewardPositionMode.FINAL_SLOT_IS_REWARD });
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
// One manual stamp, then one automatic one from a completed appointment — together they
// cross the threshold (2), and both paths must agree that's enough to earn the reward.
await addStamp(prisma, { customerId, businessId: business.businessId, amount: 1, staffId });
const appointment = await completedAppointment(staffId, customerId);
const result = await awardStampForCompletedAppointment(prisma, { appointmentId: appointment.id, staffId });
expect(result.rewardCreated).toBe(true);
});
it("redemption deducts exactly the earning threshold, not the raw stampsRequired total, in FINAL_SLOT_IS_REWARD mode", async () => {
const programme = await enableLoyalty({ stampsRequired: 4, rewardPosition: RewardPositionMode.FINAL_SLOT_IS_REWARD });
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const appointment = await completedAppointment(staffId, customerId);
// 3 stamps == threshold (stampsRequired - 1) — earns the reward without ever reaching 4.
const added = await addStamp(prisma, { customerId, businessId: business.businessId, amount: 3, staffId });
expect(added.rewardCreated).toBe(true);
const account = await prisma.loyaltyAccount.findUniqueOrThrow({
where: { customerId_loyaltyProgrammeId: { customerId, loyaltyProgrammeId: programme.id } },
});
const reward = await prisma.loyaltyReward.findFirstOrThrow({
where: { loyaltyAccountId: account.id, status: "AVAILABLE" },
});
const redemption = await redeemReward(prisma, {
rewardId: reward.id,
appointmentId: appointment.id,
staffId,
businessId: business.businessId,
});
expect(redemption.success).toBe(true);
const afterRedemption = await prisma.loyaltyAccount.findUniqueOrThrow({ where: { id: account.id } });
// Deducting the raw stampsRequired (4) from a balance of 3 would have gone negative —
// clampedDeduction alone would mask the bug. Deducting the threshold (3) leaves exactly 0.
expect(afterRedemption.currentStamps).toBe(0);
});
});
it("rejects a manual stamp when loyalty is disabled for the business", async () => {
// No enableLoyalty() call — business has no loyalty programme at all.
const { staffId } = await createTestStaff(business);
+49 -7
View File
@@ -1,4 +1,5 @@
import type { LoyaltyProgramme, PrismaClient } from "@/generated/prisma/client";
import { LoyaltyCardFont, LoyaltyCardTextSize, RewardPositionMode } from "@/generated/prisma/enums";
import { fail, ok } from "@/lib/validation-result";
import type { ValidationResult } from "@/lib/validation-result";
import {
@@ -7,6 +8,9 @@ import {
notifyLoyaltyRewardRedeemed,
notifyLoyaltyStampAdded,
} from "./loyalty-notifications";
import { MAX_STAMPS_REQUIRED, resolveRewardThreshold } from "./reward-threshold";
export { MAX_STAMPS_REQUIRED, resolveRewardThreshold } from "./reward-threshold";
/**
* Ledger-based loyalty, per the brief: the cached `LoyaltyAccount.currentStamps`
@@ -45,11 +49,19 @@ export interface LoyaltySummary {
accountId: string | null;
currentStamps: number;
stampsRequired: number;
rewardPosition: RewardPositionMode;
availableReward: { id: string; label: string; createdAt: Date; expiresAt: Date | null } | null;
rewardLabel: string | null;
stampIcon: string | null;
cardBackgroundColor: string | null;
cardBackgroundImageUrl: string | null;
termsLine: string | null;
cardTopRightText: string | null;
cardFont: LoyaltyCardFont;
cardTopRightTextSize: LoyaltyCardTextSize;
cardTopRightTextColor: string | null;
cardBorderColor: string | null;
cardMemberNumberColor: 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). */
@@ -79,6 +91,7 @@ export async function getLoyaltySummary(
accountId: account?.id ?? null,
currentStamps: account?.currentStamps ?? 0,
stampsRequired: programme.stampsRequired,
rewardPosition: programme.rewardPosition,
availableReward: account?.rewards[0]
? {
id: account.rewards[0].id,
@@ -91,6 +104,13 @@ export async function getLoyaltySummary(
stampIcon: programme.stampIcon,
cardBackgroundColor: programme.cardBackgroundColor,
cardBackgroundImageUrl: programme.cardBackgroundImageUrl,
termsLine: programme.termsLine,
cardTopRightText: programme.cardTopRightText,
cardFont: programme.cardFont,
cardTopRightTextSize: programme.cardTopRightTextSize,
cardTopRightTextColor: programme.cardTopRightTextColor,
cardBorderColor: programme.cardBorderColor,
cardMemberNumberColor: programme.cardMemberNumberColor,
};
}
@@ -124,7 +144,8 @@ export async function getAvailableRewardFlags(
export interface CustomerLoyaltyBrief {
currentStamps: number;
stampsRequired: number;
/** How many stamps actually earn the reward — see resolveRewardThreshold. */
rewardThreshold: number;
hasAvailableReward: boolean;
}
@@ -149,12 +170,13 @@ export async function getLoyaltySummariesForCustomers(
},
});
const rewardThreshold = resolveRewardThreshold(programme);
return new Map(
accounts.map((a) => [
a.customerId,
{
currentStamps: a.currentStamps,
stampsRequired: programme.stampsRequired,
rewardThreshold,
hasAvailableReward: a.rewards.length > 0,
},
]),
@@ -404,7 +426,7 @@ async function addStampTransaction(
let rewardCreated = false;
let rewardId: string | undefined;
if (account.currentStamps >= params.programme.stampsRequired) {
if (account.currentStamps >= resolveRewardThreshold(params.programme)) {
const existingAvailable = await tx.loyaltyReward.findFirst({
where: { loyaltyAccountId: account.id, status: "AVAILABLE" },
});
@@ -506,6 +528,8 @@ export async function getLoyaltyHistory(
export interface ProgrammeConfigInput {
isEnabled: boolean;
stampsRequired: number;
/** See resolveRewardThreshold's doc comment for what each mode means. */
rewardPosition: RewardPositionMode;
rewardType: "PERCENTAGE" | "FIXED_AMOUNT" | "FREE_SERVICE";
/** Decimal string — see ServiceCatalogueService's file header for why money is never a JS number. Required for PERCENTAGE/FIXED_AMOUNT, ignored for FREE_SERVICE. */
rewardValue?: string;
@@ -522,12 +546,19 @@ export interface ProgrammeConfigInput {
cardBackgroundImageUrl?: string;
stampIcon?: string;
termsLine?: string;
/** Free text shown in the card's top-right area — see schema.prisma's cardTopRightText doc comment. */
cardTopRightText?: string;
cardFont: LoyaltyCardFont;
cardTopRightTextSize: LoyaltyCardTextSize;
cardTopRightTextColor?: string;
cardBorderColor?: string;
cardMemberNumberColor?: string;
}
/** Only enforced when isEnabled — a half-configured, disabled draft is fine (the owner is still working on it); a half-configured, *enabled* programme would start awarding rewards nobody can redeem correctly. */
export function validateProgrammeConfig(input: ProgrammeConfigInput): ValidationResult<LoyaltyErrorCode> {
if (!Number.isInteger(input.stampsRequired) || input.stampsRequired < 1) {
return fail({ code: "INVALID_INPUT", message: "Stamps required must be at least 1." });
if (!Number.isInteger(input.stampsRequired) || input.stampsRequired < 1 || input.stampsRequired > MAX_STAMPS_REQUIRED) {
return fail({ code: "INVALID_INPUT", message: `Stamps required must be between 1 and ${MAX_STAMPS_REQUIRED}.` });
}
if (!input.isEnabled) return ok();
@@ -580,6 +611,7 @@ export async function updateProgrammeConfig(
const data = {
isEnabled: params.input.isEnabled,
stampsRequired: params.input.stampsRequired,
rewardPosition: params.input.rewardPosition,
rewardType: params.input.rewardType,
rewardValue: params.input.rewardType === "FREE_SERVICE" ? null : (params.input.rewardValue ?? null),
rewardLabel: params.input.rewardLabel?.trim() || null,
@@ -595,6 +627,12 @@ export async function updateProgrammeConfig(
cardBackgroundImageUrl: params.input.cardBackgroundImageUrl || null,
stampIcon: params.input.stampIcon || null,
termsLine: params.input.termsLine?.trim() || null,
cardTopRightText: params.input.cardTopRightText?.trim() || null,
cardFont: params.input.cardFont,
cardTopRightTextSize: params.input.cardTopRightTextSize,
cardTopRightTextColor: params.input.cardTopRightTextColor || null,
cardBorderColor: params.input.cardBorderColor || null,
cardMemberNumberColor: params.input.cardMemberNumberColor || null,
};
await prisma.loyaltyProgramme.upsert({
@@ -745,7 +783,11 @@ export async function redeemReward(
discountAmount = Math.min(Math.max(discountAmount, 0), subtotal);
const discountAmountString = discountAmount.toFixed(2);
const stampsRequired = reward.loyaltyAccount.loyaltyProgramme.stampsRequired;
// Deduct however many stamps actually earned this reward, not the raw
// stampsRequired total — in FINAL_SLOT_IS_REWARD mode those aren't the
// same number (see resolveRewardThreshold), and the ledger must only
// ever remove what was really collected.
const rewardThreshold = resolveRewardThreshold(reward.loyaltyAccount.loyaltyProgramme);
const { alreadyRedeemed } = await prisma.$transaction(async (tx) => {
const updated = await tx.loyaltyReward.updateMany({
@@ -760,7 +802,7 @@ export async function redeemReward(
if (updated.count === 0) return { alreadyRedeemed: true };
const account = await tx.loyaltyAccount.findUniqueOrThrow({ where: { id: reward.loyaltyAccountId } });
const clampedDeduction = Math.min(stampsRequired, account.currentStamps);
const clampedDeduction = Math.min(rewardThreshold, account.currentStamps);
await tx.loyaltyAccount.update({
where: { id: account.id },
data: { currentStamps: { decrement: clampedDeduction } },
+38
View File
@@ -0,0 +1,38 @@
import type { LoyaltyProgramme } from "@/generated/prisma/client";
import { RewardPositionMode } from "@/generated/prisma/enums";
/**
* Stamps required for a reward is capped at 10 (Phase 10 follow-up brief) —
* mirrored by the LoyaltyProgramme_stampsRequired_range CHECK constraint,
* validateProgrammeConfig, and the owner form's own client-side validation.
* Lives here (not loyalty.service.ts) for the same reason
* resolveRewardThreshold does — see that function's doc comment.
*/
export const MAX_STAMPS_REQUIRED = 10;
/**
* The single place "how many stamps actually earn the reward" is decided —
* every caller that needs to know (auto/manual stamp awarding, redemption,
* progress text, the customer card, the owner's live preview) reads through
* this rather than each re-deriving its own answer, so they can't disagree.
* Deliberately its own tiny, side-effect-free module (not defined inline in
* loyalty.service.ts) so client components — the customer card, the owner's
* live preview — can import this one pure function without pulling in the
* rest of that file's Prisma-calling service code.
*
* AFTER_ALL_STAMPS: every one of `stampsRequired` positions is a normal
* stamp; the reward unlocks once all of them are filled — threshold ==
* stampsRequired.
*
* FINAL_SLOT_IS_REWARD: the last of the `stampsRequired` positions on the
* card *is* the reward, not a stamp to collect — so it's earned one stamp
* early, at stampsRequired - 1. (Clamped at 1 so a stampsRequired of 1 can
* never produce a threshold of 0.)
*/
export function resolveRewardThreshold(
programme: Pick<LoyaltyProgramme, "stampsRequired" | "rewardPosition">,
): number {
return programme.rewardPosition === RewardPositionMode.FINAL_SLOT_IS_REWARD
? Math.max(1, programme.stampsRequired - 1)
: programme.stampsRequired;
}
@@ -218,6 +218,7 @@ export async function getConfigurationWarnings(prisma: PrismaClient, businessId:
const validation = validateProgrammeConfig({
isEnabled: loyaltyProgramme.isEnabled,
stampsRequired: loyaltyProgramme.stampsRequired,
rewardPosition: loyaltyProgramme.rewardPosition,
rewardType: loyaltyProgramme.rewardType,
rewardValue: loyaltyProgramme.rewardValue?.toString(),
freeServiceId: loyaltyProgramme.freeServiceId ?? undefined,
@@ -225,6 +226,8 @@ export async function getConfigurationWarnings(prisma: PrismaClient, businessId:
requiresStaffApproval: loyaltyProgramme.requiresStaffApproval,
earnOnCancelled: loyaltyProgramme.earnOnCancelled,
earnOnNoShow: loyaltyProgramme.earnOnNoShow,
cardFont: loyaltyProgramme.cardFont,
cardTopRightTextSize: loyaltyProgramme.cardTopRightTextSize,
});
if (!validation.valid) {
warnings.push({