This commit is contained in:
Stefan Lazic committed 2026-08-03 21:15:24 +01:00
1 parent ec5583f972
commit 546613e677
189 files changed
+9873 -655

No files matched your search

+1 -1
View File
@@ -3,7 +3,7 @@ import { Scissors } from "lucide-react";
export default function AuthLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen flex-col bg-stone p-6">
<div className="mx-auto flex w-full max-w-md flex-1 flex-col justify-center">
<div id="main-content" className="mx-auto flex w-full max-w-md flex-1 flex-col justify-center">
<div className="mb-8 flex h-16 w-16 items-center justify-center rounded-[1.25rem] bg-ink text-3xl text-white shadow-xl shadow-ink/20">
<Scissors className="h-7 w-7" aria-hidden="true" />
</div>
@@ -35,16 +35,19 @@ const REBOOKABLE_STATUSES = new Set(["COMPLETED", "CANCELLED", "NO_SHOW"]);
export default async function AppointmentDetailsPage({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ token?: string }>;
}) {
const { id } = await params;
const { token } = await searchParams;
const appointment = await getAppointmentById(prisma, id);
if (!appointment) notFound();
const session = await auth();
try {
assertCanAccessAppointment(appointment, session?.user?.id);
await assertCanAccessAppointment(prisma, appointment, { sessionUserId: session?.user?.id, guestToken: token });
} catch (error) {
if (error instanceof PermissionDeniedError) notFound(); // don't confirm the id exists to someone who can't see it
throw error;
@@ -153,7 +156,7 @@ export default async function AppointmentDetailsPage({
<div className="flex flex-wrap gap-3">
<a
href={`/api/appointments/${appointment.id}/calendar.ics`}
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"
>
<CalendarPlus className="text-accent h-4 w-4" aria-hidden="true" /> Add to Calendar
@@ -171,17 +174,17 @@ export default async function AppointmentDetailsPage({
<div className="flex flex-wrap gap-3">
{RESCHEDULABLE_STATUSES.has(appointment.status) && (
<Link
href={`/appointments/${appointment.id}/reschedule`}
href={`/appointments/${appointment.id}/reschedule${token ? `?token=${token}` : ""}`}
className="rounded-jumbo border-line border bg-white px-5 py-3 text-sm font-bold"
>
Reschedule
</Link>
)}
{CANCELLABLE_STATUSES.has(appointment.status) && (
<CancelAppointmentDialog appointmentId={appointment.id} />
<CancelAppointmentDialog appointmentId={appointment.id} guestToken={token} />
)}
{REBOOKABLE_STATUSES.has(appointment.status) && (
<BookAgainButton appointmentId={appointment.id} />
<BookAgainButton appointmentId={appointment.id} guestToken={token} />
)}
</div>
</main>
+8
View File
@@ -0,0 +1,8 @@
"use client";
import { ErrorState } from "@/components/feedback/error-state";
/** Keeps (customer)/layout.tsx's bottom nav visible on-screen even when a page below it throws — see src/app/error.tsx for the app-wide fallback this backs up. */
export default function CustomerError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
return <ErrorState onRetry={reset} />;
}
+35 -33
View File
@@ -13,6 +13,7 @@ import { computeOpeningStatus } from "@/lib/business/opening-status";
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
import { prisma } from "@/lib/db/prisma";
import { getAvailableServices } from "@/services/scheduling/availability.service";
import { getLoyaltySummary, type LoyaltySummary } from "@/services/loyalty/loyalty.service";
import { getBusinessReviews } from "@/services/reviews/reviews.service";
import type { AppointmentStatus } from "@/types/domain";
@@ -67,8 +68,7 @@ export default async function CustomerHomePage() {
let customerProfile: Awaited<ReturnType<typeof prisma.customerProfile.findUnique>> = null;
let upcomingAppointment: UpcomingAppointment | null = null;
let loyaltyAccount: Awaited<ReturnType<typeof prisma.loyaltyAccount.findFirst>> = null;
let loyaltyProgramme: Awaited<ReturnType<typeof prisma.loyaltyProgramme.findUnique>> = null;
let loyalty: LoyaltySummary | null = null;
let preferredBarberName: string | null = null;
let lastCompletedAppointment: Awaited<ReturnType<typeof prisma.appointment.findFirst>> = null;
@@ -78,7 +78,7 @@ export default async function CustomerHomePage() {
});
if (customerProfile) {
[upcomingAppointment, loyaltyProgramme, lastCompletedAppointment] = await Promise.all([
[upcomingAppointment, loyalty, lastCompletedAppointment] = await Promise.all([
prisma.appointment.findFirst({
where: {
customerId: customerProfile.id,
@@ -88,24 +88,13 @@ export default async function CustomerHomePage() {
orderBy: { startAt: "asc" },
include: { services: { include: { service: true } }, staff: { include: { user: true } } },
}),
prisma.loyaltyProgramme.findUnique({ where: { businessId: business.id } }),
getLoyaltySummary(prisma, { customerId: customerProfile.id, businessId: business.id }),
prisma.appointment.findFirst({
where: { customerId: customerProfile.id, status: "COMPLETED" },
orderBy: { startAt: "desc" },
}),
]);
if (loyaltyProgramme?.isEnabled) {
loyaltyAccount = await prisma.loyaltyAccount.findUnique({
where: {
customerId_loyaltyProgrammeId: {
customerId: customerProfile.id,
loyaltyProgrammeId: loyaltyProgramme.id,
},
},
});
}
if (customerProfile.preferredBarberId) {
const preferred = await prisma.staffProfile.findUnique({
where: { id: customerProfile.preferredBarberId },
@@ -145,7 +134,7 @@ export default async function CustomerHomePage() {
</div>
<span className="text-muted">· {reviews.totalReviews.toLocaleString()} reviews</span>
<span
className={openingStatus.isOpen ? "text-accent font-bold" : "text-danger font-bold"}
className={openingStatus.isOpen ? "text-accent-foreground font-bold" : "text-danger font-bold"}
>
{openingStatus.label}
</span>
@@ -191,25 +180,38 @@ export default async function CustomerHomePage() {
)}
{/* Loyalty summary */}
{loyaltyProgramme?.isEnabled && loyaltyAccount && (
{loyalty?.isEnabled && loyalty.accountId && (
<Link
href="/settings"
className="rounded-jumbo border-line mb-6 block border bg-white p-5 shadow-sm"
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"
}
>
<div className="mb-3 flex items-center justify-between">
<p className="text-sm font-bold">Loyalty Progress</p>
<span className="text-muted text-xs">
{loyaltyAccount.currentStamps}/{loyaltyProgramme.stampsRequired} 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, (loyaltyAccount.currentStamps / loyaltyProgramme.stampsRequired) * 100)}%`,
}}
/>
</div>
{loyalty.availableReward ? (
<div className="flex items-center justify-between">
<p className="text-sm font-bold">Reward available — {loyalty.availableReward.label}</p>
<span className="text-accent-loyalty-foreground text-xs font-bold uppercase tracking-widest">View</span>
</div>
) : (
<>
<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
</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)}%`,
}}
/>
</div>
</>
)}
</Link>
)}
+14 -3
View File
@@ -1,4 +1,8 @@
import { auth } from "@/auth";
import { BottomNav, type BottomNavItem } from "@/components/navigation/bottom-nav";
import { prisma } from "@/lib/db/prisma";
import { NotificationBell } from "@/features/notifications/components/notification-bell";
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
const NAV_ITEMS: BottomNavItem[] = [
{ href: "/home", label: "Home", icon: "house" },
@@ -11,12 +15,19 @@ const NAV_ITEMS: BottomNavItem[] = [
* "For guests, show the public business experience without personalised
* content"). /appointments and /settings each check their own session
* requirement and show an in-context sign-in prompt rather than bouncing
* a guest away with no explanation — see those pages.
* a guest away with no explanation — see those pages. The bell itself is
* only rendered for a signed-in session — a guest has no recipientUserId,
* so there's nothing in the in-app centre for them (see
* notification.service.ts's IN_APP channel resolution).
*/
export default function CustomerLayout({ children }: { children: React.ReactNode }) {
export default async function CustomerLayout({ children }: { children: React.ReactNode }) {
const session = await auth();
const unreadNotificationCount = session?.user ? await getUnreadNotificationCount(prisma, session.user.id) : 0;
return (
<div className="min-h-screen bg-stone pb-24">
{children}
{session?.user && <NotificationBell href="/notifications" unreadCount={unreadNotificationCount} />}
<div id="main-content">{children}</div>
<BottomNav items={NAV_ITEMS} variant="customer" />
</div>
);
+135
View File
@@ -0,0 +1,135 @@
import type { Metadata } from "next";
import { Gift, LogIn } from "lucide-react";
import Link from "next/link";
import { auth } from "@/auth";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { EmptyState } from "@/components/feedback/empty-state";
import { LoyaltyCard } from "@/components/ui/loyalty-card";
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";
export const metadata: Metadata = { title: "Loyalty" };
export const dynamic = "force-dynamic";
export default async function LoyaltyPage() {
const [business, session] = await Promise.all([getPrimaryBusiness(), auth()]);
if (!session?.user) {
return (
<div className="mx-auto max-w-2xl px-6 pt-12">
<h1 className="mb-6 text-2xl font-bold tracking-tight">Loyalty</h1>
<EmptyState
icon={LogIn}
title="Sign in to see your loyalty card"
description="Track your stamps and rewards across visits."
action={
<Button asChild>
<Link href="/sign-in?callbackUrl=/loyalty">Sign In</Link>
</Button>
}
/>
</div>
);
}
const customerProfile = await prisma.customerProfile.findUnique({
where: { userId: session.user.id },
});
const loyalty = customerProfile
? await getLoyaltySummary(prisma, { customerId: customerProfile.id, businessId: business.id })
: null;
if (!customerProfile || !loyalty?.isEnabled) {
return (
<div className="pb-12">
<TopNav title="Loyalty" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl px-6 py-6">
<EmptyState
icon={Gift}
title="Loyalty isn't available here yet"
description={`${business.name} hasn't turned on a loyalty programme.`}
/>
</main>
</div>
);
}
const history = loyalty.accountId
? await getLoyaltyHistory(prisma, { loyaltyAccountId: loyalty.accountId, limit: 20 })
: [];
const expiresLabel = loyalty.availableReward?.expiresAt
? loyalty.availableReward.expiresAt.toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" })
: null;
return (
<div className="pb-12">
<TopNav title="Loyalty" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl space-y-4 px-6 py-6">
<LoyaltyCard
businessName={business.name}
memberNumber={customerProfile.id.slice(-6).toUpperCase()}
stampsRequired={loyalty.stampsRequired}
stampsEarned={loyalty.currentStamps}
rewardLabel={loyalty.rewardLabel ?? "Reward"}
termsLine={loyalty.availableReward ? undefined : `${loyalty.stampsRequired - loyalty.currentStamps} more to your reward`}
/>
{loyalty.availableReward ? (
<Card className="border-accent-loyalty bg-accent-loyalty/10 space-y-1 border p-5">
<p className="text-sm font-bold">Reward available: {loyalty.availableReward.label}</p>
<p className="text-muted text-xs">
Show this to your barber at your next visit to redeem it.
{expiresLabel ? ` Expires ${expiresLabel}.` : ""}
</p>
</Card>
) : (
<Card className="space-y-1 p-5">
<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
</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)}%` }}
/>
</div>
</Card>
)}
<section>
<h2 className="text-muted mb-3 text-xs font-bold tracking-widest uppercase">History</h2>
{history.length === 0 ? (
<p className="text-muted text-sm">No loyalty activity yet.</p>
) : (
<Card className="divide-line divide-y px-5">
{history.map((t) => (
<div key={t.id} className="flex items-center justify-between py-3 text-sm">
<div>
<p className="font-medium">{t.type.replace(/_/g, " ").toLowerCase()}</p>
<p className="text-muted text-xs">
{t.createdAt.toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" })}
{t.reason ? ` · ${t.reason}` : ""}
</p>
</div>
<span className="font-bold">
{t.amount > 0 ? "+" : ""}
{t.amount}
</span>
</div>
))}
</Card>
)}
</section>
</main>
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
import type { Metadata } from "next";
import Link from "next/link";
import { auth } from "@/auth";
import { Button } from "@/components/ui/button";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { listInAppNotifications } from "@/services/notifications/notification.service";
import { NotificationList } from "@/features/notifications/components/notification-list";
export const metadata: Metadata = { title: "Notifications" };
export const dynamic = "force-dynamic";
export default async function CustomerNotificationsPage() {
const session = await auth();
if (!session?.user) {
return (
<div className="mx-auto max-w-2xl px-6 pt-12">
<h1 className="mb-6 text-2xl font-bold tracking-tight">Notifications</h1>
<p className="text-muted mb-4 text-sm">Sign in to see your notifications.</p>
<Button asChild>
<Link href="/sign-in?callbackUrl=/notifications">Sign In</Link>
</Button>
</div>
);
}
const notifications = await listInAppNotifications(prisma, { userId: session.user.id });
return (
<div className="pb-12">
<TopNav title="Notifications" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl px-6 py-6">
<NotificationList notifications={notifications} />
</main>
</div>
);
}
@@ -0,0 +1,41 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { TopNav } from "@/components/navigation/top-nav";
import { NotificationChannel, NotificationType } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db/prisma";
import { getNotificationPreferences } from "@/services/notifications/notification.service";
import { NotificationPreferencesForm } from "@/features/staff/components/notification-preferences-form";
import type { PreferenceCellState } from "@/features/staff/components/notification-preferences-form";
import { PushSubscribeToggle } from "@/features/notifications/components/push-subscribe-toggle";
import { saveCustomerNotificationPreferencesAction } from "@/features/notifications/actions/preferences";
export const metadata: Metadata = { title: "Notifications" };
export const dynamic = "force-dynamic";
const REMINDER_CHANNELS: NotificationChannel[] = [NotificationChannel.EMAIL, NotificationChannel.PUSH];
export default async function CustomerNotificationSettingsPage() {
const session = await auth();
if (!session?.user) redirect("/sign-in?callbackUrl=/settings/notifications");
const rows = await getNotificationPreferences(prisma, session.user.id);
const cells: PreferenceCellState[] = REMINDER_CHANNELS.map((channel) => ({
channel,
isEnabled:
rows.find((r) => r.type === NotificationType.BOOKING_REMINDER && r.channel === channel)?.isEnabled ?? true,
}));
return (
<div className="pb-12">
<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">
<h2 className="mb-3 text-sm font-bold">Push Notifications</h2>
<PushSubscribeToggle />
</div>
</main>
</div>
);
}
+20 -5
View File
@@ -1,5 +1,5 @@
import type { Metadata } from "next";
import { LogIn } from "lucide-react";
import { Bell, Gift, LogIn } from "lucide-react";
import Link from "next/link";
import { auth } from "@/auth";
import { Button } from "@/components/ui/button";
@@ -7,6 +7,7 @@ import { EmptyState } from "@/components/feedback/empty-state";
import { ProfileCard } from "@/components/ui/profile-card";
import { prisma } from "@/lib/db/prisma";
import { SignOutButton } from "@/features/auth/components/sign-out-button";
import { InstallAppButton } from "@/features/pwa/components/install-app-button";
export const metadata: Metadata = { title: "Settings" };
export const dynamic = "force-dynamic";
@@ -48,10 +49,24 @@ export default async function CustomerSettingsPage() {
meta={customerProfile?.email ?? session.user.email}
avatarUrl={session.user.image ?? undefined}
/>
<p className="text-muted text-sm">
Preferred barber, loyalty card, and notification preferences are designed in
<code> templates/10, 11, 35</code> and land in a later phase.
</p>
<div className="space-y-2">
<Link
href="/loyalty"
className="border-line flex items-center gap-3 rounded-xl border bg-white px-4 py-3 text-sm font-bold"
>
<Gift className="h-4 w-4" aria-hidden="true" /> Loyalty card
</Link>
<Link
href="/settings/notifications"
className="border-line flex items-center gap-3 rounded-xl border bg-white px-4 py-3 text-sm font-bold"
>
<Bell className="h-4 w-4" aria-hidden="true" /> Notifications
</Link>
</div>
<div className="border-line rounded-jumbo border bg-white p-5">
<p className="mb-3 text-sm font-bold">Get the app</p>
<InstallAppButton />
</div>
<SignOutButton />
</div>
);
+8
View File
@@ -0,0 +1,8 @@
"use client";
import { ErrorState } from "@/components/feedback/error-state";
/** Keeps (owner)/layout.tsx's sidebar/bottom nav visible on-screen even when a page below it throws — see src/app/error.tsx for the app-wide fallback this backs up. */
export default function OwnerError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
return <ErrorState onRetry={reset} />;
}
+10 -1
View File
@@ -3,7 +3,9 @@ import { auth } from "@/auth";
import { StaffShell } from "@/components/navigation/staff-shell";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db/prisma";
import { OWNER_NAV_ITEMS } from "@/lib/navigation/staff-nav-items";
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
/**
* Owner is a permission on a staff account, not a separate account type
@@ -21,8 +23,15 @@ export default async function OwnerLayout({ children }: { children: React.ReactN
redirect("/staff");
}
const unreadNotificationCount = await getUnreadNotificationCount(prisma, session.user.id);
return (
<StaffShell items={OWNER_NAV_ITEMS} footerLink={{ href: "/staff", label: "Barber Tools" }}>
<StaffShell
items={OWNER_NAV_ITEMS}
footerLink={{ href: "/staff", label: "Barber Tools" }}
notificationsHref="/owner/notifications"
unreadNotificationCount={unreadNotificationCount}
>
{children}
</StaffShell>
);
@@ -9,6 +9,7 @@ import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { getLoyaltySummary } from "@/services/loyalty/loyalty.service";
import { getAppointmentById } from "@/services/scheduling/appointment.service";
import { assertAppointmentInBusiness, StaffPermissionDeniedError } from "@/features/staff/authorization";
@@ -44,10 +45,7 @@ export default async function OwnerAppointmentDetailsPage({
const canView = await hasPermission(session.user.id, Permission.VIEW_ALL_STAFF_APPOINTMENTS);
if (!canView) redirect("/staff");
const owner = await prisma.staffProfile.findUnique({
where: { userId: session.user.id },
include: { business: { select: { id: true, timezone: true } } },
});
const owner = await getActingStaff();
if (!owner) redirect("/staff");
const { id } = await params;
+2 -4
View File
@@ -9,6 +9,7 @@ import { AppointmentCard } from "@/components/ui/appointment-card";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import {
calendarDateToUtcMidnight,
minutesFromMidnight,
@@ -60,10 +61,7 @@ export default async function OwnerCalendarPage({
const canView = await hasPermission(session.user.id, Permission.VIEW_ALL_STAFF_APPOINTMENTS);
if (!canView) redirect("/staff");
const owner = await prisma.staffProfile.findUnique({
where: { userId: session.user.id },
include: { business: { select: { id: true, name: true, timezone: true } } },
});
const owner = await getActingStaff();
if (!owner) redirect("/staff");
const { date: dateParam, view: viewParam, staffId: staffIdParam } = await searchParams;
@@ -0,0 +1,27 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { listInAppNotifications } from "@/services/notifications/notification.service";
import { NotificationList } from "@/features/notifications/components/notification-list";
export const metadata: Metadata = { title: "Notifications" };
export const dynamic = "force-dynamic";
/** Access to this route is already gated by (owner)/layout.tsx's MANAGE_STAFF check — this page only fetches this owner's own notifications, same as the staff/customer variants. */
export default async function OwnerNotificationsPage() {
const staff = await getActingStaff();
if (!staff) redirect("/home");
const notifications = await listInAppNotifications(prisma, { userId: staff.userId });
return (
<div className="pb-12">
<TopNav title="Notifications" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl px-6 py-6">
<NotificationList notifications={notifications} />
</main>
</div>
);
}
+8 -4
View File
@@ -17,6 +17,7 @@ import { Card } from "@/components/ui/card";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { todayIn } from "@/lib/time/business-time";
import { getDashboardSummary } from "@/services/reporting/owner-reporting.service";
@@ -43,10 +44,7 @@ export default async function OwnerDashboardPage() {
const canView = await hasPermission(session.user.id, Permission.VIEW_REPORTING);
if (!canView) redirect("/staff");
const staff = await prisma.staffProfile.findUnique({
where: { userId: session.user.id },
include: { business: { select: { id: true, name: true, timezone: true } } },
});
const staff = await getActingStaff();
if (!staff) redirect("/staff");
const today = todayIn(staff.business.timezone);
@@ -171,6 +169,12 @@ export default async function OwnerDashboardPage() {
</Card>
<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"
>
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"
+201
View File
@@ -0,0 +1,201 @@
import type { Metadata } from "next";
import Link from "next/link";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { Card } from "@/components/ui/card";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { addDays, endOfBusinessDay, startOfBusinessDay, todayIn } from "@/lib/time/business-time";
import type { CalendarDate } from "@/lib/time/business-time";
import {
getAppointmentReport,
getCustomerReport,
getLoyaltyReport,
getNotificationReport,
getServiceReport,
getStaffReport,
} from "@/services/reporting/reports.service";
export const metadata: Metadata = { title: "Reports" };
export const dynamic = "force-dynamic";
const PRESETS = [
{ label: "7 days", days: 7 },
{ label: "30 days", days: 30 },
{ label: "90 days", days: 90 },
] as const;
function StatTile({ label, value, hint }: { label: string; value: string | number; hint?: string }) {
return (
<Card className="p-5">
<p className="text-muted mb-2 text-[10px] font-bold tracking-widest uppercase">{label}</p>
<p className="text-2xl font-bold">{value}</p>
{hint && <p className="text-muted mt-1 text-xs">{hint}</p>}
</Card>
);
}
export default async function OwnerReportsPage({
searchParams,
}: {
searchParams: Promise<{ from?: string; to?: string }>;
}) {
const session = await auth();
if (!session?.user) redirect("/sign-in?callbackUrl=/owner/reports");
const canView = await hasPermission(session.user.id, Permission.VIEW_REPORTING);
if (!canView) redirect("/staff");
const staff = await getActingStaff();
if (!staff) redirect("/staff");
const tz = staff.business.timezone;
const today = todayIn(tz);
const { from: fromParam, to: toParam } = await searchParams;
const toDate: CalendarDate = toParam ?? today;
const fromDate: CalendarDate = fromParam ?? addDays(toDate, -29);
const range = { from: startOfBusinessDay(fromDate, tz), to: endOfBusinessDay(toDate, tz) };
const [appointmentReport, serviceReport, staffReport, customerReport, loyaltyReport, notificationReport] =
await Promise.all([
getAppointmentReport(prisma, { businessId: staff.businessId, range }),
getServiceReport(prisma, { businessId: staff.businessId, range }),
getStaffReport(prisma, { businessId: staff.businessId, range }),
getCustomerReport(prisma, { businessId: staff.businessId, range }),
getLoyaltyReport(prisma, { businessId: staff.businessId, range }),
getNotificationReport(prisma, { businessId: staff.businessId, range }),
]);
return (
<div className="space-y-6 px-6 pt-12 pb-12">
<div>
<h1 className="text-2xl font-bold tracking-tight">Reports</h1>
<p className="text-muted text-sm">
{fromDate} to {toDate}
</p>
</div>
<div className="flex flex-wrap gap-2">
{PRESETS.map((preset) => (
<Link
key={preset.days}
href={`/owner/reports?from=${addDays(today, -(preset.days - 1))}&to=${today}`}
className="border-line rounded-full border bg-white px-4 py-2 text-xs font-bold"
>
Last {preset.label}
</Link>
))}
</div>
<section className="space-y-3">
<h2 className="text-muted text-xs font-bold tracking-widest uppercase">Appointments</h2>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatTile label="Total" value={appointmentReport.totalAppointments} />
<StatTile label="Completed" value={appointmentReport.completed} hint={`${appointmentReport.completionRate}% of total`} />
<StatTile label="Cancelled" value={appointmentReport.cancelled} hint={`${appointmentReport.cancellationRate}% of total`} />
<StatTile label="No-shows" value={appointmentReport.noShow} hint={`${appointmentReport.noShowRate}% of total`} />
</div>
<div className="grid grid-cols-3 gap-3">
<StatTile label="Gross revenue" value={`£${appointmentReport.grossRevenue}`} />
<StatTile label="Discounts given" value={`£${appointmentReport.discountsGiven}`} />
<StatTile label="Net revenue" value={`£${appointmentReport.netRevenue}`} />
</div>
</section>
<section className="space-y-3">
<h2 className="text-muted text-xs font-bold tracking-widest uppercase">Services</h2>
<Card className="divide-line divide-y px-5">
{serviceReport.length === 0 ? (
<p className="text-muted py-4 text-sm">No completed appointments in this range.</p>
) : (
serviceReport.map((s) => (
<div key={s.serviceId} className="flex items-center justify-between py-3 text-sm">
<div>
<p className="font-bold">{s.name}</p>
<p className="text-muted text-xs">{s.timesCompleted} completed</p>
</div>
<span className="font-bold">£{s.revenue}</span>
</div>
))
)}
</Card>
</section>
<section className="space-y-3">
<h2 className="text-muted text-xs font-bold tracking-widest uppercase">Staff</h2>
<Card className="divide-line divide-y px-5">
{staffReport.length === 0 ? (
<p className="text-muted py-4 text-sm">No appointments in this range.</p>
) : (
staffReport.map((s) => (
<div key={s.staffId} className="flex items-center justify-between py-3 text-sm">
<div>
<p className="font-bold">{s.name}</p>
<p className="text-muted text-xs">
{s.completed} completed · {s.cancelled} cancelled · {s.noShow} no-show
</p>
</div>
<span className="font-bold">£{s.revenue}</span>
</div>
))
)}
</Card>
</section>
<section className="space-y-3">
<h2 className="text-muted text-xs font-bold tracking-widest uppercase">Customers</h2>
<div className="grid grid-cols-2 gap-3">
<StatTile label="New customers" value={customerReport.newCustomers} />
<StatTile label="Returning customers" value={customerReport.returningCustomers} />
</div>
{customerReport.topCustomers.length > 0 && (
<Card className="divide-line divide-y px-5">
{customerReport.topCustomers.map((c) => (
<div key={c.customerId} className="flex items-center justify-between py-3 text-sm">
<div>
<p className="font-bold">{c.name}</p>
<p className="text-muted text-xs">{c.visits} visit{c.visits === 1 ? "" : "s"}</p>
</div>
<span className="font-bold">£{c.spend}</span>
</div>
))}
</Card>
)}
</section>
<section className="space-y-3">
<h2 className="text-muted text-xs font-bold tracking-widest uppercase">Loyalty</h2>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatTile label="Stamps issued" value={loyaltyReport.stampsIssued} />
<StatTile label="Stamps removed" value={loyaltyReport.stampsRemoved} />
<StatTile label="Rewards earned" value={loyaltyReport.rewardsEarned} />
<StatTile label="Rewards redeemed" value={loyaltyReport.rewardsRedeemed} />
</div>
{loyaltyReport.rewardsExpired > 0 && <StatTile label="Rewards expired" value={loyaltyReport.rewardsExpired} />}
</section>
<section className="space-y-3">
<h2 className="text-muted text-xs font-bold tracking-widest uppercase">Notifications</h2>
<div className="grid grid-cols-2 gap-3">
<StatTile label="Sent" value={notificationReport.totalSent} />
<StatTile label="Failed" value={notificationReport.totalFailed} />
</div>
{notificationReport.byChannel.length > 0 && (
<Card className="divide-line divide-y px-5">
{notificationReport.byChannel.map((c) => (
<div key={c.channel} className="flex items-center justify-between py-3 text-sm">
<span className="font-bold">{c.channel}</span>
<span className="text-muted">
{c.sent} sent{c.failed > 0 ? ` · ${c.failed} failed` : ""}
</span>
</div>
))}
</Card>
)}
</section>
</div>
);
}
+2 -1
View File
@@ -4,6 +4,7 @@ import { auth } from "@/auth";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { listCategories } from "@/services/catalogue/service-catalogue.service";
import { CatalogueManager } from "@/features/owner/components/catalogue-manager";
@@ -17,7 +18,7 @@ export default async function OwnerServicesPage() {
const canManage = await hasPermission(session.user.id, Permission.MANAGE_SERVICES);
if (!canManage) redirect("/staff");
const owner = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
const owner = await getActingStaff();
if (!owner) redirect("/staff");
const categories = await listCategories(prisma, owner.businessId);
@@ -5,6 +5,7 @@ import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { getBookingRule } from "@/services/scheduling/booking-rule.service";
import { BookingRulesForm } from "@/features/owner/components/booking-rules-form";
@@ -18,7 +19,7 @@ export default async function OwnerBookingRulesPage() {
const canManage = await hasPermission(session.user.id, Permission.MANAGE_BOOKING_RULES);
if (!canManage) redirect("/staff");
const owner = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
const owner = await getActingStaff();
if (!owner) redirect("/staff");
const rule = await getBookingRule(prisma, owner.businessId);
@@ -5,6 +5,7 @@ import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { getBusinessProfile } from "@/services/business/business.service";
import { BusinessDetailsForm } from "@/features/owner/components/business-details-form";
@@ -18,7 +19,7 @@ export default async function OwnerBusinessDetailsPage() {
const canManage = await hasPermission(session.user.id, Permission.MANAGE_BUSINESS_PROFILE);
if (!canManage) redirect("/staff");
const staff = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
const staff = await getActingStaff();
if (!staff) redirect("/staff");
const business = await getBusinessProfile(prisma, staff.businessId);
@@ -5,6 +5,7 @@ import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { getOpeningHours } from "@/services/business/business.service";
import { OpeningHoursForm } from "@/features/owner/components/opening-hours-form";
@@ -18,7 +19,7 @@ export default async function OwnerOpeningHoursPage() {
const canManage = await hasPermission(session.user.id, Permission.MANAGE_BUSINESS_PROFILE);
if (!canManage) redirect("/staff");
const staff = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
const staff = await getActingStaff();
if (!staff) redirect("/staff");
const hours = await getOpeningHours(prisma, staff.businessId);
@@ -5,6 +5,7 @@ import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { getProgrammeConfig } from "@/services/loyalty/loyalty.service";
import { LoyaltyConfigForm } from "@/features/owner/components/loyalty-config-form";
@@ -18,7 +19,7 @@ export default async function OwnerLoyaltyConfigPage() {
const canManage = await hasPermission(session.user.id, Permission.MANAGE_LOYALTY_PROGRAMME);
if (!canManage) redirect("/staff");
const owner = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
const owner = await getActingStaff();
if (!owner) redirect("/staff");
const [programme, services, staff] = await Promise.all([
+6
View File
@@ -7,6 +7,7 @@ import { ProfileCard } from "@/components/ui/profile-card";
import { SignOutButton } from "@/features/auth/components/sign-out-button";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { InstallAppButton } from "@/features/pwa/components/install-app-button";
export const metadata: Metadata = { title: "Owner Settings" };
export const dynamic = "force-dynamic";
@@ -50,6 +51,11 @@ export default async function OwnerSettingsPage() {
))}
</ul>
<div className="rounded-jumbo border-line border bg-white p-5">
<p className="mb-3 text-sm font-bold">Get the app</p>
<InstallAppButton />
</div>
<SignOutButton />
</div>
);
@@ -0,0 +1,53 @@
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { auth } from "@/auth";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { listBreaks } from "@/services/staff/staff.service";
import { BreaksList } from "@/features/staff/components/breaks-list";
import { createOwnerBreakActionSet } from "@/features/owner/actions/break-action-set";
export const metadata: Metadata = { title: "Staff Breaks" };
export const dynamic = "force-dynamic";
export default async function OwnerStaffBreaksPage({ params }: { params: Promise<{ id: string }> }) {
const session = await auth();
if (!session?.user) redirect("/sign-in");
const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
if (!canManage) redirect("/staff");
const owner = await getActingStaff();
if (!owner) redirect("/staff");
const { id } = await params;
const target = await prisma.staffProfile.findFirst({
where: { id, businessId: owner.businessId },
include: { user: { select: { name: true } } },
});
if (!target) notFound();
const breaks = await listBreaks(prisma, id);
return (
<div className="pb-12">
<TopNav title={`${target.user.name ?? "Staff"} — Breaks`} onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl px-6 py-6">
<BreaksList
breaks={breaks.map((b) => ({
id: b.id,
label: b.label,
startMinutes: b.startMinutes,
endMinutes: b.endMinutes,
days: b.days.map((d) => d.dayOfWeek),
}))}
actions={createOwnerBreakActionSet(id)}
appointmentHrefBase="/owner/appointments"
/>
</main>
</div>
);
}
+10 -2
View File
@@ -5,6 +5,7 @@ import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission, Weekday } from "@/generated/prisma/enums";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { getWorkingHours } from "@/services/staff/staff.service";
import { WorkingHoursForm } from "@/features/staff/components/working-hours-form";
import { updateStaffWorkingHoursAction } from "@/features/owner/actions/staff";
@@ -35,7 +36,7 @@ export default async function OwnerStaffWorkingHoursPage({ params }: { params: P
const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
if (!canManage) redirect("/staff");
const owner = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
const owner = await getActingStaff();
if (!owner) redirect("/staff");
const { id } = await params;
@@ -65,10 +66,17 @@ export default async function OwnerStaffWorkingHoursPage({ params }: { params: P
<div className="pb-12">
<TopNav title={`${target.user.name ?? "Staff"} — Working Hours`} onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl px-6 py-6">
{/* .bind(null, id), not an arrow-function wrapper — a Client Component can only
receive an actual Server Action reference as a prop. An inline arrow closure
here throws "Event handlers cannot be passed to Client Component props" at
render time (found empirically in Phase 7 — this page never actually worked
until this fix; see docs/phase-7-summary.md and
src/features/owner/actions/break-action-set.ts for the same issue). */}
<WorkingHoursForm
initialRows={workingHours}
businessHoursLabel={businessHoursLabel}
onSave={(days) => updateStaffWorkingHoursAction(id, days)}
onSave={updateStaffWorkingHoursAction.bind(null, id)}
appointmentHrefBase="/owner/appointments"
/>
</main>
</div>
+10 -3
View File
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { Mail, Phone } from "lucide-react";
import Image from "next/image";
import { notFound, redirect } from "next/navigation";
import { auth } from "@/auth";
import { TopNav } from "@/components/navigation/top-nav";
@@ -7,6 +8,7 @@ import { Card } from "@/components/ui/card";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { getStaffDetailById, listRolesForBusiness } from "@/services/staff/staff.service";
import { StaffDetailPanel } from "@/features/owner/components/staff-detail-panel";
@@ -20,7 +22,7 @@ export default async function OwnerStaffDetailPage({ params }: { params: Promise
const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
if (!canManage) redirect("/staff");
const owner = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
const owner = await getActingStaff();
if (!owner) redirect("/staff");
const { id } = await params;
@@ -38,8 +40,13 @@ export default async function OwnerStaffDetailPage({ params }: { params: Promise
<div className="space-y-4">
<Card className="flex flex-col items-center p-6 text-center">
{target.user.image ? (
// eslint-disable-next-line @next/next/no-img-element -- external avatar URL, no next/image domain config for it
<img src={target.user.image} alt="" className="border-stone mb-4 h-24 w-24 rounded-full border-4 object-cover" />
<Image
src={target.user.image}
alt=""
width={96}
height={96}
className="border-stone mb-4 h-24 w-24 rounded-full border-4 object-cover"
/>
) : (
<div className="bg-stone text-muted mb-4 flex h-24 w-24 items-center justify-center rounded-full text-2xl font-bold">
{(target.user.name ?? "?").charAt(0)}
@@ -0,0 +1,61 @@
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { auth } from "@/auth";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { listTimeOff } from "@/services/staff/staff.service";
import { TimeOffList } from "@/features/staff/components/time-off-list";
import { createOwnerTimeOffActionSet } from "@/features/owner/actions/time-off-action-set";
export const metadata: Metadata = { title: "Staff Time Off" };
export const dynamic = "force-dynamic";
function toCalendarDate(date: Date): string {
return date.toISOString().slice(0, 10);
}
export default async function OwnerStaffTimeOffPage({ params }: { params: Promise<{ id: string }> }) {
const session = await auth();
if (!session?.user) redirect("/sign-in");
const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
if (!canManage) redirect("/staff");
const owner = await getActingStaff();
if (!owner) redirect("/staff");
const { id } = await params;
const target = await prisma.staffProfile.findFirst({
where: { id, businessId: owner.businessId },
include: { user: { select: { name: true } } },
});
if (!target) notFound();
const { upcoming, past } = await listTimeOff(prisma, id);
// Whole-day rows only — partial-day "Block Time" entries are managed from the owner calendar (see owner-block-time-button.tsx).
const wholeDay = (rows: typeof upcoming) => rows.filter((r) => r.startMinutes === null && r.endMinutes === null);
const toEditable = (rows: typeof upcoming) =>
wholeDay(rows).map((t) => ({
id: t.id,
startDate: toCalendarDate(t.startDate),
endDate: toCalendarDate(t.endDate),
reason: t.reason,
}));
return (
<div className="pb-12">
<TopNav title={`${target.user.name ?? "Staff"} — Time Off`} onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl px-6 py-6">
<TimeOffList
upcoming={toEditable(upcoming)}
past={toEditable(past)}
actions={createOwnerTimeOffActionSet(id)}
appointmentHrefBase="/owner/appointments"
/>
</main>
</div>
);
}
+2 -1
View File
@@ -5,6 +5,7 @@ import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { listRolesForBusiness } from "@/services/staff/staff.service";
import { AddStaffForm } from "@/features/owner/components/add-staff-form";
@@ -18,7 +19,7 @@ export default async function OwnerAddStaffPage() {
const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
if (!canManage) redirect("/staff");
const owner = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
const owner = await getActingStaff();
if (!owner) redirect("/staff");
const [roles, services] = await Promise.all([
+2 -1
View File
@@ -6,6 +6,7 @@ import { auth } from "@/auth";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { listStaffForBusiness } from "@/services/staff/staff.service";
import { cn } from "@/lib/utils/cn";
@@ -23,7 +24,7 @@ export default async function OwnerTeamPage({
const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
if (!canManage) redirect("/staff");
const owner = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
const owner = await getActingStaff();
if (!owner) redirect("/staff");
const { q = "" } = await searchParams;
+8
View File
@@ -0,0 +1,8 @@
"use client";
import { ErrorState } from "@/components/feedback/error-state";
/** Keeps (staff)/layout.tsx's sidebar/bottom nav visible on-screen even when a page below it throws — see src/app/error.tsx for the app-wide fallback this backs up. */
export default function StaffError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
return <ErrorState onRetry={reset} />;
}
+8 -1
View File
@@ -2,6 +2,7 @@ import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { StaffShell } from "@/components/navigation/staff-shell";
import { prisma } from "@/lib/db/prisma";
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
export default async function StaffLayout({ children }: { children: React.ReactNode }) {
const session = await auth();
@@ -18,5 +19,11 @@ export default async function StaffLayout({ children }: { children: React.ReactN
redirect("/home");
}
return <StaffShell>{children}</StaffShell>;
const unreadNotificationCount = await getUnreadNotificationCount(prisma, session.user.id);
return (
<StaffShell notificationsHref="/staff/notifications" unreadNotificationCount={unreadNotificationCount}>
{children}
</StaffShell>
);
}
@@ -163,14 +163,26 @@ export default async function StaffAppointmentDetailsPage({
</div>
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Duration / Price
Duration
</span>
<p className="font-bold">
{totalDuration} min · £{totalPrice.toFixed(2)}
{discount > 0 && (
<span className="text-accent-loyalty"> (-£{discount.toFixed(2)} reward)</span>
)}
</p>
<p className="font-bold">{totalDuration} min</p>
</div>
</div>
<div className="border-line border-t pt-4 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted">Subtotal</span>
<span className="font-bold">£{totalPrice.toFixed(2)}</span>
</div>
{discount > 0 && (
<div className="text-accent-loyalty flex items-center justify-between">
<span>Loyalty reward</span>
<span className="font-bold">-£{discount.toFixed(2)}</span>
</div>
)}
<div className="border-line mt-1 flex items-center justify-between border-t pt-1">
<span className="font-bold">Total</span>
<span className="font-bold">£{(totalPrice - discount).toFixed(2)}</span>
</div>
</div>
+1 -1
View File
@@ -216,7 +216,7 @@ export default async function StaffCustomerDetailsPage({
<li key={t.id} className="text-muted text-xs">
{t.createdAt.toLocaleDateString("en-GB", { day: "numeric", month: "short" })}{" "}
— {t.amount > 0 ? "+" : ""}
{t.amount} stamp{Math.abs(t.amount) === 1 ? "" : "s"}
{t.amount} stamp{Math.abs(t.amount) === 1 ? "" : "s"} — {t.type.replace(/_/g, " ").toLowerCase()}
{t.reason ? ` (${t.reason})` : ""}
{t.authoredByStaff?.user.name ? ` · ${t.authoredByStaff.user.name}` : ""}
</li>
+17 -3
View File
@@ -26,11 +26,25 @@ export default async function StaffClientsPage({
// Scoped via the appointment relation, not "every CustomerProfile row" —
// a real business boundary even though this is a single-business v1 app
// (see docs/phase-5-summary.md).
// (see docs/phase-5-summary.md). `select`-scoped rather than the full
// row + full appointment `include` this used to fetch — the card only
// ever reads status/startAt off each appointment, never the service
// lines/notes/discount that a full include would also pull down for
// every appointment of every client on every page view (see the Phase 7
// performance pass).
const customers = await prisma.customerProfile.findMany({
where: { appointments: { some: { businessId: staff.businessId } } },
include: {
appointments: { where: { businessId: staff.businessId }, orderBy: { startAt: "desc" } },
select: {
id: true,
firstName: true,
lastName: true,
email: true,
phone: true,
appointments: {
where: { businessId: staff.businessId },
orderBy: { startAt: "desc" },
select: { status: true, startAt: true },
},
},
});
@@ -0,0 +1,26 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { listInAppNotifications } from "@/services/notifications/notification.service";
import { NotificationList } from "@/features/notifications/components/notification-list";
export const metadata: Metadata = { title: "Notifications" };
export const dynamic = "force-dynamic";
export default async function StaffNotificationsPage() {
const staff = await getActingStaff();
if (!staff) redirect("/home");
const notifications = await listInAppNotifications(prisma, { userId: staff.userId });
return (
<div className="pb-12">
<TopNav title="Notifications" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl px-6 py-6">
<NotificationList notifications={notifications} />
</main>
</div>
);
}
@@ -1,25 +1,41 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { TopNav } from "@/components/navigation/top-nav";
import { NotificationChannel, NotificationType } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db/prisma";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { getNotificationPreferences } from "@/services/staff/staff.service";
import { getNotificationPreferences } from "@/services/notifications/notification.service";
import { NotificationPreferencesForm } from "@/features/staff/components/notification-preferences-form";
import type { PreferenceCellState } from "@/features/staff/components/notification-preferences-form";
import { PushSubscribeToggle } from "@/features/notifications/components/push-subscribe-toggle";
import { saveNotificationPreferencesAction } from "@/features/staff/actions/profile";
export const metadata: Metadata = { title: "Notifications" };
export const dynamic = "force-dynamic";
const REMINDER_CHANNELS: NotificationChannel[] = [NotificationChannel.EMAIL, NotificationChannel.PUSH];
export default async function StaffNotificationsPage() {
const staff = await getActingStaff();
if (!staff) redirect("/home");
const cells = await getNotificationPreferences(prisma, staff.userId);
const rows = await getNotificationPreferences(prisma, staff.userId);
const cells: PreferenceCellState[] = REMINDER_CHANNELS.map((channel) => ({
channel,
isEnabled:
rows.find((r) => r.type === NotificationType.STAFF_APPOINTMENT_REMINDER && r.channel === channel)
?.isEnabled ?? true,
}));
return (
<div className="pb-12">
<TopNav title="Notifications" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl px-6 py-6">
<NotificationPreferencesForm initial={cells} />
<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">
<h2 className="mb-3 text-sm font-bold">Push Notifications</h2>
<PushSubscribeToggle />
</div>
</main>
</div>
);
+6
View File
@@ -6,6 +6,7 @@ import { ProfileCard } from "@/components/ui/profile-card";
import { SignOutButton } from "@/features/auth/components/sign-out-button";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { InstallAppButton } from "@/features/pwa/components/install-app-button";
const SETTINGS_LINKS = [
{ href: "/staff/settings/working-hours", label: "Working Hours", icon: Clock },
@@ -52,6 +53,11 @@ export default async function StaffSettingsPage() {
))}
</ul>
<div className="rounded-jumbo border border-line bg-white p-5">
<p className="mb-3 text-sm font-bold">Get the app</p>
<InstallAppButton />
</div>
<SignOutButton />
</div>
);
@@ -13,8 +13,9 @@ import { getAppointmentById } from "@/services/scheduling/appointment.service";
* response"). Still calls straight into AppointmentService/ics.ts —
* no booking logic lives here.
*/
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const token = new URL(request.url).searchParams.get("token") ?? undefined;
const appointment = await getAppointmentById(prisma, id);
if (!appointment) {
@@ -23,7 +24,7 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
const session = await auth();
try {
assertCanAccessAppointment(appointment, session?.user?.id);
await assertCanAccessAppointment(prisma, appointment, { sessionUserId: session?.user?.id, guestToken: token });
} catch (error) {
if (error instanceof PermissionDeniedError) {
return NextResponse.json({ error: error.message }, { status: 403 });
@@ -0,0 +1,58 @@
import { timingSafeEqual } from "node:crypto";
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db/prisma";
import { expireOverdueRewards } from "@/services/loyalty/loyalty.service";
import { getEmailProvider, getPushProvider } from "@/services/notifications/providers";
import { processDueNotificationDeliveries } from "@/services/notifications/reminder-worker";
/** Same constant-time convention as src/lib/booking-draft/sign.ts and guest-access.service.ts — a plain `!==` on a secret leaks its length/prefix via timing. */
function secretsMatch(provided: string, expected: string): boolean {
const providedBuffer = Buffer.from(provided);
const expectedBuffer = Buffer.from(expected);
if (providedBuffer.length !== expectedBuffer.length) return false;
return timingSafeEqual(providedBuffer, expectedBuffer);
}
/**
* The external trigger point for the background notification processor —
* see reminder-worker.ts for why this exists instead of an in-process
* timer. Not a page a person visits; a scheduler (e.g. a hosting
* provider's cron/scheduled-function feature, or any external cron
* hitting this URL with the right header) is expected to POST here on a
* fixed interval.
*
* Recommended frequency: every 1–2 minutes. Reminders are scheduled to
* the minute (see reminder-scheduling.ts) but this app has no promise of
* sub-minute delivery — a 1–2 minute poll interval is a reasonable
* trade-off between timeliness and load. Every call is safe to run
* concurrently with another still-in-flight call (or with itself on a
* retry) — see processDueNotificationDeliveries' per-row conditional
* claim; overlapping calls just mean some rows get skippedAlreadyClaimed,
* not double-sent.
*
* Auth: a shared-secret bearer token (CRON_SECRET), not a user session —
* this is a machine-to-machine endpoint. Returns 503 (not 401) if
* CRON_SECRET was never configured at all, so a misconfigured deployment
* fails loudly in monitoring rather than silently accepting an empty
* expected secret.
*/
export async function POST(request: Request) {
const secret = process.env.CRON_SECRET;
if (!secret) {
return NextResponse.json({ error: "CRON_SECRET is not configured on this deployment." }, { status: 503 });
}
const provided = request.headers.get("authorization");
if (!provided || !secretsMatch(provided, `Bearer ${secret}`)) {
return NextResponse.json({ error: "Unauthorized." }, { status: 401 });
}
const result = await processDueNotificationDeliveries(prisma, {
emailProvider: getEmailProvider(),
pushProvider: getPushProvider(),
});
// Piggybacks the loyalty reward expiry sweep on the same scheduled trigger
// rather than standing up a second cron endpoint for one small job.
await expireOverdueRewards(prisma);
return NextResponse.json(result);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

@@ -9,5 +9,9 @@
* the bottom navigation" allowance.
*/
export default function RescheduleLayout({ children }: { children: React.ReactNode }) {
return <div className="min-h-screen bg-stone">{children}</div>;
return (
<div id="main-content" className="min-h-screen bg-stone">
{children}
</div>
);
}
+12 -4
View File
@@ -15,21 +15,28 @@ export const dynamic = "force-dynamic";
const NON_RESCHEDULABLE = new Set(["COMPLETED", "CANCELLED", "NO_SHOW", "IN_PROGRESS"]);
export default async function RescheduleAppointmentPage({ params }: { params: Promise<{ id: string }> }) {
export default async function RescheduleAppointmentPage({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ token?: string }>;
}) {
const { id } = await params;
const { token } = await searchParams;
const appointment = await getAppointmentById(prisma, id);
if (!appointment) notFound();
const session = await auth();
try {
assertCanAccessAppointment(appointment, session?.user?.id);
await assertCanAccessAppointment(prisma, appointment, { sessionUserId: session?.user?.id, guestToken: token });
} catch (error) {
if (error instanceof PermissionDeniedError) notFound();
throw error;
}
if (NON_RESCHEDULABLE.has(appointment.status)) {
redirect(`/appointments/${id}`);
redirect(`/appointments/${id}${token ? `?token=${token}` : ""}`);
}
const business = await getPrimaryBusiness();
@@ -40,9 +47,10 @@ export default async function RescheduleAppointmentPage({ params }: { params: Pr
appointmentId: id,
staffId: selection.staffId,
startAt: selection.startAt,
guestToken: token,
});
if (result.success) {
redirect(`/appointments/${id}`);
redirect(`/appointments/${id}${token ? `?token=${token}` : ""}`);
}
return result;
}
+16 -9
View File
@@ -26,10 +26,10 @@ const STATUS_MAP: Record<string, AppointmentStatus> = {
};
export default async function BookConfirmationPage() {
const appointmentId = await readLastBookingReference();
if (!appointmentId) redirect("/home");
const reference = await readLastBookingReference();
if (!reference) redirect("/home");
const appointment = await getAppointmentById(prisma, appointmentId);
const appointment = await getAppointmentById(prisma, reference.appointmentId);
if (!appointment) redirect("/home");
const business = await getPrimaryBusiness();
@@ -81,7 +81,7 @@ export default async function BookConfirmationPage() {
</div>
<a
href={`/api/appointments/${appointment.id}/calendar.ics`}
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"
>
<CalendarPlus className="h-4 w-4 text-accent" aria-hidden="true" /> Add to Calendar
@@ -89,11 +89,18 @@ export default async function BookConfirmationPage() {
</Card>
<div className="mt-8 w-full max-w-md space-y-3">
{session?.user ? (
<Button asChild variant="secondary" className="w-full">
<Link href="/appointments">View Appointment</Link>
</Button>
) : (
<Button asChild variant="secondary" className="w-full">
<Link
href={
session?.user
? "/appointments"
: `/appointments/${appointment.id}${reference.guestAccessToken ? `?token=${reference.guestAccessToken}` : ""}`
}
>
View Appointment
</Link>
</Button>
{!session?.user && (
<Card className="space-y-3 p-5 text-center">
<p className="text-sm font-bold">Want to manage this booking later?</p>
<p className="text-xs text-muted">
+5 -1
View File
@@ -8,5 +8,9 @@
* improves focus").
*/
export default function BookLayout({ children }: { children: React.ReactNode }) {
return <div className="min-h-screen bg-stone">{children}</div>;
return (
<div id="main-content" className="min-h-screen bg-stone">
{children}
</div>
);
}
+14
View File
@@ -7,6 +7,7 @@ import { Card } from "@/components/ui/card";
import { readDraft } from "@/lib/booking-draft/cookie";
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
import { prisma } from "@/lib/db/prisma";
import { getLoyaltySummary } from "@/services/loyalty/loyalty.service";
import { ConfirmBookingButton } from "@/features/booking/components/confirm-booking-button";
export const metadata: Metadata = { title: "Review Booking" };
@@ -41,6 +42,10 @@ export default async function BookReviewPage() {
const startAt = new Date(draft.startAt);
const endAt = new Date(startAt.getTime() + totalDuration * 60_000);
const loyalty = customerProfile
? await getLoyaltySummary(prisma, { customerId: customerProfile.id, businessId: business.id })
: null;
const customerName = draft.guest
? `${draft.guest.firstName} ${draft.guest.lastName}`
: `${customerProfile?.firstName ?? session?.user?.name ?? ""} ${customerProfile?.lastName ?? ""}`.trim();
@@ -169,6 +174,15 @@ export default async function BookReviewPage() {
</p>
</div>
{loyalty?.availableReward && (
<div className="border-line bg-accent-loyalty/10 rounded-lg border-t p-4">
<p className="text-sm font-bold">You have a reward available</p>
<p className="text-muted text-xs">
Mention it to your barber at check-in — {loyalty.availableReward.label}.
</p>
</div>
)}
{draft.notes && (
<div className="border-line border-t pt-6">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
+23
View File
@@ -0,0 +1,23 @@
"use client";
import { ErrorState } from "@/components/feedback/error-state";
/**
* Root error boundary — catches anything that escapes a more specific
* one (see (customer)/error.tsx, (staff)/error.tsx, (owner)/error.tsx),
* so a crash anywhere in the app shows this app-styled fallback instead
* of Next's default error page. `reset` re-renders the segment that
* threw, which is enough to recover from a transient failure (a flaky
* fetch, a stale cache) without a full page reload.
*/
export default function RootError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
return (
<html lang="en">
<body className="bg-stone font-sans">
<div id="main-content">
<ErrorState onRetry={reset} />
</div>
</body>
</html>
);
}
+24 -1
View File
@@ -13,10 +13,22 @@
--color-ink: #111b15;
--color-stone: #f4f6f4;
--color-surface: #ffffff;
--color-muted: #6c7870;
/* Darkened from the templates' #6c7870 — that value was ~4.2:1 on
--color-stone (this app's default body background), just under WCAG
AA's 4.5:1 for normal text, and text-muted is used 300+ times across
the app for secondary/caption copy. See docs/implementation-plan.md
§8 and the Phase 7 accessibility pass. */
--color-muted: #5c6961;
--color-line: #e1e7e2;
--color-accent: #10b981;
--color-accent-loyalty: #f59e0b;
/* Darker text-only variants — --color-accent/--color-accent-loyalty are
correct as-is for backgrounds (buttons, badges, where a light/white
foreground sits on top) but fail WCAG AA (~2.5:1, ~2.2:1) when used
directly as text on a white/stone background, which several
"Saved." confirmation messages and status labels do. */
--color-accent-foreground: #047857;
--color-accent-loyalty-foreground: #92400e;
/* Generic UI-state colors — destructive actions, form-validation errors,
and warning notices. Deliberately separate variables from the
@@ -35,6 +47,17 @@
--color-status-cancelled: #ef4444;
--color-status-completed: #6c7870;
/* Darker text-only variants of the status colors above — the base
colors are correct for backgrounds/dots/borders (where they're paired
with a light tint, not read as text), but at full saturation several
of them fail WCAG AA (~2.5:1) as *text* on the near-white tinted pill
background StatusPill uses them on. Only status-progress's base color
already passes AA as text, so it has no separate foreground variant.
See docs/implementation-plan.md §8 and the Phase 7 accessibility pass. */
--color-status-confirmed-foreground: #047857;
--color-status-pending-foreground: #92400e;
--color-status-cancelled-foreground: #b91c1c;
--radius-jumbo: 1.5rem;
--radius-tile: 1rem;
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+15
View File
@@ -16,6 +16,11 @@ export const metadata: Metadata = {
},
description:
"Book your next barber appointment in seconds — services, staff and loyalty rewards in one place.",
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: "NextChair",
},
};
export const viewport: Viewport = {
@@ -24,6 +29,7 @@ export const viewport: Viewport = {
maximumScale: 1,
userScalable: false,
viewportFit: "cover",
themeColor: "#111b15",
};
export default function RootLayout({
@@ -34,6 +40,15 @@ export default function RootLayout({
return (
<html lang="en" className={inter.variable}>
<body className="font-sans">
{/* Visible only when focused — lets a keyboard user jump past the
bottom nav / sidebar chrome that's repeated on every page,
straight to the #main-content landmark each layout renders. */}
<a
href="#main-content"
className="bg-ink sr-only z-50 rounded-full px-4 py-2 text-sm font-bold text-white focus:not-sr-only focus:fixed focus:top-4 focus:left-4"
>
Skip to main content
</a>
<Providers>{children}</Providers>
</body>
</html>
+28
View File
@@ -0,0 +1,28 @@
import type { MetadataRoute } from "next";
/**
* Next.js's manifest file convention — auto-served at /manifest.webmanifest
* with the <link rel="manifest"> tag injected automatically, so nothing in
* layout.tsx needs to reference this directly. Single business per
* deployment (see docs/implementation-plan.md), so the name/colors are
* static rather than read from the Business row.
*/
export default function manifest(): MetadataRoute.Manifest {
return {
name: "NextChair — Barber Booking",
short_name: "NextChair",
description: "Book your next barber appointment in seconds — services, staff and loyalty rewards in one place.",
start_url: "/home",
scope: "/",
display: "standalone",
background_color: "#f4f6f4",
theme_color: "#111b15",
orientation: "portrait-primary",
icons: [
{ src: "/icons/icon-192.png", sizes: "192x192", type: "image/png", purpose: "any" },
{ src: "/icons/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any" },
{ src: "/icons/icon-maskable-192.png", sizes: "192x192", type: "image/png", purpose: "maskable" },
{ src: "/icons/icon-maskable-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
],
};
}
+25
View File
@@ -0,0 +1,25 @@
import type { Metadata } from "next";
import { WifiOff } from "lucide-react";
export const metadata: Metadata = { title: "You're offline" };
/**
* The service worker's navigation fallback (see public/sw.js) — served
* from cache when a page request fails with no network. Deliberately
* static: no auth, no data fetching, nothing that could itself require a
* network round-trip, since the whole point is that it still renders with
* zero connectivity. Precached at service-worker install time.
*/
export default function OfflinePage() {
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-stone px-6 text-center">
<div className="bg-ink mb-6 flex h-16 w-16 items-center justify-center rounded-[1.25rem] text-white shadow-xl">
<WifiOff className="h-7 w-7" aria-hidden="true" />
</div>
<h1 className="mb-2 text-xl font-bold tracking-tight">You&apos;re offline</h1>
<p className="text-muted max-w-xs text-sm">
NextChair needs a connection for booking, schedules, and account details. Reconnect and try again.
</p>
</div>
);
}
@@ -6,5 +6,9 @@
* meant for "Confirm Reschedule".
*/
export default function OwnerRescheduleLayout({ children }: { children: React.ReactNode }) {
return <div className="bg-stone min-h-screen">{children}</div>;
return (
<div id="main-content" className="bg-stone min-h-screen">
{children}
</div>
);
}
+1 -1
View File
@@ -11,7 +11,7 @@ export default async function WelcomePage() {
}
return (
<div className="flex min-h-screen flex-col justify-end bg-ink px-8 pb-16 text-white">
<div id="main-content" className="flex min-h-screen flex-col justify-end bg-ink px-8 pb-16 text-white">
<div className="mb-8 flex h-16 w-16 items-center justify-center rounded-2xl bg-white text-ink">
<Scissors className="h-7 w-7" aria-hidden="true" />
</div>
@@ -6,5 +6,9 @@
* clicks meant for "Confirm Reschedule".
*/
export default function StaffRescheduleLayout({ children }: { children: React.ReactNode }) {
return <div className="bg-stone min-h-screen">{children}</div>;
return (
<div id="main-content" className="bg-stone min-h-screen">
{children}
</div>
);
}
+5 -1
View File
@@ -6,5 +6,9 @@
* the persistent staff bottom nav.
*/
export default function StaffBookLayout({ children }: { children: React.ReactNode }) {
return <div className="bg-stone min-h-screen">{children}</div>;
return (
<div id="main-content" className="bg-stone min-h-screen">
{children}
</div>
);
}
+29 -4
View File
@@ -6,6 +6,17 @@ import Google from "next-auth/providers/google";
import { authConfig } from "@/auth.config";
import { prisma } from "@/lib/db/prisma";
import { signInSchema } from "@/features/auth/schemas";
import { checkRateLimit } from "@/lib/security/rate-limit";
// A dummy hash to compare against when no real user/passwordHash exists,
// so a "no such account" / "Google-only account" attempt still pays
// bcrypt's cost — without this, that branch returns near-instantly while
// a wrong-password-for-a-real-account attempt always waits for a real
// bcrypt.compare, letting a timing measurement distinguish "no such
// account" from "wrong password" (an account-enumeration side channel).
// Computed once at module load (not hand-typed) so it's guaranteed to be
// a valid hash at the same cost factor registration uses.
const DUMMY_HASH = bcrypt.hashSync("nextchair-timing-safety-placeholder", 12);
export const { handlers, auth, signIn, signOut } = NextAuth({
...authConfig,
@@ -28,11 +39,25 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
if (!parsed.success) return null;
const { email, password } = parsed.data;
const user = await prisma.user.findUnique({ where: { email } });
if (!user?.passwordHash) return null;
const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) return null;
// Keyed by the attempted email, not IP — this is what actually
// stops credential stuffing against one account regardless of how
// many source addresses it's spread across. 30 attempts per 15
// minutes is well above anything a real forgetful user (or a
// shared/office login) would ever hit, while still making online
// guessing impractical — a real brute-force attempt wants
// thousands of guesses, not tens.
const { allowed } = checkRateLimit(`signin:${email.toLowerCase()}`, {
maxAttempts: 30,
windowMs: 15 * 60 * 1000,
});
if (!allowed) return null;
const user = await prisma.user.findUnique({ where: { email } });
// Always compare against *something* — see DUMMY_HASH above — so
// this call's timing doesn't reveal whether the account exists.
const valid = await bcrypt.compare(password, user?.passwordHash ?? DUMMY_HASH);
if (!user?.passwordHash || !valid) return null;
return { id: user.id, email: user.email, name: user.name, image: user.image };
},
+7 -1
View File
@@ -1,3 +1,4 @@
import { NotificationBell } from "@/features/notifications/components/notification-bell";
import { BottomNav } from "./bottom-nav";
import { StaffSidebarNav } from "./staff-sidebar-nav";
import type { BottomNavItem } from "./bottom-nav";
@@ -14,15 +15,20 @@ export function StaffShell({
children,
items = STAFF_NAV_ITEMS,
footerLink,
notificationsHref = "/staff/notifications",
unreadNotificationCount = 0,
}: {
children: React.ReactNode;
items?: BottomNavItem[];
footerLink?: { href: string; label: string };
notificationsHref?: string;
unreadNotificationCount?: number;
}) {
return (
<div className="min-h-screen bg-stone pb-24 lg:pb-0 lg:pl-56">
<StaffSidebarNav items={items} footerLink={footerLink} />
{children}
<NotificationBell href={notificationsHref} unreadCount={unreadNotificationCount} />
<div id="main-content">{children}</div>
<BottomNav items={items} variant="staff" />
</div>
);
+7 -1
View File
@@ -2,7 +2,13 @@
import { SessionProvider } from "next-auth/react";
import type { ReactNode } from "react";
import { ServiceWorkerManager } from "@/features/pwa/components/service-worker-manager";
export function Providers({ children }: { children: ReactNode }) {
return <SessionProvider>{children}</SessionProvider>;
return (
<SessionProvider>
<ServiceWorkerManager />
{children}
</SessionProvider>
);
}
+4 -1
View File
@@ -58,7 +58,10 @@ export function LoyaltyCard({
<div className="grid grid-cols-5 gap-2">
{Array.from({ length: stampsRequired }, (_, i) => {
const earned = i < stampsEarned;
const isRewardSlot = i === stampsRequired - 1;
// 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;
return (
<div
key={i}
+5 -5
View File
@@ -16,17 +16,17 @@ const STATUS_CONFIG: Record<
> = {
pending_approval: {
label: "Pending Approval",
className: "bg-status-pending/10 text-status-pending",
className: "bg-status-pending/10 text-status-pending-foreground",
icon: Clock,
},
confirmed: {
label: "Confirmed",
className: "bg-status-confirmed/10 text-status-confirmed",
className: "bg-status-confirmed/10 text-status-confirmed-foreground",
icon: CheckCheck,
},
arrived: {
label: "Arrived",
className: "bg-status-confirmed/10 text-status-confirmed",
className: "bg-status-confirmed/10 text-status-confirmed-foreground",
icon: LogIn,
},
in_progress: {
@@ -37,12 +37,12 @@ const STATUS_CONFIG: Record<
completed: { label: "Completed", className: "bg-stone text-muted", icon: CheckCheck },
cancelled: {
label: "Cancelled",
className: "bg-status-cancelled/10 text-status-cancelled",
className: "bg-status-cancelled/10 text-status-cancelled-foreground",
icon: Ban,
},
no_show: {
label: "No-show",
className: "bg-status-cancelled/10 text-status-cancelled",
className: "bg-status-cancelled/10 text-status-cancelled-foreground",
icon: UserX,
},
};
+8
View File
@@ -4,6 +4,8 @@ import bcrypt from "bcryptjs";
import { signIn } from "@/auth";
import { prisma } from "@/lib/db/prisma";
import { registerSchema } from "@/features/auth/schemas";
import { checkRateLimit } from "@/lib/security/rate-limit";
import { getRequestIp } from "@/lib/security/request-ip";
export interface RegisterResult {
success: boolean;
@@ -19,6 +21,12 @@ export interface RegisterResult {
* docs/implementation-plan.md §15, Question 3-adjacent).
*/
export async function registerAction(formData: FormData): Promise<RegisterResult> {
const ip = await getRequestIp();
const { allowed } = checkRateLimit(`register:${ip}`, { maxAttempts: 5, windowMs: 60 * 60 * 1000 });
if (!allowed) {
return { success: false, error: "Too many attempts. Please try again later." };
}
const parsed = registerSchema.safeParse({
firstName: formData.get("firstName"),
lastName: formData.get("lastName"),
@@ -1,35 +1,104 @@
import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PermissionDeniedError, assertCanAccessAppointment, canAccessAppointment } from "../authorization";
/**
* The registered-customer branch never touches the database (it's a plain
* userId comparison), so it's tested here in isolation. The guest-token
* branch's real hash/expiry/revocation behaviour is covered against a real
* database in customer-booking.integration.test.ts — this file only checks
* that authorization.ts calls into validateGuestAccessToken correctly and
* respects its result.
*/
vi.mock("@/services/scheduling/guest-access.service", () => ({
validateGuestAccessToken: vi.fn(),
}));
const { validateGuestAccessToken } = await import("@/services/scheduling/guest-access.service");
const mockValidate = vi.mocked(validateGuestAccessToken);
beforeEach(() => {
mockValidate.mockClear();
});
// Not a real PrismaClient — the mocked guest-access service never touches it.
const prisma = {} as never;
describe("assertCanAccessAppointment", () => {
it("allows a registered customer to access their own appointment", () => {
expect(() =>
assertCanAccessAppointment({ customer: { userId: "user_1" } }, "user_1"),
).not.toThrow();
it("allows a registered customer to access their own appointment", async () => {
await expect(
assertCanAccessAppointment(prisma, { id: "a1", customer: { userId: "user_1" } }, { sessionUserId: "user_1" }),
).resolves.toBeUndefined();
});
it("denies a different signed-in user from accessing a registered customer's appointment", () => {
expect(() => assertCanAccessAppointment({ customer: { userId: "user_1" } }, "user_2")).toThrow(
PermissionDeniedError,
);
it("denies a different signed-in user from accessing a registered customer's appointment", async () => {
await expect(
assertCanAccessAppointment(prisma, { id: "a1", customer: { userId: "user_1" } }, { sessionUserId: "user_2" }),
).rejects.toThrow(PermissionDeniedError);
});
it("denies an unauthenticated caller from accessing a registered customer's appointment", () => {
expect(() => assertCanAccessAppointment({ customer: { userId: "user_1" } }, undefined)).toThrow(
PermissionDeniedError,
);
it("denies an unauthenticated caller from accessing a registered customer's appointment", async () => {
await expect(
assertCanAccessAppointment(prisma, { id: "a1", customer: { userId: "user_1" } }, { sessionUserId: undefined }),
).rejects.toThrow(PermissionDeniedError);
});
it("allows access to a guest appointment (no linked user) regardless of session — the documented guest access model", () => {
expect(() => assertCanAccessAppointment({ customer: { userId: null } }, undefined)).not.toThrow();
expect(() => assertCanAccessAppointment({ customer: { userId: null } }, "some_other_user")).not.toThrow();
it("never even calls the guest-token validator for a registered customer's appointment", async () => {
mockValidate.mockClear();
await assertCanAccessAppointment(
prisma,
{ id: "a1", customer: { userId: "user_1" } },
{ sessionUserId: "user_1" },
).catch(() => {});
expect(mockValidate).not.toHaveBeenCalled();
});
it("allows a guest appointment when the guest-token validator confirms the token", async () => {
mockValidate.mockResolvedValueOnce(true);
await expect(
assertCanAccessAppointment(
prisma,
{ id: "a1", customer: { userId: null } },
{ sessionUserId: undefined, guestToken: "correct-token" },
),
).resolves.toBeUndefined();
expect(mockValidate).toHaveBeenCalledWith(prisma, { appointmentId: "a1", rawToken: "correct-token" });
});
it("denies a guest appointment when no token is supplied at all — the appointment id is no longer sufficient", async () => {
await expect(
assertCanAccessAppointment(prisma, { id: "a1", customer: { userId: null } }, { sessionUserId: undefined }),
).rejects.toThrow(PermissionDeniedError);
expect(mockValidate).not.toHaveBeenCalled();
});
it("denies a guest appointment when the guest-token validator rejects the token", async () => {
mockValidate.mockResolvedValueOnce(false);
await expect(
assertCanAccessAppointment(
prisma,
{ id: "a1", customer: { userId: null } },
{ sessionUserId: undefined, guestToken: "wrong-token" },
),
).rejects.toThrow(PermissionDeniedError);
});
});
describe("canAccessAppointment", () => {
it("mirrors assertCanAccessAppointment as a boolean", () => {
expect(canAccessAppointment({ customer: { userId: "user_1" } }, "user_1")).toBe(true);
expect(canAccessAppointment({ customer: { userId: "user_1" } }, "user_2")).toBe(false);
expect(canAccessAppointment({ customer: { userId: null } }, undefined)).toBe(true);
it("mirrors assertCanAccessAppointment as a boolean", async () => {
expect(
await canAccessAppointment(prisma, { id: "a1", customer: { userId: "user_1" } }, { sessionUserId: "user_1" }),
).toBe(true);
expect(
await canAccessAppointment(prisma, { id: "a1", customer: { userId: "user_1" } }, { sessionUserId: "user_2" }),
).toBe(false);
mockValidate.mockResolvedValueOnce(true);
expect(
await canAccessAppointment(
prisma,
{ id: "a1", customer: { userId: null } },
{ sessionUserId: undefined, guestToken: "correct-token" },
),
).toBe(true);
});
});
@@ -4,6 +4,7 @@ import { addDays, todayIn, wallClockToInstant, weekdayOf } from "@/lib/time/busi
import type { CalendarDate } from "@/lib/time/business-time";
import { cancelBooking, createBooking, rescheduleBooking } from "@/services/scheduling/booking.service";
import { listAppointments } from "@/services/scheduling/appointment.service";
import { revokeGuestAccessToken } from "@/services/scheduling/guest-access.service";
import {
cleanupTestBusiness,
createTestBusiness,
@@ -357,10 +358,16 @@ describe("customer booking flows (integration)", () => {
});
if (!created.success) throw new Error("setup failed");
expect(() => assertCanAccessAppointment(created.appointment, ownerUserId)).not.toThrow();
expect(() => assertCanAccessAppointment(created.appointment, strangerUserId)).toThrow(PermissionDeniedError);
expect(() => assertCanAccessAppointment(created.appointment, undefined)).toThrow(PermissionDeniedError);
expect(canAccessAppointment(created.appointment, strangerUserId)).toBe(false);
await expect(
assertCanAccessAppointment(prisma, created.appointment, { sessionUserId: ownerUserId }),
).resolves.toBeUndefined();
await expect(
assertCanAccessAppointment(prisma, created.appointment, { sessionUserId: strangerUserId }),
).rejects.toThrow(PermissionDeniedError);
await expect(
assertCanAccessAppointment(prisma, created.appointment, { sessionUserId: undefined }),
).rejects.toThrow(PermissionDeniedError);
expect(await canAccessAppointment(prisma, created.appointment, { sessionUserId: strangerUserId })).toBe(false);
});
it("does not treat the appointment id alone as permission — an unrelated session cannot claim it", async () => {
@@ -378,7 +385,102 @@ describe("customer booking flows (integration)", () => {
expect(created.appointment.customer.userId).toBe(ownerUserId);
const randomOtherId = "some-other-user-id-that-is-not-the-owner";
expect(canAccessAppointment(created.appointment, randomOtherId)).toBe(false);
expect(await canAccessAppointment(prisma, created.appointment, { sessionUserId: randomOtherId })).toBe(false);
});
});
describe("guest access tokens (Phase 7 — see guest-access.service.ts)", () => {
it("a guest booking issues a token, and only the correct token grants access", async () => {
const { staffId } = await createTestStaff(business);
const guest = await createTestGuestCustomer();
guestProfileIds.push(guest.customerId);
const created = await createBooking(prisma, {
businessId: business.businessId,
customerId: guest.customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
if (!created.success) throw new Error("setup failed");
expect(created.guestAccessToken).toBeTruthy();
expect(created.appointment.customer.userId).toBeNull();
await expect(
assertCanAccessAppointment(prisma, created.appointment, { guestToken: created.guestAccessToken }),
).resolves.toBeUndefined();
// The bare appointment id is no longer sufficient — the exact gap this closes.
await expect(
assertCanAccessAppointment(prisma, created.appointment, { guestToken: undefined }),
).rejects.toThrow(PermissionDeniedError);
await expect(
assertCanAccessAppointment(prisma, created.appointment, { guestToken: "totally-wrong-token" }),
).rejects.toThrow(PermissionDeniedError);
});
it("a token scoped to one guest appointment does not grant access to another", async () => {
const { staffId } = await createTestStaff(business);
const guest = await createTestGuestCustomer();
guestProfileIds.push(guest.customerId);
const first = await createBooking(prisma, {
businessId: business.businessId,
customerId: guest.customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
const second = await createBooking(prisma, {
businessId: business.businessId,
customerId: guest.customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 12 * 60, business.timezone),
});
if (!first.success || !second.success) throw new Error("setup failed");
expect(first.guestAccessToken).not.toBe(second.guestAccessToken);
await expect(
assertCanAccessAppointment(prisma, second.appointment, { guestToken: first.guestAccessToken }),
).rejects.toThrow(PermissionDeniedError);
});
it("an expired or revoked token is rejected even though it's otherwise correct", async () => {
const { staffId } = await createTestStaff(business);
const guest = await createTestGuestCustomer();
guestProfileIds.push(guest.customerId);
const created = await createBooking(prisma, {
businessId: business.businessId,
customerId: guest.customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
if (!created.success) throw new Error("setup failed");
await prisma.guestAppointmentAccessToken.update({
where: { appointmentId: created.appointment.id },
data: { expiresAt: new Date(Date.now() - 1000) },
});
await expect(
assertCanAccessAppointment(prisma, created.appointment, { guestToken: created.guestAccessToken }),
).rejects.toThrow(PermissionDeniedError);
await prisma.guestAppointmentAccessToken.update({
where: { appointmentId: created.appointment.id },
data: { expiresAt: new Date(Date.now() + 1000 * 60 * 60), revokedAt: null },
});
await expect(
assertCanAccessAppointment(prisma, created.appointment, { guestToken: created.guestAccessToken }),
).resolves.toBeUndefined();
await revokeGuestAccessToken(prisma, created.appointment.id);
await expect(
assertCanAccessAppointment(prisma, created.appointment, { guestToken: created.guestAccessToken }),
).rejects.toThrow(PermissionDeniedError);
});
});
+14 -1
View File
@@ -4,6 +4,8 @@ import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { clearDraft, readDraft, writeLastBookingReference } from "@/lib/booking-draft/cookie";
import { prisma } from "@/lib/db/prisma";
import { checkRateLimit } from "@/lib/security/rate-limit";
import { getRequestIp } from "@/lib/security/request-ip";
import { resolveCustomerForBooking } from "@/services/customer/customer.service";
import { createBooking } from "@/services/scheduling/booking.service";
import { getFirstFriendlyMessage } from "../error-messages";
@@ -42,6 +44,14 @@ export async function confirmBookingAction(businessId: string): Promise<ConfirmB
if (!draft.guest) {
return { success: false, error: "Please provide your contact details first." };
}
// Registered customers already have a rate-limited signin gate on
// their account; a guest has none, so this is the one place spam
// bookings can otherwise be generated with no account at all.
const ip = await getRequestIp();
const { allowed } = checkRateLimit(`guest-booking:${ip}`, { maxAttempts: 8, windowMs: 60 * 60 * 1000 });
if (!allowed) {
return { success: false, error: "Too many booking attempts. Please try again later." };
}
const resolved = await resolveCustomerForBooking(prisma, { businessId, guest: draft.guest });
customerId = resolved.customerId;
}
@@ -62,7 +72,10 @@ export async function confirmBookingAction(businessId: string): Promise<ConfirmB
return { success: false, error: getFirstFriendlyMessage(result.validation) };
}
await writeLastBookingReference(result.appointment.id);
await writeLastBookingReference({
appointmentId: result.appointment.id,
guestAccessToken: result.guestAccessToken,
});
await clearDraft();
redirect("/book/confirmation");
}
@@ -31,7 +31,10 @@ export async function cancelCustomerAppointmentAction(input: unknown): Promise<C
const session = await auth();
try {
assertCanAccessAppointment(appointment, session?.user?.id);
await assertCanAccessAppointment(prisma, appointment, {
sessionUserId: session?.user?.id,
guestToken: parsed.data.guestToken,
});
} catch (error) {
if (error instanceof PermissionDeniedError) return { success: false, error: error.message };
throw error;
@@ -65,7 +68,10 @@ export async function rescheduleCustomerAppointmentAction(input: unknown): Promi
const session = await auth();
try {
assertCanAccessAppointment(appointment, session?.user?.id);
await assertCanAccessAppointment(prisma, appointment, {
sessionUserId: session?.user?.id,
guestToken: parsed.data.guestToken,
});
} catch (error) {
if (error instanceof PermissionDeniedError) return { success: false, error: error.message };
throw error;
+4 -1
View File
@@ -140,7 +140,10 @@ export async function startRebookAction(input: unknown): Promise<void> {
const session = await auth();
try {
assertCanAccessAppointment(appointment, session?.user?.id);
await assertCanAccessAppointment(prisma, appointment, {
sessionUserId: session?.user?.id,
guestToken: parsed.data.guestToken,
});
} catch (error) {
if (error instanceof PermissionDeniedError) {
redirect("/appointments");
+37 -10
View File
@@ -1,3 +1,6 @@
import type { PrismaClient } from "@/generated/prisma/client";
import { validateGuestAccessToken } from "@/services/scheduling/guest-access.service";
/**
* Customer-facing ownership checks. Kept in the feature/action layer, not
* the domain services — AppointmentService/BookingService are also used
@@ -17,31 +20,55 @@ export class PermissionDeniedError extends Error {
}
export interface OwnableAppointment {
id: string;
customer: { userId: string | null };
}
export interface AppointmentAccess {
sessionUserId?: string | undefined;
/** Raw token from the guest's booking-confirmation link — see guest-access.service.ts. Ignored for a registered customer's appointment. */
guestToken?: string;
}
/**
* Ownership rule:
* - A REGISTERED customer's appointment (customer.userId is set) always
* requires the requesting session to match that exact user. An
* appointment id alone is never sufficient — see the Phase 4 brief's
* explicit "Appointment IDs are not treated as permission".
* - A GUEST appointment (customer.userId is null) has no stronger
* credential available in this phase — no magic-link/OTP system was
* built (explicitly deferred, see docs/phase-4-summary.md). Access is
* by the appointment's own unguessable id, the same "order tracking"
* pattern many guest-checkout systems use. This is a deliberate,
* documented scope limitation, not an oversight.
* - A GUEST appointment (customer.userId is null) requires the current,
* unexpired, unrevoked access token for THIS exact appointment (see
* GuestAppointmentAccessToken in schema.prisma and
* guest-access.service.ts). Closes the Phase 4 gap where the
* appointment's own id — unguessable but never actually secret — was
* the only credential (see docs/phase-4-summary.md and
* docs/phase-7-summary.md).
*/
export function assertCanAccessAppointment(appointment: OwnableAppointment, sessionUserId: string | undefined): void {
if (appointment.customer.userId && appointment.customer.userId !== sessionUserId) {
export async function assertCanAccessAppointment(
prisma: PrismaClient,
appointment: OwnableAppointment,
access: AppointmentAccess,
): Promise<void> {
if (appointment.customer.userId) {
if (appointment.customer.userId !== access.sessionUserId) throw new PermissionDeniedError();
return;
}
if (
!access.guestToken ||
!(await validateGuestAccessToken(prisma, { appointmentId: appointment.id, rawToken: access.guestToken }))
) {
throw new PermissionDeniedError();
}
}
export function canAccessAppointment(appointment: OwnableAppointment, sessionUserId: string | undefined): boolean {
export async function canAccessAppointment(
prisma: PrismaClient,
appointment: OwnableAppointment,
access: AppointmentAccess,
): Promise<boolean> {
try {
assertCanAccessAppointment(appointment, sessionUserId);
await assertCanAccessAppointment(prisma, appointment, access);
return true;
} catch {
return false;
@@ -4,7 +4,15 @@ import { useTransition } from "react";
import { Button } from "@/components/ui/button";
import { startRebookAction } from "../actions/draft";
export function BookAgainButton({ appointmentId, className }: { appointmentId: string; className?: string }) {
export function BookAgainButton({
appointmentId,
guestToken,
className,
}: {
appointmentId: string;
guestToken?: string;
className?: string;
}) {
const [isPending, startTransition] = useTransition();
return (
@@ -13,7 +21,7 @@ export function BookAgainButton({ appointmentId, className }: { appointmentId: s
size="sm"
className={className}
isLoading={isPending}
onClick={() => startTransition(() => startRebookAction({ appointmentId }))}
onClick={() => startTransition(() => startRebookAction({ appointmentId, guestToken }))}
>
Book Again
</Button>
@@ -7,7 +7,13 @@ import { Modal } from "@/components/ui/modal";
import { Textarea } from "@/components/ui/textarea";
import { cancelCustomerAppointmentAction } from "../actions/customer-appointments";
export function CancelAppointmentDialog({ appointmentId }: { appointmentId: string }) {
export function CancelAppointmentDialog({
appointmentId,
guestToken,
}: {
appointmentId: string;
guestToken?: string;
}) {
const [open, setOpen] = useState(false);
const [reason, setReason] = useState("");
const [error, setError] = useState<string | null>(null);
@@ -21,6 +27,7 @@ export function CancelAppointmentDialog({ appointmentId }: { appointmentId: stri
const res = await cancelCustomerAppointmentAction({
appointmentId,
reason: reason || undefined,
guestToken,
});
if (!res.success) {
setError(res.error ?? "Something went wrong. Please try again.");
+3
View File
@@ -85,15 +85,18 @@ export const saveDetailsStepSchema = z.object({
export const startRebookSchema = z.object({
appointmentId: z.string(),
guestToken: z.string().optional(),
});
export const customerCancelSchema = z.object({
appointmentId: z.string(),
reason: z.string().max(500).optional(),
guestToken: z.string().optional(),
});
export const customerRescheduleSchema = z.object({
appointmentId: z.string(),
staffId: z.string().optional(),
startAt: z.iso.datetime(),
guestToken: z.string().optional(),
});
@@ -0,0 +1,41 @@
"use server";
import { revalidatePath } from "next/cache";
import { auth } from "@/auth";
import { prisma } from "@/lib/db/prisma";
import { markAllNotificationsRead, markNotificationRead } from "@/services/notifications/notification.service";
export interface MarkReadActionResult {
success: boolean;
error?: string;
}
/**
* Shared across all three notification-centre pages (customer/staff/owner)
* — ownership is enforced by recipientUserId matching the session, exactly
* the same rule regardless of which role is asking (see
* NotificationService.markNotificationRead's own scoped WHERE clause), so
* one action file covers all of them rather than three near-identical
* copies.
*/
export async function markNotificationReadAction(notificationId: string): Promise<MarkReadActionResult> {
const session = await auth();
if (!session?.user) return { success: false, error: "You must be signed in." };
await markNotificationRead(prisma, { notificationId, userId: session.user.id });
revalidatePath("/notifications");
revalidatePath("/staff/notifications");
revalidatePath("/owner/notifications");
return { success: true };
}
export async function markAllNotificationsReadAction(): Promise<MarkReadActionResult> {
const session = await auth();
if (!session?.user) return { success: false, error: "You must be signed in." };
await markAllNotificationsRead(prisma, session.user.id);
revalidatePath("/notifications");
revalidatePath("/staff/notifications");
revalidatePath("/owner/notifications");
return { success: true };
}
@@ -0,0 +1,35 @@
"use server";
import { auth } from "@/auth";
import { NotificationType } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db/prisma";
import { setNotificationPreference } from "@/services/notifications/notification.service";
import type { PreferenceCellState } from "@/features/staff/components/notification-preferences-form";
export interface PreferencesActionResult {
success: boolean;
error?: string;
}
/**
* The customer-side counterpart to features/staff/actions/profile.ts's
* saveNotificationPreferencesAction — same shape, but scoped to the
* session's own customer account and the one preference-controlled
* customer-facing type (BOOKING_REMINDER; see notifications/types.ts).
*/
export async function saveCustomerNotificationPreferencesAction(
cells: PreferenceCellState[],
): Promise<PreferencesActionResult> {
const session = await auth();
if (!session?.user) return { success: false, error: "You must be signed in." };
for (const cell of cells) {
await setNotificationPreference(prisma, {
userId: session.user.id,
type: NotificationType.BOOKING_REMINDER,
channel: cell.channel,
isEnabled: cell.isEnabled,
});
}
return { success: true };
}
@@ -0,0 +1,51 @@
"use server";
import { auth } from "@/auth";
import { prisma } from "@/lib/db/prisma";
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
import { subscribeToPush, unsubscribeFromPush } from "@/services/notifications/push-subscription.service";
export interface PushActionResult {
success: boolean;
error?: string;
}
/**
* Registered users only (staff, owner, signed-in customer) — a guest has
* no session to attribute the subscription to. A guest subscribing right
* after a guest booking is a real, documented gap for a future pass (see
* docs/phase-7-summary.md, "Known limitations"), not an oversight.
*/
export async function subscribeToPushAction(input: {
endpoint: string;
p256dhKey: string;
authKey: string;
deviceLabel?: string;
}): Promise<PushActionResult> {
const session = await auth();
if (!session?.user) return { success: false, error: "You must be signed in." };
const [business, customerProfile] = await Promise.all([
getPrimaryBusiness(),
prisma.customerProfile.findUnique({ where: { userId: session.user.id } }),
]);
await subscribeToPush(prisma, {
businessId: business.id,
userId: session.user.id,
customerId: customerProfile?.id,
endpoint: input.endpoint,
p256dhKey: input.p256dhKey,
authKey: input.authKey,
deviceLabel: input.deviceLabel,
});
return { success: true };
}
export async function unsubscribeFromPushAction(endpoint: string): Promise<PushActionResult> {
const session = await auth();
if (!session?.user) return { success: false, error: "You must be signed in." };
await unsubscribeFromPush(prisma, { endpoint, ownerUserId: session.user.id });
return { success: true };
}
@@ -0,0 +1,27 @@
import Link from "next/link";
import { Bell } from "lucide-react";
/**
* Fixed top-right on every screen inside the shell that renders it (see
* StaffShell and (customer)/layout.tsx) — unread count is fetched
* server-side by the layout and passed in, not polled client-side, so it
* only ever reflects "as of this page load/navigation". Real-time badge
* updates without a reload would need a persistent connection this phase
* doesn't build (see docs/phase-7-summary.md, "Known limitations").
*/
export function NotificationBell({ href, unreadCount }: { href: string; unreadCount: number }) {
return (
<Link
href={href}
aria-label={unreadCount > 0 ? `Notifications, ${unreadCount} unread` : "Notifications"}
className="fixed top-4 right-4 z-40 flex h-10 w-10 items-center justify-center rounded-full border border-line bg-white shadow-sm"
>
<Bell className="h-4 w-4" aria-hidden="true" />
{unreadCount > 0 && (
<span className="bg-danger absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center rounded-full px-1 text-[10px] font-bold text-white">
{unreadCount > 9 ? "9+" : unreadCount}
</span>
)}
</Link>
);
}
@@ -0,0 +1,112 @@
"use client";
import { Bell, BellOff } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useTransition } from "react";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/feedback/empty-state";
import { markAllNotificationsReadAction, markNotificationReadAction } from "../actions/mark-read";
export interface NotificationListItem {
id: string;
subject: string | null;
bodyText: string;
deepLinkPath: string | null;
readAt: Date | null;
createdAt: Date;
}
function formatTimestamp(date: Date): string {
return date.toLocaleString("en-GB", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
}
/**
* Shared by /notifications, /staff/notifications, /owner/notifications —
* ownership scoping already happened server-side (each page only ever
* passes that session's own notifications, via
* NotificationService.listInAppNotifications), so this component just
* renders whatever list it's given and reports read/unread actions back.
*/
export function NotificationList({ notifications }: { notifications: NotificationListItem[] }) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const hasUnread = notifications.some((n) => !n.readAt);
const handleOpen = (notification: NotificationListItem) => {
if (!notification.readAt) {
startTransition(async () => {
await markNotificationReadAction(notification.id);
router.refresh();
});
}
};
const handleMarkAllRead = () => {
startTransition(async () => {
await markAllNotificationsReadAction();
router.refresh();
});
};
if (notifications.length === 0) {
return (
<EmptyState
icon={BellOff}
title="No notifications yet"
description="You'll see booking updates and reminders here."
/>
);
}
return (
<div className="space-y-4">
{hasUnread && (
<div className="flex justify-end">
<Button variant="secondary" size="sm" isLoading={isPending} onClick={handleMarkAllRead}>
Mark All Read
</Button>
</div>
)}
<ul className="space-y-2">
{notifications.map((notification) => {
const unread = !notification.readAt;
const content = (
<div
className={`rounded-jumbo border p-4 transition-colors ${
unread ? "border-accent/30 bg-accent/5" : "border-line bg-white"
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3">
<Bell className={`mt-0.5 h-4 w-4 shrink-0 ${unread ? "text-accent" : "text-muted"}`} aria-hidden="true" />
<div>
{notification.subject && <p className="text-sm font-bold">{notification.subject}</p>}
<p className="text-muted mt-0.5 text-sm">{notification.bodyText}</p>
<p className="text-muted mt-1 text-xs">{formatTimestamp(notification.createdAt)}</p>
</div>
</div>
{unread && <span role="status" className="bg-accent mt-1 h-2 w-2 shrink-0 rounded-full" aria-label="Unread" />}
</div>
</div>
);
return (
<li key={notification.id}>
{notification.deepLinkPath ? (
<Link href={notification.deepLinkPath} onClick={() => handleOpen(notification)} className="block">
{content}
</Link>
) : (
<button type="button" onClick={() => handleOpen(notification)} className="block w-full text-left">
{content}
</button>
)}
</li>
);
})}
</ul>
</div>
);
}
@@ -0,0 +1,153 @@
"use client";
import { useEffect, useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { subscribeToPushAction, unsubscribeFromPushAction } from "../actions/push-subscriptions";
import { urlBase64ToUint8Array } from "../lib/vapid";
type Status = "checking" | "unsupported" | "subscribed" | "not-subscribed" | "denied";
/**
* The one control that ever asks for push permission — deliberately not
* rendered anywhere that loads automatically on first visit (see the
* Phase 7 brief's "never request permission on first load"). Placed in
* Settings, a screen someone only reaches by deliberately navigating
* there, with the benefit explained in the paragraph above the button
* before anything is requested.
*/
function isPushSupported(): boolean {
return typeof window !== "undefined" && "serviceWorker" in navigator && "PushManager" in window;
}
export function PushSubscribeToggle() {
// Always starts as "checking" (renders nothing) on both server and
// client — `isPushSupported`/`Notification.permission` are
// client-only reads, so resolving them in a useState initializer would
// make the client's hydration render disagree with the server's and
// trigger a hydration error. The real status is only ever resolved
// client-side, post-mount, in the effect below.
const [status, setStatus] = useState<Status>("checking");
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
useEffect(() => {
if (status !== "checking") return;
let cancelled = false;
Promise.resolve()
.then(async () => {
if (!isPushSupported()) return "unsupported" as const;
if (Notification.permission === "denied") return "denied" as const;
const reg = await navigator.serviceWorker.getRegistration();
const sub = await reg?.pushManager.getSubscription();
return sub ? ("subscribed" as const) : ("not-subscribed" as const);
})
.then((resolved) => {
if (!cancelled) setStatus(resolved);
})
.catch(() => {
if (!cancelled) setStatus("not-subscribed");
});
return () => {
cancelled = true;
};
}, [status]);
const subscribe = () => {
setError(null);
startTransition(async () => {
try {
const vapidPublicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY;
if (!vapidPublicKey) {
setError("Push notifications aren't configured for this business yet.");
return;
}
const registration = await navigator.serviceWorker.register("/sw.js");
await navigator.serviceWorker.ready;
const permission = await Notification.requestPermission();
if (permission !== "granted") {
setStatus("denied");
return;
}
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey) as BufferSource,
});
const json = subscription.toJSON();
if (!json.endpoint || !json.keys?.p256dh || !json.keys?.auth) {
setError("Couldn't complete the push subscription. Please try again.");
return;
}
const result = await subscribeToPushAction({
endpoint: json.endpoint,
p256dhKey: json.keys.p256dh,
authKey: json.keys.auth,
deviceLabel: navigator.userAgent.slice(0, 100),
});
if (!result.success) {
setError(result.error ?? "Couldn't save your subscription.");
return;
}
setStatus("subscribed");
} catch {
setError("Couldn't enable push notifications. Please try again.");
}
});
};
const unsubscribe = () => {
setError(null);
startTransition(async () => {
const registration = await navigator.serviceWorker.getRegistration();
const subscription = await registration?.pushManager.getSubscription();
if (subscription) {
await unsubscribeFromPushAction(subscription.endpoint);
await subscription.unsubscribe();
}
setStatus("not-subscribed");
});
};
if (status === "checking") return null;
if (status === "unsupported") {
return <p className="text-muted text-sm">Push notifications aren&apos;t supported in this browser.</p>;
}
if (status === "denied") {
return (
<p className="text-muted text-sm">
Push notifications are blocked for this site in your browser settings. Allow notifications for this site to
turn them back on.
</p>
);
}
return (
<div className="space-y-2">
{status === "not-subscribed" && (
<p className="text-muted text-sm">
Get a push notification on this device for reminders and important updates, in addition to email.
</p>
)}
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
<Button
variant={status === "subscribed" ? "secondary" : "primary"}
size="sm"
isLoading={isPending}
onClick={status === "subscribed" ? unsubscribe : subscribe}
>
{status === "subscribed" ? "Disable Push Notifications" : "Enable Push Notifications"}
</Button>
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
/** Web Push wants the VAPID public key as a Uint8Array, not the base64url string the browser subscribe() call ultimately needs to sign with — standard boilerplate conversion, no library needed for it. */
export function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
const rawData = atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; i++) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
@@ -0,0 +1,94 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { Weekday } from "@/generated/prisma/enums";
import { createBreak, createTimeOff, deleteBreak, deleteTimeOff } from "@/services/staff/staff.service";
import {
cleanupTestBusiness,
createTestBusiness,
createTestStaff,
prisma,
} from "@/services/scheduling/__tests__/fixtures";
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
/**
* Owner-on-behalf-of-staff breaks and time off (Phase 7, closing the Phase
* 6-documented gap — see docs/phase-7-summary.md, "Owner-managed
* availability"). The owner Server Actions in
* src/features/owner/actions/breaks.ts and time-off.ts are thin wrappers:
* `assertOwnerPermission` (already covered by
* src/features/owner/__tests__/authorization.integration.test.ts) plus a
* business-scoped staffId lookup, then these exact StaffService calls with
* an explicit target staffId instead of the caller's own. Importing the
* "use server" action files directly here isn't possible — they import
* `@/auth`, which pulls in next-auth's NextAuth() factory and fails
* outside Next's own bundler/runtime (`Cannot find module
* "next/server"`), so what these tests verify is the StaffService plumbing
* an owner action delegates to, exactly as every other StaffService
* integration test in this suite does.
*/
describe("Owner-managed breaks and time off (integration)", () => {
let business: TestBusiness;
beforeEach(async () => {
business = await createTestBusiness();
});
afterEach(async () => {
await cleanupTestBusiness(business);
});
it("creates and deletes a break against an explicit target staffId, not the caller's own", async () => {
const { staffId } = await createTestStaff(business);
const created = await createBreak(prisma, {
staffId,
businessTimezone: business.timezone,
input: { label: "Owner-added break", startMinutes: 12 * 60, endMinutes: 12 * 60 + 30, days: [Weekday.MONDAY] },
});
expect(created.success).toBe(true);
if (!created.success) return;
const stored = await prisma.break.findUnique({ where: { id: created.break.id } });
expect(stored?.staffId).toBe(staffId);
expect(stored?.label).toBe("Owner-added break");
const removed = await deleteBreak(prisma, { breakId: created.break.id, staffId });
expect(removed.success).toBe(true);
const gone = await prisma.break.findUnique({ where: { id: created.break.id } });
expect(gone).toBeNull();
});
it("creates and deletes whole-day time off against an explicit target staffId", async () => {
const { staffId } = await createTestStaff(business);
const created = await createTimeOff(prisma, {
staffId,
businessTimezone: business.timezone,
input: { startDate: "2027-02-01", endDate: "2027-02-03", reason: "Owner-added leave" },
});
expect(created.success).toBe(true);
if (!created.success) return;
const stored = await prisma.timeOff.findUnique({ where: { id: created.timeOff.id } });
expect(stored?.staffId).toBe(staffId);
expect(stored?.reason).toBe("Owner-added leave");
const removed = await deleteTimeOff(prisma, { timeOffId: created.timeOff.id, staffId });
expect(removed.success).toBe(true);
const gone = await prisma.timeOff.findUnique({ where: { id: created.timeOff.id } });
expect(gone).toBeNull();
});
it("a break created for staff A is never visible when querying by staff B's id (no accidental cross-staff writes)", async () => {
const { staffId: staffA } = await createTestStaff(business);
const { staffId: staffB } = await createTestStaff(business);
await createBreak(prisma, {
staffId: staffA,
businessTimezone: business.timezone,
input: { label: "A's break", startMinutes: 9 * 60, endMinutes: 9 * 60 + 15, days: [Weekday.TUESDAY] },
});
const staffBBreaks = await prisma.break.findMany({ where: { staffId: staffB } });
expect(staffBBreaks).toHaveLength(0);
});
});
+13 -6
View File
@@ -15,6 +15,7 @@ import {
start,
} from "@/services/scheduling/appointment.service";
import { awardStampForCompletedAppointment } from "@/services/loyalty/loyalty.service";
import { notifyAppointmentNoShow, notifyBookingApproved, notifyBookingBarberChanged, notifyBookingCompleted } from "@/services/scheduling/booking-notifications";
import { writeAuditLog } from "@/lib/audit/write-audit-log";
import type { StaffAppointmentActionResult } from "@/features/staff/actions/appointment-actions";
@@ -74,9 +75,11 @@ export async function ownerConfirmAppointmentAction(appointmentId: string): Prom
if (!appointment) return { success: false, error: error ?? "Not found." };
const result = await confirm(prisma, appointmentId);
return result.success
? { success: true }
: { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
if (!result.success) {
return { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
}
await notifyBookingApproved(prisma, result.appointment);
return { success: true };
}
export async function ownerCheckInAppointmentAction(appointmentId: string): Promise<StaffAppointmentActionResult> {
@@ -107,6 +110,7 @@ export async function ownerCompleteAppointmentAction(appointmentId: string): Pro
if (!result.success) {
return { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
}
await notifyBookingCompleted(prisma, result.appointment);
const loyaltyResult = await awardStampForCompletedAppointment(prisma, { appointmentId, staffId: appointment.staffId });
return { success: true, loyaltyStampAwarded: loyaltyResult.success && !loyaltyResult.alreadyAwarded };
@@ -117,9 +121,11 @@ export async function ownerMarkNoShowAction(appointmentId: string): Promise<Staf
if (!appointment) return { success: false, error: error ?? "Not found." };
const result = await markNoShow(prisma, appointmentId);
return result.success
? { success: true }
: { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
if (!result.success) {
return { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
}
await notifyAppointmentNoShow(prisma, result.appointment);
return { success: true };
}
export async function ownerCancelAppointmentAction(params: {
@@ -172,6 +178,7 @@ export async function ownerRescheduleAppointmentAction(params: {
entityId: params.appointmentId,
metadata: { fromStaffId: previousStaffId, toStaffId: params.staffId },
});
await notifyBookingBarberChanged(prisma, result.appointment);
}
return { success: true };
@@ -0,0 +1,26 @@
import { ownerCreateBreakAction, ownerDeleteBreakAction, ownerUpdateBreakAction } from "./breaks";
import type { BreakActionSet } from "@/features/staff/components/break-form-sheet";
/**
* Plain module (no "use server") assembling the owner break Server Actions
* into the `BreakActionSet` shape BreaksList/BreakFormSheet expect, bound
* to one target staff member.
*
* Uses `.bind(null, staffId)`, not an arrow-function wrapper — a Client
* Component can only receive an actual Server Action reference as a prop;
* React's Flight serializer specifically recognizes `.bind()` on a Server
* Action (Next's own documented pattern for passing extra arguments) and
* reconstructs the call server-side, but a plain closure like `(input) =>
* ownerCreateBreakAction(staffId, input)` is just an ordinary function with
* no such registration and throws "Functions cannot be passed directly to
* Client Components" at render time — found empirically in Phase 7 (see
* docs/phase-7-summary.md) when this file's first version used arrow
* wrappers instead.
*/
export function createOwnerBreakActionSet(staffId: string): BreakActionSet {
return {
create: ownerCreateBreakAction.bind(null, staffId),
update: ownerUpdateBreakAction.bind(null, staffId),
remove: ownerDeleteBreakAction.bind(null, staffId),
};
}
+155
View File
@@ -0,0 +1,155 @@
"use server";
import { auth } from "@/auth";
import { prisma } from "@/lib/db/prisma";
import { Permission } from "@/generated/prisma/enums";
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
import { writeAuditLog } from "@/lib/audit/write-audit-log";
import { createBreak, deleteBreak, updateBreak } from "@/services/staff/staff.service";
import type { BreakInput } from "@/services/staff/staff.service";
import { notifyStaffScheduleChanged } from "@/services/staff/staff-notifications";
export interface BreakConflictItem {
id: string;
customerName: string;
serviceNames: string;
startAt: string;
}
export interface BreakActionResult {
success: boolean;
error?: string;
conflicts?: BreakConflictItem[];
}
function mapConflicts(conflicts: { id: string; customer: { firstName: string; lastName: string }; services: { service: { name: string } }[]; startAt: Date }[]): BreakConflictItem[] {
return conflicts.map((c) => ({
id: c.id,
customerName: `${c.customer.firstName} ${c.customer.lastName}`,
serviceNames: c.services.map((s) => s.service.name).join(" + "),
startAt: c.startAt.toISOString(),
}));
}
async function requireStaffManagerFor(staffId: string) {
const session = await auth();
if (!session?.user) return { owner: null, error: "You must be signed in." } as const;
try {
await assertOwnerPermission(session.user.id, Permission.MANAGE_STAFF);
} catch (error) {
if (error instanceof OwnerPermissionDeniedError) return { owner: null, error: error.message } as const;
throw error;
}
const owner = await prisma.staffProfile.findUnique({
where: { userId: session.user.id },
include: { business: { select: { name: true, timezone: true } }, user: { select: { name: true } } },
});
if (!owner) return { owner: null, target: null, error: "You must be signed in as staff." } as const;
const target = await prisma.staffProfile.findFirst({ where: { id: staffId, businessId: owner.businessId } });
if (!target) return { owner: null, target: null, error: "Staff member not found." } as const;
return { owner, target, error: null } as const;
}
/**
* Owner-on-behalf-of-staff break management — same StaffService calls the
* barber's own editor uses, targeting an explicit `staffId` argument
* instead of the caller's own. Assembled into BreaksList/BreakFormSheet's
* `actions` prop shape by the plain (non-"use server") factory in
* break-action-set.ts — a "use server" file may only export async
* functions, never a plain object or a function that returns one
* synchronously, so the per-staffId binding has to live outside this file
* (see docs/phase-6-summary.md for where this constraint was first found).
*/
export async function ownerCreateBreakAction(
staffId: string,
input: BreakInput,
acknowledgeConflicts?: boolean,
): Promise<BreakActionResult> {
const { owner, target, error } = await requireStaffManagerFor(staffId);
if (!owner || !target) return { success: false, error: error ?? "Not authorized." };
const result = await createBreak(prisma, {
staffId,
businessTimezone: owner.business.timezone,
input,
acknowledgeConflicts,
});
if (!result.success) {
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't create this break.", conflicts: mapConflicts(result.conflicts) };
}
await writeAuditLog(prisma, {
actorUserId: owner.userId,
action: "STAFF_BREAK_CHANGED",
entityType: "StaffProfile",
entityId: staffId,
});
await notifyStaffScheduleChanged(prisma, {
businessId: owner.businessId,
staffUserId: target.userId,
changedByName: owner.user.name ?? "The business owner",
whatChanged: "your breaks",
viewSchedulePath: "/staff/settings/breaks",
});
return { success: true };
}
export async function ownerUpdateBreakAction(
staffId: string,
breakId: string,
input: BreakInput,
acknowledgeConflicts?: boolean,
): Promise<BreakActionResult> {
const { owner, target, error } = await requireStaffManagerFor(staffId);
if (!owner || !target) return { success: false, error: error ?? "Not authorized." };
const result = await updateBreak(prisma, {
breakId,
staffId,
businessTimezone: owner.business.timezone,
input,
acknowledgeConflicts,
});
if (!result.success) {
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't update this break.", conflicts: mapConflicts(result.conflicts) };
}
await writeAuditLog(prisma, {
actorUserId: owner.userId,
action: "STAFF_BREAK_CHANGED",
entityType: "StaffProfile",
entityId: staffId,
});
await notifyStaffScheduleChanged(prisma, {
businessId: owner.businessId,
staffUserId: target.userId,
changedByName: owner.user.name ?? "The business owner",
whatChanged: "your breaks",
viewSchedulePath: "/staff/settings/breaks",
});
return { success: true };
}
export async function ownerDeleteBreakAction(staffId: string, breakId: string): Promise<BreakActionResult> {
const { owner, target, error } = await requireStaffManagerFor(staffId);
if (!owner || !target) return { success: false, error: error ?? "Not authorized." };
const result = await deleteBreak(prisma, { breakId, staffId });
if (!result.success) return { success: false, error: "Couldn't delete this break." };
await writeAuditLog(prisma, {
actorUserId: owner.userId,
action: "STAFF_BREAK_CHANGED",
entityType: "StaffProfile",
entityId: staffId,
});
await notifyStaffScheduleChanged(prisma, {
businessId: owner.businessId,
staffUserId: target.userId,
changedByName: owner.user.name ?? "The business owner",
whatChanged: "your breaks",
viewSchedulePath: "/staff/settings/breaks",
});
return { success: true };
}
+45 -4
View File
@@ -15,6 +15,7 @@ import {
} from "@/services/staff/staff.service";
import type { CreateStaffInput, UpdateStaffAsOwnerInput, WorkingHoursDayInput } from "@/services/staff/staff.service";
import { createInvitation, regenerateInvitation } from "@/services/staff/staff-invitation.service";
import { notifyStaffScheduleChanged } from "@/services/staff/staff-notifications";
async function requireStaffManager() {
const session = await auth();
@@ -27,7 +28,10 @@ async function requireStaffManager() {
throw error;
}
const staff = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
const staff = await prisma.staffProfile.findUnique({
where: { userId: session.user.id },
include: { business: { select: { timezone: true } }, user: { select: { name: true } } },
});
if (!staff) return { staff: null, error: "You must be signed in as staff." } as const;
return { staff, error: null } as const;
@@ -182,15 +186,31 @@ export async function setStaffServiceEligibilityAction(
return { success: true };
}
export interface WorkingHoursConflictItem {
id: string;
customerName: string;
serviceNames: string;
startAt: string;
}
export interface WorkingHoursActionResult {
success: boolean;
error?: string;
conflicts?: WorkingHoursConflictItem[];
}
/** Owner-on-behalf-of-staff working hours — same StaffService.setWorkingHours call the barber's own Phase 5 editor uses, just targeting a chosen staffId instead of the caller. No conflict detection here, matching the existing self-service editor's behaviour (a documented gap — see docs/phase-6-summary.md). */
/**
* Owner-on-behalf-of-staff working hours — same StaffService.setWorkingHours
* call the barber's own Phase 5 editor uses, just targeting a chosen
* staffId instead of the caller. Now conflict-reviewed like every other
* owner mutation that can strand an appointment (see
* docs/phase-7-summary.md, "Working-hours conflict review") — this closes
* the gap Phase 6 documented and deliberately left open.
*/
export async function updateStaffWorkingHoursAction(
staffId: string,
days: WorkingHoursDayInput[],
acknowledgeConflicts = false,
): Promise<WorkingHoursActionResult> {
const { staff: owner, error } = await requireStaffManager();
if (!owner) return { success: false, error: error ?? "Not authorized." };
@@ -198,9 +218,23 @@ export async function updateStaffWorkingHoursAction(
const target = await prisma.staffProfile.findFirst({ where: { id: staffId, businessId: owner.businessId } });
if (!target) return { success: false, error: "Staff member not found." };
const result = await setWorkingHours(prisma, { staffId, days });
const result = await setWorkingHours(prisma, {
staffId,
businessTimezone: owner.business.timezone,
days,
acknowledgeConflicts,
});
if (!result.success) {
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't save working hours." };
return {
success: false,
error: result.validation.issues[0]?.message ?? "Couldn't save working hours.",
conflicts: result.conflicts.map((c) => ({
id: c.id,
customerName: `${c.customer.firstName} ${c.customer.lastName}`,
serviceNames: c.services.map((s) => s.service.name).join(" + "),
startAt: c.startAt.toISOString(),
})),
};
}
await writeAuditLog(prisma, {
@@ -209,6 +243,13 @@ export async function updateStaffWorkingHoursAction(
entityType: "StaffProfile",
entityId: staffId,
});
await notifyStaffScheduleChanged(prisma, {
businessId: owner.businessId,
staffUserId: target.userId,
changedByName: owner.user.name ?? "The business owner",
whatChanged: "your working hours",
viewSchedulePath: "/staff/settings/working-hours",
});
return { success: true };
}
@@ -0,0 +1,17 @@
import { ownerCreateTimeOffAction, ownerDeleteTimeOffAction, ownerUpdateTimeOffAction } from "./time-off";
import type { TimeOffActionSet } from "@/features/staff/components/time-off-form-sheet";
/**
* Plain module (no "use server") assembling the owner time-off Server
* Actions into the `TimeOffActionSet` shape TimeOffList/TimeOffFormSheet
* expect, bound to one target staff member — see break-action-set.ts for
* why `.bind(null, staffId)` is required here instead of an arrow-function
* wrapper.
*/
export function createOwnerTimeOffActionSet(staffId: string): TimeOffActionSet {
return {
create: ownerCreateTimeOffAction.bind(null, staffId),
update: ownerUpdateTimeOffAction.bind(null, staffId),
remove: ownerDeleteTimeOffAction.bind(null, staffId),
};
}
+146
View File
@@ -0,0 +1,146 @@
"use server";
import { auth } from "@/auth";
import { prisma } from "@/lib/db/prisma";
import { Permission } from "@/generated/prisma/enums";
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
import { writeAuditLog } from "@/lib/audit/write-audit-log";
import { createTimeOff, deleteTimeOff, updateTimeOff } from "@/services/staff/staff.service";
import type { TimeOffInput } from "@/services/staff/staff.service";
import { notifyStaffScheduleChanged } from "@/services/staff/staff-notifications";
export interface TimeOffConflictItem {
id: string;
customerName: string;
serviceNames: string;
startAt: string;
}
export interface TimeOffActionResult {
success: boolean;
error?: string;
conflicts?: TimeOffConflictItem[];
}
function mapConflicts(conflicts: { id: string; customer: { firstName: string; lastName: string }; services: { service: { name: string } }[]; startAt: Date }[]): TimeOffConflictItem[] {
return conflicts.map((c) => ({
id: c.id,
customerName: `${c.customer.firstName} ${c.customer.lastName}`,
serviceNames: c.services.map((s) => s.service.name).join(" + "),
startAt: c.startAt.toISOString(),
}));
}
async function requireStaffManagerFor(staffId: string) {
const session = await auth();
if (!session?.user) return { owner: null, target: null, error: "You must be signed in." } as const;
try {
await assertOwnerPermission(session.user.id, Permission.MANAGE_STAFF);
} catch (error) {
if (error instanceof OwnerPermissionDeniedError) return { owner: null, target: null, error: error.message } as const;
throw error;
}
const owner = await prisma.staffProfile.findUnique({
where: { userId: session.user.id },
include: { business: { select: { name: true, timezone: true } }, user: { select: { name: true } } },
});
if (!owner) return { owner: null, target: null, error: "You must be signed in as staff." } as const;
const target = await prisma.staffProfile.findFirst({ where: { id: staffId, businessId: owner.businessId } });
if (!target) return { owner: null, target: null, error: "Staff member not found." } as const;
return { owner, target, error: null } as const;
}
/** Owner-on-behalf-of-staff time off — mirrors breaks.ts exactly, including why the per-staffId binding lives in the separate time-off-action-set.ts factory rather than here. */
export async function ownerCreateTimeOffAction(
staffId: string,
input: Pick<TimeOffInput, "startDate" | "endDate" | "reason">,
acknowledgeConflicts?: boolean,
): Promise<TimeOffActionResult> {
const { owner, target, error } = await requireStaffManagerFor(staffId);
if (!owner || !target) return { success: false, error: error ?? "Not authorized." };
const result = await createTimeOff(prisma, {
staffId,
businessTimezone: owner.business.timezone,
input,
acknowledgeConflicts,
});
if (!result.success) {
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't add this time off.", conflicts: mapConflicts(result.conflicts) };
}
await writeAuditLog(prisma, {
actorUserId: owner.userId,
action: "STAFF_TIME_OFF_CHANGED",
entityType: "StaffProfile",
entityId: staffId,
});
await notifyStaffScheduleChanged(prisma, {
businessId: owner.businessId,
staffUserId: target.userId,
changedByName: owner.user.name ?? "The business owner",
whatChanged: "your time off",
viewSchedulePath: "/staff/settings/time-off",
});
return { success: true };
}
export async function ownerUpdateTimeOffAction(
staffId: string,
timeOffId: string,
input: Pick<TimeOffInput, "startDate" | "endDate" | "reason">,
acknowledgeConflicts?: boolean,
): Promise<TimeOffActionResult> {
const { owner, target, error } = await requireStaffManagerFor(staffId);
if (!owner || !target) return { success: false, error: error ?? "Not authorized." };
const result = await updateTimeOff(prisma, {
timeOffId,
staffId,
businessTimezone: owner.business.timezone,
input,
acknowledgeConflicts,
});
if (!result.success) {
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't update this time off.", conflicts: mapConflicts(result.conflicts) };
}
await writeAuditLog(prisma, {
actorUserId: owner.userId,
action: "STAFF_TIME_OFF_CHANGED",
entityType: "StaffProfile",
entityId: staffId,
});
await notifyStaffScheduleChanged(prisma, {
businessId: owner.businessId,
staffUserId: target.userId,
changedByName: owner.user.name ?? "The business owner",
whatChanged: "your time off",
viewSchedulePath: "/staff/settings/time-off",
});
return { success: true };
}
export async function ownerDeleteTimeOffAction(staffId: string, timeOffId: string): Promise<TimeOffActionResult> {
const { owner, target, error } = await requireStaffManagerFor(staffId);
if (!owner || !target) return { success: false, error: error ?? "Not authorized." };
const result = await deleteTimeOff(prisma, { timeOffId, staffId });
if (!result.success) return { success: false, error: "Couldn't remove this time off." };
await writeAuditLog(prisma, {
actorUserId: owner.userId,
action: "STAFF_TIME_OFF_CHANGED",
entityType: "StaffProfile",
entityId: staffId,
});
await notifyStaffScheduleChanged(prisma, {
businessId: owner.businessId,
staffUserId: target.userId,
changedByName: owner.user.name ?? "The business owner",
whatChanged: "your time off",
viewSchedulePath: "/staff/settings/time-off",
});
return { success: true };
}
@@ -158,7 +158,7 @@ export function BookingRulesForm({ initial }: { initial: BookingRuleInput }) {
</p>
)}
{saved && !error && (
<p role="status" className="text-accent text-sm font-medium">
<p role="status" className="text-accent-foreground text-sm font-medium">
Saved — the customer booking flow uses these rules immediately. Existing appointments are unaffected.
</p>
)}
@@ -193,7 +193,7 @@ export function BusinessDetailsForm({ initial }: { initial: BusinessDetailsIniti
</p>
)}
{saved && !error && (
<p role="status" className="text-accent text-sm font-medium">
<p role="status" className="text-accent-foreground text-sm font-medium">
Saved — changes are live for customers immediately.
</p>
)}
@@ -158,7 +158,12 @@ export function LoyaltyConfigForm({
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Rules &amp; Expiry</h3>
<div className="rounded-jumbo border-line space-y-5 border bg-white p-6">
<Switch label="Apply reward automatically" checked={config.autoApplyReward} onCheckedChange={(v) => set("autoApplyReward", v)} />
<Switch
label="Apply reward automatically"
description="Redemption is always staff-initiated this phase — this setting is saved but has no automatic effect yet."
checked={config.autoApplyReward}
onCheckedChange={(v) => set("autoApplyReward", v)}
/>
<Switch
label="Require staff approval to redeem"
description="Redemption already requires a permitted staff member — this is informational only this phase."
@@ -203,7 +208,7 @@ export function LoyaltyConfigForm({
</p>
)}
{saved && !error && (
<p role="status" className="text-accent text-sm font-medium">
<p role="status" className="text-accent-foreground text-sm font-medium">
Saved.
</p>
)}
@@ -189,7 +189,7 @@ export function OpeningHoursForm({ initialRows }: { initialRows: InitialOpeningH
</p>
)}
{saved && !error && (
<p role="status" className="text-accent text-sm font-medium">
<p role="status" className="text-accent-foreground text-sm font-medium">
Saved. The booking engine reflects these hours immediately.
</p>
)}
@@ -162,7 +162,7 @@ export function StaffDetailPanel({
</p>
)}
{roleSaved && !roleError && (
<p role="status" className="text-accent text-sm font-medium">
<p role="status" className="text-accent-foreground text-sm font-medium">
Saved.
</p>
)}
@@ -206,7 +206,7 @@ export function StaffDetailPanel({
</p>
)}
{servicesSaved && !servicesError && (
<p role="status" className="text-accent text-sm font-medium">
<p role="status" className="text-accent-foreground text-sm font-medium">
Saved.
</p>
)}
@@ -220,11 +220,19 @@ 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 overflow-hidden border bg-white">
<div className="rounded-jumbo 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>
</Link>
<Link href={`/owner/team/${staffId}/breaks`} className="hover:bg-stone flex items-center justify-between px-6 py-4">
<span className="text-sm font-bold">Breaks</span>
<span className="text-muted text-xs">Edit</span>
</Link>
<Link href={`/owner/team/${staffId}/time-off`} className="hover:bg-stone flex items-center justify-between px-6 py-4">
<span className="text-sm font-bold">Time Off</span>
<span className="text-muted text-xs">Edit</span>
</Link>
</div>
</section>
@@ -0,0 +1,89 @@
"use client";
import { Download } from "lucide-react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
}
function isStandalone(): boolean {
if (typeof window === "undefined") return false;
return (
window.matchMedia("(display-mode: standalone)").matches ||
// iOS Safari's own (non-standard, but the only signal it exposes).
(window.navigator as Navigator & { standalone?: boolean }).standalone === true
);
}
function isIos(): boolean {
if (typeof navigator === "undefined") return false;
return /iphone|ipad|ipod/i.test(navigator.userAgent);
}
/**
* Chrome/Edge/Android fire `beforeinstallprompt` when the manifest +
* service worker criteria are met — we capture and defer it (the spec
* requires preventDefault() to control exactly when the native prompt
* shows) and trigger it from this button instead of an unsolicited popup.
* iOS Safari never fires that event at all, so it gets manual
* "Add to Home Screen" instructions instead — there's no programmatic
* install API on that platform.
*/
export function InstallAppButton() {
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
const [installed, setInstalled] = useState(() => isStandalone());
const [showIosInstructions, setShowIosInstructions] = useState(false);
useEffect(() => {
const onBeforeInstallPrompt = (event: Event) => {
event.preventDefault();
setDeferredPrompt(event as BeforeInstallPromptEvent);
};
const onInstalled = () => {
setInstalled(true);
setDeferredPrompt(null);
};
window.addEventListener("beforeinstallprompt", onBeforeInstallPrompt);
window.addEventListener("appinstalled", onInstalled);
return () => {
window.removeEventListener("beforeinstallprompt", onBeforeInstallPrompt);
window.removeEventListener("appinstalled", onInstalled);
};
}, []);
if (installed) {
return <p className="text-muted text-sm">NextChair is installed on this device.</p>;
}
if (deferredPrompt) {
return (
<Button
size="sm"
onClick={async () => {
await deferredPrompt.prompt();
await deferredPrompt.userChoice;
setDeferredPrompt(null);
}}
>
<Download className="h-4 w-4" aria-hidden="true" /> Install App
</Button>
);
}
if (isIos()) {
return showIosInstructions ? (
<p className="text-muted text-sm">
Tap the Share icon in Safari, then choose &ldquo;Add to Home Screen&rdquo;.
</p>
) : (
<Button size="sm" variant="secondary" onClick={() => setShowIosInstructions(true)}>
<Download className="h-4 w-4" aria-hidden="true" /> Install App
</Button>
);
}
return <p className="text-muted text-sm">Installing isn&apos;t available in this browser yet.</p>;
}
@@ -0,0 +1,81 @@
"use client";
import { useEffect, useState } from "react";
/**
* Registers the service worker once, app-wide, and surfaces the
* update-available handshake public/sw.js sets up: a new SW version
* installs and sits `waiting` rather than taking over immediately, so an
* open tab isn't silently switched to a new app version mid-session. This
* component detects that waiting worker and lets the user opt into the
* refresh instead.
*/
export function ServiceWorkerManager() {
const [waitingWorker, setWaitingWorker] = useState<ServiceWorker | null>(null);
const [dismissed, setDismissed] = useState(false);
useEffect(() => {
if (!("serviceWorker" in navigator)) return;
navigator.serviceWorker
.register("/sw.js")
.then((registration) => {
// An update was already downloaded (e.g. by another tab) before this page loaded.
if (registration.waiting && navigator.serviceWorker.controller) {
setWaitingWorker(registration.waiting);
}
registration.addEventListener("updatefound", () => {
const installing = registration.installing;
if (!installing) return;
installing.addEventListener("statechange", () => {
// Only a real update — an installing worker with no existing
// controller is just this tab's first-ever SW install, not an update.
if (installing.state === "installed" && navigator.serviceWorker.controller) {
setWaitingWorker(installing);
}
});
});
})
.catch(() => {
// Best-effort — offline/install/update support degrades gracefully without it.
});
let reloaded = false;
const onControllerChange = () => {
if (reloaded) return;
reloaded = true;
window.location.reload();
};
navigator.serviceWorker.addEventListener("controllerchange", onControllerChange);
return () => navigator.serviceWorker.removeEventListener("controllerchange", onControllerChange);
}, []);
if (!waitingWorker || dismissed) return null;
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"
>
<span>A new version is available.</span>
<div className="flex items-center gap-3">
<button
type="button"
className="font-bold whitespace-nowrap underline"
onClick={() => waitingWorker.postMessage({ type: "SKIP_WAITING" })}
>
Refresh
</button>
<button
type="button"
aria-label="Dismiss"
className="text-white/60"
onClick={() => setDismissed(true)}
>
✕
</button>
</div>
</div>
);
}
@@ -12,6 +12,7 @@ import {
start,
} from "@/services/scheduling/appointment.service";
import { awardStampForCompletedAppointment } from "@/services/loyalty/loyalty.service";
import { notifyAppointmentNoShow, notifyBookingApproved, notifyBookingCompleted } from "@/services/scheduling/booking-notifications";
import { assertOwnAppointment, StaffPermissionDeniedError } from "../authorization";
/**
@@ -58,9 +59,11 @@ export async function confirmAppointmentAction(
if (!appointment) return { success: false, error: error ?? "Not found." };
const result = await confirm(prisma, appointmentId);
return result.success
? { success: true }
: { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
if (!result.success) {
return { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
}
await notifyBookingApproved(prisma, result.appointment);
return { success: true };
}
export async function checkInAppointmentAction(
@@ -97,6 +100,7 @@ export async function completeAppointmentAction(
if (!result.success) {
return { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
}
await notifyBookingCompleted(prisma, result.appointment);
const loyaltyResult = await awardStampForCompletedAppointment(prisma, {
appointmentId,
@@ -115,9 +119,11 @@ export async function markNoShowAction(
if (!appointment) return { success: false, error: error ?? "Not found." };
const result = await markNoShow(prisma, appointmentId);
return result.success
? { success: true }
: { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
if (!result.success) {
return { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
}
await notifyAppointmentNoShow(prisma, result.appointment);
return { success: true };
}
export async function cancelStaffAppointmentAction(params: {
+13 -4
View File
@@ -1,9 +1,11 @@
"use server";
import { NotificationType } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db/prisma";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { setNotificationPreferences, updateStaffProfile } from "@/services/staff/staff.service";
import type { NotificationPreferenceCell } from "@/services/staff/staff.service";
import { setNotificationPreference } from "@/services/notifications/notification.service";
import type { PreferenceCellState } from "@/features/staff/components/notification-preferences-form";
import { updateStaffProfile } from "@/services/staff/staff.service";
export interface ProfileActionResult {
success: boolean;
@@ -35,11 +37,18 @@ export async function updateProfileAction(input: {
}
export async function saveNotificationPreferencesAction(
cells: NotificationPreferenceCell[],
cells: PreferenceCellState[],
): Promise<ProfileActionResult> {
const staff = await getActingStaff();
if (!staff) return { success: false, error: "You must be signed in as staff." };
await setNotificationPreferences(prisma, staff.userId, cells);
for (const cell of cells) {
await setNotificationPreference(prisma, {
userId: staff.userId,
type: NotificationType.STAFF_APPOINTMENT_REMINDER,
channel: cell.channel,
isEnabled: cell.isEnabled,
});
}
return { success: true };
}
+1 -1
View File
@@ -174,7 +174,7 @@ export async function confirmStaffBookingAction(notes?: string): Promise<StaffBo
return { success: false, error: getFirstFriendlyMessage(result.validation) };
}
await writeLastBookingReference(result.appointment.id);
await writeLastBookingReference({ appointmentId: result.appointment.id });
await clearStaffDraft();
redirect(`/staff/appointments/${result.appointment.id}`);
}
+28 -3
View File
@@ -5,18 +5,43 @@ import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { setWorkingHours } from "@/services/staff/staff.service";
import type { WorkingHoursDayInput } from "@/services/staff/staff.service";
export interface WorkingHoursConflictItem {
id: string;
customerName: string;
serviceNames: string;
startAt: string;
}
export interface WorkingHoursActionResult {
success: boolean;
error?: string;
conflicts?: WorkingHoursConflictItem[];
}
export async function saveWorkingHoursAction(days: WorkingHoursDayInput[]): Promise<WorkingHoursActionResult> {
export async function saveWorkingHoursAction(
days: WorkingHoursDayInput[],
acknowledgeConflicts = false,
): Promise<WorkingHoursActionResult> {
const staff = await getActingStaff();
if (!staff) return { success: false, error: "You must be signed in as staff." };
const result = await setWorkingHours(prisma, { staffId: staff.id, days });
const result = await setWorkingHours(prisma, {
staffId: staff.id,
businessTimezone: staff.business.timezone,
days,
acknowledgeConflicts,
});
if (!result.success) {
return { success: false, error: result.validation.issues[0]?.message ?? "Couldn't save working hours." };
return {
success: false,
error: result.validation.issues[0]?.message ?? "Couldn't save working hours.",
conflicts: result.conflicts.map((c) => ({
id: c.id,
customerName: `${c.customer.firstName} ${c.customer.lastName}`,
serviceNames: c.services.map((s) => s.service.name).join(" + "),
startAt: c.startAt.toISOString(),
})),
};
}
return { success: true };
}
@@ -9,6 +9,20 @@ import { Input } from "@/components/ui/input";
import { Weekday } from "@/generated/prisma/enums";
import type { BreakActionResult, BreakConflictItem } from "../actions/breaks";
import { createBreakAction, deleteBreakAction, updateBreakAction } from "../actions/breaks";
import type { BreakInput } from "@/services/staff/staff.service";
/** Same DI shape as AppointmentStatusActions' `actions` prop — the owner-on-behalf-of-staff editor injects its own Server Actions bound to a chosen staffId instead of forking this component. */
export interface BreakActionSet {
create: (input: BreakInput, acknowledgeConflicts?: boolean) => Promise<BreakActionResult>;
update: (breakId: string, input: BreakInput, acknowledgeConflicts?: boolean) => Promise<BreakActionResult>;
remove: (breakId: string) => Promise<BreakActionResult>;
}
const DEFAULT_ACTIONS: BreakActionSet = {
create: createBreakAction,
update: updateBreakAction,
remove: deleteBreakAction,
};
const DAY_ORDER: Weekday[] = [
Weekday.MONDAY,
@@ -53,10 +67,14 @@ export function BreakFormSheet({
open,
onClose,
existing,
actions = DEFAULT_ACTIONS,
appointmentHrefBase = "/staff/appointments",
}: {
open: boolean;
onClose: () => void;
existing?: EditableBreak | null;
actions?: BreakActionSet;
appointmentHrefBase?: string;
}) {
const router = useRouter();
const [label, setLabel] = useState(existing?.label ?? "Break");
@@ -105,8 +123,8 @@ export function BreakFormSheet({
startTransition(async () => {
const input = { label, startMinutes, endMinutes, days };
const result: BreakActionResult = existing
? await updateBreakAction(existing.id, input, acknowledgeConflicts)
: await createBreakAction(input, acknowledgeConflicts);
? await actions.update(existing.id, input, acknowledgeConflicts)
: await actions.create(input, acknowledgeConflicts);
if (!result.success) {
setError(result.error ?? "Something went wrong.");
setConflicts(result.conflicts ?? null);
@@ -120,7 +138,7 @@ export function BreakFormSheet({
const handleDelete = () => {
if (!existing) return;
startDeleteTransition(async () => {
const result = await deleteBreakAction(existing.id);
const result = await actions.remove(existing.id);
if (!result.success) {
setError(result.error ?? "Couldn't delete this break.");
return;
@@ -142,7 +160,7 @@ export function BreakFormSheet({
<ul className="space-y-2">
{conflicts.map((c) => (
<li key={c.id} className="border-line rounded-lg border bg-white p-3 text-xs">
<Link href={`/staff/appointments/${c.id}`} className="font-bold underline">
<Link href={`${appointmentHrefBase}/${c.id}`} className="font-bold underline">
{c.customerName}
</Link>
<p className="text-muted">{c.serviceNames}</p>
+12 -2
View File
@@ -5,7 +5,7 @@ import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Weekday } from "@/generated/prisma/enums";
import { BreakFormSheet } from "./break-form-sheet";
import type { EditableBreak } from "./break-form-sheet";
import type { BreakActionSet, EditableBreak } from "./break-form-sheet";
const DAY_SHORT: Record<Weekday, string> = {
MONDAY: "Mon",
@@ -40,7 +40,15 @@ function toTimeString(minutes: number): string {
return `${h12}:${String(m).padStart(2, "0")} ${period}`;
}
export function BreaksList({ breaks }: { breaks: EditableBreak[] }) {
export function BreaksList({
breaks,
actions,
appointmentHrefBase,
}: {
breaks: EditableBreak[];
actions?: BreakActionSet;
appointmentHrefBase?: string;
}) {
const [sheetOpen, setSheetOpen] = useState(false);
const [editing, setEditing] = useState<EditableBreak | null>(null);
@@ -90,6 +98,8 @@ export function BreaksList({ breaks }: { breaks: EditableBreak[] }) {
open={sheetOpen}
onClose={() => setSheetOpen(false)}
existing={editing}
actions={actions}
appointmentHrefBase={appointmentHrefBase}
/>
</div>
);
@@ -3,59 +3,48 @@
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { NotificationCategory, NotificationChannel } from "@/generated/prisma/enums";
import { saveNotificationPreferencesAction } from "../actions/profile";
import { NotificationChannel } from "@/generated/prisma/enums";
const CATEGORY_LABEL: Record<NotificationCategory, string> = {
BOOKING_CONFIRMATION: "New booking confirmed",
BOOKING_REMINDER: "Upcoming appointment reminders",
BOOKING_CHANGED: "Appointment rescheduled",
BOOKING_CANCELLED: "Appointment cancelled",
LOYALTY_REWARD_EARNED: "Customer earns a loyalty stamp",
LOYALTY_REWARD_AVAILABLE: "Customer reward becomes available",
MARKETING: "Product news and tips",
};
const CATEGORY_ORDER: NotificationCategory[] = [
NotificationCategory.BOOKING_CONFIRMATION,
NotificationCategory.BOOKING_REMINDER,
NotificationCategory.BOOKING_CHANGED,
NotificationCategory.BOOKING_CANCELLED,
NotificationCategory.LOYALTY_REWARD_EARNED,
NotificationCategory.LOYALTY_REWARD_AVAILABLE,
NotificationCategory.MARKETING,
];
const CHANNEL_ORDER: NotificationChannel[] = [
NotificationChannel.EMAIL,
NotificationChannel.SMS,
NotificationChannel.PUSH,
];
const CHANNEL_LABEL: Record<NotificationChannel, string> = { EMAIL: "Email", SMS: "SMS", PUSH: "Push" };
const CHANNEL_ORDER: NotificationChannel[] = [NotificationChannel.EMAIL, NotificationChannel.PUSH];
const CHANNEL_LABEL: Record<NotificationChannel, string> = { EMAIL: "Email", PUSH: "Push", IN_APP: "In-app" };
export interface PreferenceCellState {
category: NotificationCategory;
channel: NotificationChannel;
isEnabled: boolean;
}
export function NotificationPreferencesForm({ initial }: { initial: PreferenceCellState[] }) {
/**
* Every other notification a barber gets (new appointment, reschedule,
* cancellation, schedule change) is required — always sent, no toggle —
* so this form only ever shows the one type that's genuinely optional:
* appointment reminders. See NotificationService/types.ts,
* PREFERENCE_CONTROLLED_TYPES, and docs/phase-7-summary.md — showing a
* toggle for a required type would just be a no-op switch that does
* nothing when flipped, which the Phase 7 brief explicitly calls out to
* avoid.
*/
export interface NotificationPreferencesFormProps {
initial: PreferenceCellState[];
/** Recipient-specific — see features/staff/actions/profile.ts (staff) and features/notifications/actions/preferences.ts (customer). */
action: (cells: PreferenceCellState[]) => Promise<{ success: boolean; error?: string }>;
}
export function NotificationPreferencesForm({ initial, action }: NotificationPreferencesFormProps) {
const router = useRouter();
const [cells, setCells] = useState(initial);
const [saved, setSaved] = useState(false);
const [isPending, startTransition] = useTransition();
const isEnabled = (category: NotificationCategory, channel: NotificationChannel) =>
cells.find((c) => c.category === category && c.channel === channel)?.isEnabled ?? true;
const isEnabled = (channel: NotificationChannel) => cells.find((c) => c.channel === channel)?.isEnabled ?? true;
const toggle = (category: NotificationCategory, channel: NotificationChannel) => {
const toggle = (channel: NotificationChannel) => {
setSaved(false);
setCells((prev) =>
prev.map((c) => (c.category === category && c.channel === channel ? { ...c, isEnabled: !c.isEnabled } : c)),
);
setCells((prev) => prev.map((c) => (c.channel === channel ? { ...c, isEnabled: !c.isEnabled } : c)));
};
const handleSave = () => {
startTransition(async () => {
await saveNotificationPreferencesAction(cells);
await action(cells);
setSaved(true);
router.refresh();
});
@@ -64,52 +53,30 @@ export function NotificationPreferencesForm({ initial }: { initial: PreferenceCe
return (
<div className="space-y-4">
<p className="text-muted text-sm">
Choose which of these you want to hear about, and how. This controls what you receive —
delivery isn&apos;t wired up yet.
Booking confirmations, changes, and cancellations always notify you — those aren&apos;t
optional. Choose how you&apos;d like to hear about upcoming appointment reminders.
</p>
<div className="overflow-hidden rounded-jumbo border border-line bg-white">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-line">
<th scope="col" className="px-4 py-3 font-bold">
Notification
</th>
{CHANNEL_ORDER.map((channel) => (
<th key={channel} scope="col" className="px-2 py-3 text-center text-xs font-bold">
{CHANNEL_LABEL[channel]}
</th>
))}
</tr>
</thead>
<tbody>
{CATEGORY_ORDER.map((category) => (
<tr key={category} className="border-b border-line last:border-b-0">
<th scope="row" className="px-4 py-3 text-xs font-medium">
{CATEGORY_LABEL[category]}
</th>
{CHANNEL_ORDER.map((channel) => (
<td key={channel} className="px-2 py-3 text-center">
<button
type="button"
role="switch"
aria-checked={isEnabled(category, channel)}
aria-label={`${CATEGORY_LABEL[category]} via ${CHANNEL_LABEL[channel]}`}
onClick={() => toggle(category, channel)}
className={`h-5 w-5 rounded-md border transition-colors ${
isEnabled(category, channel) ? "border-accent bg-accent" : "border-line bg-white"
}`}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
<div className="rounded-jumbo 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>
<button
type="button"
role="switch"
aria-checked={isEnabled(channel)}
aria-label={`Appointment reminders via ${CHANNEL_LABEL[channel]}`}
onClick={() => toggle(channel)}
className={`h-5 w-5 rounded-md border transition-colors ${
isEnabled(channel) ? "border-accent bg-accent" : "border-line bg-white"
}`}
/>
</div>
))}
</div>
{saved && (
<p role="status" className="text-accent text-sm font-medium">
<p role="status" className="text-accent-foreground text-sm font-medium">
Saved.
</p>
)}
@@ -79,7 +79,7 @@ export function ProfileForm({ initial }: { initial: ProfileFormInitial }) {
</p>
)}
{saved && !error && (
<p role="status" className="text-accent text-sm font-medium">
<p role="status" className="text-accent-foreground text-sm font-medium">
Saved.
</p>
)}
@@ -8,6 +8,7 @@ import { BottomSheet } from "@/components/ui/bottom-sheet";
import { Input } from "@/components/ui/input";
import type { TimeOffActionResult, TimeOffConflictItem } from "../actions/time-off";
import { createTimeOffAction, deleteTimeOffAction, updateTimeOffAction } from "../actions/time-off";
import type { TimeOffInput } from "@/services/staff/staff.service";
export interface EditableTimeOff {
id: string;
@@ -16,6 +17,26 @@ export interface EditableTimeOff {
reason: string | null;
}
/** Same DI shape as AppointmentStatusActions' `actions` prop — the owner-on-behalf-of-staff editor injects its own Server Actions bound to a chosen staffId instead of forking this component. */
export interface TimeOffActionSet {
create: (
input: Pick<TimeOffInput, "startDate" | "endDate" | "reason">,
acknowledgeConflicts?: boolean,
) => Promise<TimeOffActionResult>;
update: (
timeOffId: string,
input: Pick<TimeOffInput, "startDate" | "endDate" | "reason">,
acknowledgeConflicts?: boolean,
) => Promise<TimeOffActionResult>;
remove: (timeOffId: string) => Promise<TimeOffActionResult>;
}
const DEFAULT_ACTIONS: TimeOffActionSet = {
create: createTimeOffAction,
update: updateTimeOffAction,
remove: deleteTimeOffAction,
};
function todayIso(): string {
return new Date().toISOString().slice(0, 10);
}
@@ -24,10 +45,14 @@ export function TimeOffFormSheet({
open,
onClose,
existing,
actions = DEFAULT_ACTIONS,
appointmentHrefBase = "/staff/appointments",
}: {
open: boolean;
onClose: () => void;
existing?: EditableTimeOff | null;
actions?: TimeOffActionSet;
appointmentHrefBase?: string;
}) {
const router = useRouter();
const [startDate, setStartDate] = useState(existing?.startDate ?? todayIso());
@@ -60,8 +85,8 @@ export function TimeOffFormSheet({
startTransition(async () => {
const input = { startDate, endDate, reason: reason.trim() || undefined };
const result: TimeOffActionResult = existing
? await updateTimeOffAction(existing.id, input, acknowledgeConflicts)
: await createTimeOffAction(input, acknowledgeConflicts);
? await actions.update(existing.id, input, acknowledgeConflicts)
: await actions.create(input, acknowledgeConflicts);
if (!result.success) {
setError(result.error ?? "Something went wrong.");
setConflicts(result.conflicts ?? null);
@@ -75,7 +100,7 @@ export function TimeOffFormSheet({
const handleDelete = () => {
if (!existing) return;
startDeleteTransition(async () => {
const result = await deleteTimeOffAction(existing.id);
const result = await actions.remove(existing.id);
if (!result.success) {
setError(result.error ?? "Couldn't remove this time off.");
return;
@@ -98,7 +123,7 @@ export function TimeOffFormSheet({
<ul className="space-y-2">
{conflicts.map((c) => (
<li key={c.id} className="border-line rounded-lg border bg-white p-3 text-xs">
<Link href={`/staff/appointments/${c.id}`} className="font-bold underline">
<Link href={`${appointmentHrefBase}/${c.id}`} className="font-bold underline">
{c.customerName}
</Link>
<p className="text-muted">{c.serviceNames}</p>
@@ -3,7 +3,7 @@
import { Plus } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import type { EditableTimeOff } from "./time-off-form-sheet";
import type { EditableTimeOff, TimeOffActionSet } from "./time-off-form-sheet";
import { TimeOffFormSheet } from "./time-off-form-sheet";
function formatRange(startDate: string, endDate: string): string {
@@ -18,9 +18,13 @@ function formatRange(startDate: string, endDate: string): string {
export function TimeOffList({
upcoming,
past,
actions,
appointmentHrefBase,
}: {
upcoming: EditableTimeOff[];
past: EditableTimeOff[];
actions?: TimeOffActionSet;
appointmentHrefBase?: string;
}) {
const [sheetOpen, setSheetOpen] = useState(false);
const [editing, setEditing] = useState<EditableTimeOff | null>(null);
@@ -86,6 +90,8 @@ export function TimeOffList({
open={sheetOpen}
onClose={() => setSheetOpen(false)}
existing={editing}
actions={actions}
appointmentHrefBase={appointmentHrefBase}
/>
</div>
);
Loaded 100 of 189 files, more files were not shown because too many files have changed in this diff. Show more