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;.