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}”.
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & 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"
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -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'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 & 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 & 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 & 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't set a password yet. Email delivery isn'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 & 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't cancel them — they'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'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">
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user