Fixes
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:
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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 “{q}”.
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 “{q}”.
|
||||
|
||||
Reference in new issue
Block a user