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

No files matched your search

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

After

Width:  |  Height:  |  Size: 2.2 KiB

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

After

Width:  |  Height:  |  Size: 16 KiB

+15
View File
@@ -16,6 +16,11 @@ export const metadata: Metadata = {
},
description:
"Book your next barber appointment in seconds — services, staff and loyalty rewards in one place.",
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: "NextChair",
},
};
export const viewport: Viewport = {
@@ -24,6 +29,7 @@ export const viewport: Viewport = {
maximumScale: 1,
userScalable: false,
viewportFit: "cover",
themeColor: "#111b15",
};
export default function RootLayout({
@@ -34,6 +40,15 @@ export default function RootLayout({
return (
<html lang="en" className={inter.variable}>
<body className="font-sans">
{/* Visible only when focused — lets a keyboard user jump past the
bottom nav / sidebar chrome that's repeated on every page,
straight to the #main-content landmark each layout renders. */}
<a
href="#main-content"
className="bg-ink sr-only z-50 rounded-full px-4 py-2 text-sm font-bold text-white focus:not-sr-only focus:fixed focus:top-4 focus:left-4"
>
Skip to main content
</a>
<Providers>{children}</Providers>
</body>
</html>
+28
View File
@@ -0,0 +1,28 @@
import type { MetadataRoute } from "next";
/**
* Next.js's manifest file convention — auto-served at /manifest.webmanifest
* with the <link rel="manifest"> tag injected automatically, so nothing in
* layout.tsx needs to reference this directly. Single business per
* deployment (see docs/implementation-plan.md), so the name/colors are
* static rather than read from the Business row.
*/
export default function manifest(): MetadataRoute.Manifest {
return {
name: "NextChair — Barber Booking",
short_name: "NextChair",
description: "Book your next barber appointment in seconds — services, staff and loyalty rewards in one place.",
start_url: "/home",
scope: "/",
display: "standalone",
background_color: "#f4f6f4",
theme_color: "#111b15",
orientation: "portrait-primary",
icons: [
{ src: "/icons/icon-192.png", sizes: "192x192", type: "image/png", purpose: "any" },
{ src: "/icons/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any" },
{ src: "/icons/icon-maskable-192.png", sizes: "192x192", type: "image/png", purpose: "maskable" },
{ src: "/icons/icon-maskable-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
],
};
}
+25
View File
@@ -0,0 +1,25 @@
import type { Metadata } from "next";
import { WifiOff } from "lucide-react";
export const metadata: Metadata = { title: "You're offline" };
/**
* The service worker's navigation fallback (see public/sw.js) — served
* from cache when a page request fails with no network. Deliberately
* static: no auth, no data fetching, nothing that could itself require a
* network round-trip, since the whole point is that it still renders with
* zero connectivity. Precached at service-worker install time.
*/
export default function OfflinePage() {
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-stone px-6 text-center">
<div className="bg-ink mb-6 flex h-16 w-16 items-center justify-center rounded-[1.25rem] text-white shadow-xl">
<WifiOff className="h-7 w-7" aria-hidden="true" />
</div>
<h1 className="mb-2 text-xl font-bold tracking-tight">You&apos;re offline</h1>
<p className="text-muted max-w-xs text-sm">
NextChair needs a connection for booking, schedules, and account details. Reconnect and try again.
</p>
</div>
);
}
@@ -6,5 +6,9 @@
* meant for "Confirm Reschedule".
*/
export default function OwnerRescheduleLayout({ children }: { children: React.ReactNode }) {
return <div className="bg-stone min-h-screen">{children}</div>;
return (
<div id="main-content" className="bg-stone min-h-screen">
{children}
</div>
);
}
+1 -1
View File
@@ -11,7 +11,7 @@ export default async function WelcomePage() {
}
return (
<div className="flex min-h-screen flex-col justify-end bg-ink px-8 pb-16 text-white">
<div id="main-content" className="flex min-h-screen flex-col justify-end bg-ink px-8 pb-16 text-white">
<div className="mb-8 flex h-16 w-16 items-center justify-center rounded-2xl bg-white text-ink">
<Scissors className="h-7 w-7" aria-hidden="true" />
</div>
@@ -6,5 +6,9 @@
* clicks meant for "Confirm Reschedule".
*/
export default function StaffRescheduleLayout({ children }: { children: React.ReactNode }) {
return <div className="bg-stone min-h-screen">{children}</div>;
return (
<div id="main-content" className="bg-stone min-h-screen">
{children}
</div>
);
}
+5 -1
View File
@@ -6,5 +6,9 @@
* the persistent staff bottom nav.
*/
export default function StaffBookLayout({ children }: { children: React.ReactNode }) {
return <div className="bg-stone min-h-screen">{children}</div>;
return (
<div id="main-content" className="bg-stone min-h-screen">
{children}
</div>
);
}