Phase 7
This commit is contained in:
189 files changed
+9873
-655
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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([
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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([
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
@@ -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>
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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'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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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 };
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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 & 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 “Add to Home Screen”.
|
||||
</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'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: {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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't wired up yet.
|
||||
Booking confirmations, changes, and cancellations always notify you — those aren't
|
||||
optional. Choose how you'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
Reference in new issue
Block a user