Revert "PHP Rebuild"

This reverts commit 2ef3654841.
This commit is contained in:
Stefan Lazic committed 2026-09-05 09:32:05 +01:00
1 parent a59f998f2b
commit 2aa8405006
690 files changed
+12045 -33943

No files matched your search

+51
View File
@@ -0,0 +1,51 @@
import type { Metadata } from "next";
import Link from "next/link";
import { prisma } from "@/lib/db/prisma";
import { validateInvitationToken } from "@/services/staff/staff-invitation.service";
import { AcceptInvitationForm } from "@/features/auth/components/accept-invitation-form";
export const metadata: Metadata = { title: "Accept Invitation" };
export const dynamic = "force-dynamic";
export default async function AcceptInvitationPage({
searchParams,
}: {
searchParams: Promise<{ token?: string }>;
}) {
const { token } = await searchParams;
if (!token) {
return <InvalidInvitation message="This invitation link is missing its token." />;
}
const check = await validateInvitationToken(prisma, token);
if (!check.valid) {
const message =
check.reason === "EXPIRED"
? "This invitation link has expired. Ask the business owner to send you a new one."
: check.reason === "ALREADY_ACCEPTED"
? "This invitation has already been used."
: "This invitation link isn't valid.";
return <InvalidInvitation message={message} />;
}
return (
<div>
<h1 className="mb-2 text-3xl font-bold tracking-tight">Welcome, {check.invitation.staff.user.name}</h1>
<p className="text-muted mb-10">Finish setting up your staff account.</p>
<AcceptInvitationForm token={token} businessName={check.invitation.staff.business.name} />
</div>
);
}
function InvalidInvitation({ message }: { message: string }) {
return (
<div>
<h1 className="mb-2 text-3xl font-bold tracking-tight">Invitation Not Available</h1>
<p className="text-muted mb-10">{message}</p>
<Link href="/sign-in" className="border-ink text-ink border-b font-bold text-sm">
Go to Sign In
</Link>
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { getBranding } from "@/lib/business/branding";
import { AuthHeader } from "@/features/auth/components/auth-header";
/**
* Every auth page (sign-in, register, forgot-password, verify) shares
* this shell, so the business logo is shown once here rather than on
* each page — see the brief's "Authentication Branding" requirement.
* Unreachable pre-install: src/proxy.ts redirects auth routes to
* /install until a business exists, so `getBranding()` never falls back
* to neutral wording here in practice.
*
* Forced dynamic: without this, a production build can statically
* prerender /sign-in and /register with whatever branding (or the
* neutral fallback) happened to be in the database at *build* time,
* baked into static HTML served forever after — a real business's name
* would never actually reach these pages in that deployment. See
* docs/phase-8-summary.md, "Known limitations" for how this was found
* (a Docker build with no live database at build time).
*/
export const dynamic = "force-dynamic";
export default async function AuthLayout({ children }: { children: React.ReactNode }) {
const branding = await getBranding();
return (
<div className="flex min-h-screen flex-col bg-stone p-6">
<div id="main-content" className="mx-auto flex w-full max-w-md flex-1 flex-col justify-center">
<AuthHeader logoUrl={branding.logoUrl} />
{children}
</div>
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { RegisterForm } from "@/features/auth/components/register-form";
import { getBranding } from "@/lib/business/branding";
export const metadata: Metadata = {
title: "Create Account",
};
export default async function RegisterPage() {
const branding = await getBranding();
return (
<div>
<h1 className="mb-2 text-3xl font-bold tracking-tight">Join {branding.name}</h1>
<p className="mb-5 text-sm text-muted">Create an account to track your appointments &amp; loyalty.</p>
{/* useSearchParams in RegisterForm requires a Suspense boundary. */}
<Suspense>
<RegisterForm />
</Suspense>
</div>
);
}
+24
View File
@@ -0,0 +1,24 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { SignInForm } from "@/features/auth/components/sign-in-form";
import { getBranding } from "@/lib/business/branding";
export const metadata: Metadata = {
title: "Sign In",
};
export default async function SignInPage() {
const branding = await getBranding();
return (
<div>
<h1 className="mb-2 text-2xl font-bold tracking-tight">Welcome back to {branding.name}</h1>
<p className="mb-12 text-sm leading-relaxed text-muted">
Please enter your credentials to access your appointments.
</p>
{/* useSearchParams in SignInForm requires a Suspense boundary. */}
<Suspense>
<SignInForm />
</Suspense>
</div>
);
}
@@ -0,0 +1,190 @@
import type { Metadata } from "next";
import { CalendarPlus, Phone } from "lucide-react";
import Link from "next/link";
import { notFound } from "next/navigation";
import { auth } from "@/auth";
import { Card } from "@/components/ui/card";
import { StatusPill } from "@/components/ui/status-pill";
import { TopNav } from "@/components/navigation/top-nav";
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
import { prisma } from "@/lib/db/prisma";
import { getAppointmentById } from "@/services/scheduling/appointment.service";
import {
assertCanAccessAppointment,
PermissionDeniedError,
} from "@/features/booking/authorization";
import { BookAgainButton } from "@/features/booking/components/book-again-button";
import { CancelAppointmentDialog } from "@/features/booking/components/cancel-appointment-dialog";
import type { AppointmentStatus } from "@/types/domain";
export const metadata: Metadata = { title: "Appointment Details" };
export const dynamic = "force-dynamic";
const STATUS_MAP: Record<string, AppointmentStatus> = {
PENDING: "pending_approval",
CONFIRMED: "confirmed",
CHECKED_IN: "arrived",
IN_PROGRESS: "in_progress",
COMPLETED: "completed",
CANCELLED: "cancelled",
NO_SHOW: "no_show",
};
const RESCHEDULABLE_STATUSES = new Set(["PENDING", "CONFIRMED", "CHECKED_IN"]);
const CANCELLABLE_STATUSES = new Set(["PENDING", "CONFIRMED", "CHECKED_IN"]);
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 {
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;
}
const business = await getPrimaryBusiness();
const totalDuration = appointment.services.reduce(
(sum, s) => sum + s.durationAtBookingMinutes,
0,
);
const totalPrice = appointment.services.reduce((sum, s) => sum + Number(s.priceAtBooking), 0);
let cancelledByLabel: string | null = null;
if (appointment.status === "CANCELLED" && appointment.cancelledByUserId) {
const canceller = await prisma.user.findUnique({
where: { id: appointment.cancelledByUserId },
select: { name: true },
});
cancelledByLabel = canceller?.name ?? null;
}
return (
<div className="pb-12">
<TopNav title="Appointment Details" onBack={true} sticky className="bg-stone" />
<main className="mx-auto max-w-2xl space-y-4 px-6 py-6">
<Card className="space-y-6 p-6">
<div className="flex items-center justify-between">
<StatusPill status={STATUS_MAP[appointment.status] ?? "confirmed"} />
<span className="text-muted text-[10px] font-bold tracking-widest uppercase">
Ref: {appointment.id.slice(-8)}
</span>
</div>
<div className="border-line space-y-1 border-t pt-4">
<p className="text-lg font-bold">
{appointment.services.map((s) => s.service.name).join(" + ")}
</p>
<p className="text-muted text-sm">
with {appointment.staff.user.name} · {business.name}
</p>
</div>
<div className="border-line grid grid-cols-2 gap-4 border-t pt-4 text-sm">
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Date
</span>
{appointment.startAt.toLocaleDateString("en-GB", {
weekday: "short",
day: "numeric",
month: "short",
timeZone: business.timezone,
})}
</div>
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Time
</span>
{appointment.startAt.toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
timeZone: business.timezone,
})}
</div>
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Duration
</span>
{totalDuration} min
</div>
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Total
</span>
£{totalPrice.toFixed(2)}
</div>
</div>
{appointment.notes && (
<div className="border-line border-t pt-4">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Your Notes
</span>
<p className="text-muted mt-1 text-sm italic">{appointment.notes}</p>
</div>
)}
{appointment.status === "CANCELLED" && (
<div className="border-line border-t pt-4 text-sm">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Cancellation
</span>
<p className="text-muted mt-1">
Cancelled{" "}
{appointment.cancelledAt?.toLocaleString("en-GB", { timeZone: business.timezone })}
{cancelledByLabel ? ` by ${cancelledByLabel}` : ""}
</p>
{appointment.cancellationReason && (
<p className="text-muted mt-1 italic">
&ldquo;{appointment.cancellationReason}&rdquo;
</p>
)}
</div>
)}
</Card>
<div className="flex flex-col gap-3">
<a
href={`/api/appointments/${appointment.id}/calendar.ics${token ? `?token=${token}` : ""}`}
className="rounded-md border-line flex w-full items-center justify-center gap-2 border bg-white px-5 py-3 text-sm font-bold"
>
<CalendarPlus className="text-accent h-4 w-4" aria-hidden="true" /> Add to Calendar
</a>
{business.phone && (
<a
href={`tel:${business.phone}`}
className="rounded-md border-line flex w-full items-center justify-center gap-2 border bg-white px-5 py-3 text-sm font-bold"
>
<Phone className="text-accent h-4 w-4" aria-hidden="true" /> Contact Business
</a>
)}
{RESCHEDULABLE_STATUSES.has(appointment.status) && (
<Link
href={`/appointments/${appointment.id}/reschedule${token ? `?token=${token}` : ""}`}
className="rounded-md border-line block w-full border bg-white px-5 py-3 text-center text-sm font-bold"
>
Reschedule
</Link>
)}
{CANCELLABLE_STATUSES.has(appointment.status) && (
<CancelAppointmentDialog appointmentId={appointment.id} guestToken={token} className="w-full" />
)}
{REBOOKABLE_STATUSES.has(appointment.status) && (
<BookAgainButton appointmentId={appointment.id} guestToken={token} className="w-full" />
)}
</div>
</main>
</div>
);
}
+183
View File
@@ -0,0 +1,183 @@
import type { Metadata } from "next";
import { CalendarPlus, LogIn } from "lucide-react";
import Link from "next/link";
import { auth } from "@/auth";
import { AppointmentCard } from "@/components/ui/appointment-card";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { EmptyState } from "@/components/feedback/empty-state";
import { TopNav } from "@/components/navigation/top-nav";
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
import { prisma } from "@/lib/db/prisma";
import { listAppointments } from "@/services/scheduling/appointment.service";
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
import { BookAgainButton } from "@/features/booking/components/book-again-button";
import { CancelAppointmentDialog } from "@/features/booking/components/cancel-appointment-dialog";
import { InlineNotificationBell } from "@/features/notifications/components/notification-bell";
import type { AppointmentStatus } from "@/types/domain";
export const metadata: Metadata = { title: "Appointments" };
export const dynamic = "force-dynamic";
const STATUS_MAP: Record<string, AppointmentStatus> = {
PENDING: "pending_approval",
CONFIRMED: "confirmed",
CHECKED_IN: "arrived",
IN_PROGRESS: "in_progress",
COMPLETED: "completed",
CANCELLED: "cancelled",
NO_SHOW: "no_show",
};
const UPCOMING_STATUSES = ["PENDING", "CONFIRMED", "CHECKED_IN", "IN_PROGRESS"] as const;
const CANCELLED_STATUSES = ["CANCELLED", "NO_SHOW"] as const;
const RESCHEDULABLE_STATUSES = new Set(["PENDING", "CONFIRMED", "CHECKED_IN"]);
const CANCELLABLE_STATUSES = new Set(["PENDING", "CONFIRMED", "CHECKED_IN"]);
export default async function AppointmentsPage() {
const session = await auth();
if (!session?.user) {
return (
<div className="pb-12">
<TopNav title="Appointments" sticky className="bg-stone" />
<div className="mx-auto max-w-2xl px-6 pt-6">
<EmptyState
icon={LogIn}
title="Sign in to see your appointments"
description="Create an account or sign in to view, reschedule, and cancel your bookings."
action={
<Button asChild>
<Link href="/sign-in?callbackUrl=/appointments">Sign In</Link>
</Button>
}
/>
</div>
</div>
);
}
const [business, unreadNotificationCount] = await Promise.all([
getPrimaryBusiness(),
getUnreadNotificationCount(prisma, session.user.id),
]);
const customerProfile = await prisma.customerProfile.findUnique({
where: { userId: session.user.id },
});
const bell = <InlineNotificationBell href="/notifications" unreadCount={unreadNotificationCount} />;
if (!customerProfile) {
return (
<div className="pb-12">
<TopNav title="Appointments" sticky className="bg-stone" action={bell} />
<div className="mx-auto max-w-2xl px-6 pt-6">
<EmptyState
icon={CalendarPlus}
title="No appointments yet"
description="Book your first appointment to see it here."
action={
<Button asChild>
<Link href="/book">Book Now</Link>
</Button>
}
/>
</div>
</div>
);
}
const [upcoming, past, cancelled] = await Promise.all([
listAppointments(prisma, { customerId: customerProfile.id, statuses: [...UPCOMING_STATUSES] }),
listAppointments(prisma, { customerId: customerProfile.id, statuses: ["COMPLETED"] }),
listAppointments(prisma, { customerId: customerProfile.id, statuses: [...CANCELLED_STATUSES] }),
]);
const renderList = (appointments: typeof upcoming, kind: "upcoming" | "past" | "cancelled") => {
if (appointments.length === 0) {
return (
<EmptyState
icon={CalendarPlus}
title={
kind === "upcoming"
? "No upcoming appointments"
: kind === "past"
? "No past appointments"
: "No cancelled appointments"
}
description={kind === "upcoming" ? "Book your next visit in a few taps." : undefined}
action={
kind === "upcoming" ? (
<Button asChild>
<Link href="/book">Book Now</Link>
</Button>
) : undefined
}
/>
);
}
return (
<div className="space-y-4">
{appointments.map((appointment) => (
<AppointmentCard
key={appointment.id}
status={STATUS_MAP[appointment.status] ?? "confirmed"}
serviceName={appointment.services.map((s) => s.service.name).join(" + ")}
person={{ id: appointment.staffId, name: appointment.staff.user.name ?? "Barber" }}
formattedDate={appointment.startAt.toLocaleDateString("en-GB", {
weekday: "short",
day: "numeric",
month: "short",
timeZone: business.timezone,
})}
formattedTime={appointment.startAt.toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
timeZone: business.timezone,
})}
actions={
<div className="flex flex-1 flex-wrap gap-2">
<Link
href={`/appointments/${appointment.id}`}
className="border-line rounded-lg border px-4 py-2 text-xs font-bold tracking-widest uppercase"
>
View
</Link>
{kind === "upcoming" && RESCHEDULABLE_STATUSES.has(appointment.status) && (
<Link
href={`/appointments/${appointment.id}/reschedule`}
className="border-line rounded-lg border px-4 py-2 text-xs font-bold tracking-widest uppercase"
>
Reschedule
</Link>
)}
{kind === "upcoming" && CANCELLABLE_STATUSES.has(appointment.status) && (
<CancelAppointmentDialog appointmentId={appointment.id} />
)}
{kind !== "upcoming" && <BookAgainButton appointmentId={appointment.id} />}
</div>
}
/>
))}
</div>
);
};
return (
<div className="pb-12">
<TopNav title="Appointments" sticky className="bg-stone" action={bell} />
<div className="mx-auto max-w-2xl px-6 pt-6">
<Tabs defaultValue="upcoming">
<TabsList>
<TabsTrigger value="upcoming">Upcoming</TabsTrigger>
<TabsTrigger value="past">Past</TabsTrigger>
<TabsTrigger value="cancelled">Cancelled</TabsTrigger>
</TabsList>
<TabsContent value="upcoming">{renderList(upcoming, "upcoming")}</TabsContent>
<TabsContent value="past">{renderList(past, "past")}</TabsContent>
<TabsContent value="cancelled">{renderList(cancelled, "cancelled")}</TabsContent>
</Tabs>
</div>
</div>
);
}
+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} />;
}
+296
View File
@@ -0,0 +1,296 @@
import type { Metadata } from "next";
import { Star } from "lucide-react";
import Link from "next/link";
import { auth } from "@/auth";
import { AppointmentCard } from "@/components/ui/appointment-card";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { ImageGallery } from "@/components/ui/image-gallery";
import { GalleryPlacement, Weekday } from "@/generated/prisma/enums";
import type { Prisma } from "@/generated/prisma/client";
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 { listGalleryImages } from "@/services/gallery/gallery.service";
import { getLoyaltySummary, type LoyaltySummary } from "@/services/loyalty/loyalty.service";
import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
import { getBusinessReviews } from "@/services/reviews/reviews.service";
import { HomeTabs } from "@/features/home/components/home-tabs";
import type { AppointmentStatus } from "@/types/domain";
export const metadata: Metadata = { title: "Home" };
export const dynamic = "force-dynamic";
const WEEKDAY_ORDER: Weekday[] = [
Weekday.MONDAY,
Weekday.TUESDAY,
Weekday.WEDNESDAY,
Weekday.THURSDAY,
Weekday.FRIDAY,
Weekday.SATURDAY,
Weekday.SUNDAY,
];
const WEEKDAY_LABEL: Record<Weekday, string> = {
MONDAY: "Monday",
TUESDAY: "Tuesday",
WEDNESDAY: "Wednesday",
THURSDAY: "Thursday",
FRIDAY: "Friday",
SATURDAY: "Saturday",
SUNDAY: "Sunday",
};
const STATUS_MAP: Record<string, AppointmentStatus> = {
PENDING: "pending_approval",
CONFIRMED: "confirmed",
CHECKED_IN: "arrived",
IN_PROGRESS: "in_progress",
};
export default async function CustomerHomePage() {
const [business, session] = await Promise.all([getPrimaryBusiness(), auth()]);
const [services, categories, staff, reviews, heroImages, portfolioImages] = await Promise.all([
getAvailableServices(prisma, { businessId: business.id }),
prisma.serviceCategory.findMany({ where: { businessId: business.id, isActive: true }, orderBy: { displayOrder: "asc" } }),
prisma.staffProfile.findMany({
where: { businessId: business.id, status: "ACTIVE", isBookableOnline: true },
include: { user: { select: { name: true, image: true } } },
}),
getBusinessReviews(business.id),
listGalleryImages(prisma, { businessId: business.id, placement: GalleryPlacement.HERO, activeOnly: true }),
listGalleryImages(prisma, { businessId: business.id, placement: GalleryPlacement.PORTFOLIO, activeOnly: true }),
]);
const openingStatus = computeOpeningStatus(business.hours, business.timezone);
const location = business.locations[0];
// Hero: owner-managed gallery first; falls back to a single cover/logo
// image (still fully functional with zero gallery rows configured —
// see docs/phase-9-summary.md, "Home Screen Redesign").
const heroGalleryImages =
heroImages.length > 0
? heroImages.map((i) => ({ id: i.id, url: i.imageUrl, caption: i.caption }))
: business.coverImageUrl || business.logoUrl
? [{ id: "fallback", url: (business.coverImageUrl ?? business.logoUrl)!, caption: null }]
: [];
const hoursRows = WEEKDAY_ORDER.map((day) => {
const dayHours = business.hours.find((h) => h.dayOfWeek === day);
const hoursLabel =
!dayHours || dayHours.isClosed || dayHours.opensAtMinutes === null || dayHours.closesAtMinutes === null
? "Closed"
: `${String(Math.floor(dayHours.opensAtMinutes / 60)).padStart(2, "0")}:${String(dayHours.opensAtMinutes % 60).padStart(2, "0")} – ${String(Math.floor(dayHours.closesAtMinutes / 60)).padStart(2, "0")}:${String(dayHours.closesAtMinutes % 60).padStart(2, "0")}`;
return { label: WEEKDAY_LABEL[day], hoursLabel };
});
// Keyless Google Maps embed — no API key/SDK needed for a simple pinned
// map, unlike the JS Maps SDK. Prefers exact coordinates; falls back to
// the street address when they're not set.
const mapQuery =
location?.latitude && location?.longitude
? `${location.latitude},${location.longitude}`
: location
? `${location.addressLine1}, ${location.city} ${location.postalCode}`
: null;
const mapEmbedSrc = mapQuery ? `https://www.google.com/maps?q=${encodeURIComponent(mapQuery)}&output=embed` : null;
type UpcomingAppointment = Prisma.AppointmentGetPayload<{
include: { services: { include: { service: true } }; staff: { include: { user: true } } };
}>;
let customerProfile: Awaited<ReturnType<typeof prisma.customerProfile.findUnique>> = null;
let upcomingAppointment: UpcomingAppointment | null = null;
let loyalty: LoyaltySummary | null = null;
let preferredBarberName: string | null = null;
let lastCompletedAppointment: Awaited<ReturnType<typeof prisma.appointment.findFirst>> = null;
if (session?.user?.id) {
customerProfile = await prisma.customerProfile.findUnique({
where: { userId: session.user.id },
});
if (customerProfile) {
[upcomingAppointment, loyalty, lastCompletedAppointment] = await Promise.all([
prisma.appointment.findFirst({
where: {
customerId: customerProfile.id,
status: { in: ["PENDING", "CONFIRMED", "CHECKED_IN"] },
startAt: { gte: new Date() },
},
orderBy: { startAt: "asc" },
include: { services: { include: { service: true } }, staff: { include: { user: true } } },
}),
getLoyaltySummary(prisma, { customerId: customerProfile.id, businessId: business.id }),
prisma.appointment.findFirst({
where: { customerId: customerProfile.id, status: "COMPLETED" },
orderBy: { startAt: "desc" },
}),
]);
if (customerProfile.preferredBarberId) {
const preferred = await prisma.staffProfile.findUnique({
where: { id: customerProfile.preferredBarberId },
include: { user: { select: { name: true } } },
});
preferredBarberName = preferred?.user.name ?? null;
}
}
}
return (
<div>
{/* Hero — an owner-managed gallery (see /owner/settings/gallery), matching the content width rather than bleeding edge-to-edge. */}
{heroGalleryImages.length > 0 && (
<div className="mx-auto max-w-2xl pb-1 bg-white border-line border-b">
<ImageGallery images={heroGalleryImages} />
</div>
)}
<main className="mx-auto max-w-2xl bg-white px-6 pt-3 pb-6">
{/* {session?.user && (
<p className="text-muted mb-1 text-sm">
Good to see you, {session.user.name?.split(" ")[0] ?? "there"}
</p>
)} */}
<h1 className="mb-1 text-2xl font-bold tracking-tight">{business.name}</h1>
{location && (
<p className="text-muted mb-3 text-xs">
{location.addressLine1}, {location.city} {location.postalCode}
</p>
)}
<div className="mb-6 flex items-center gap-3 text-sm">
<div className="text-accent-loyalty flex items-center gap-1">
<Star className="h-4 w-4 fill-current" aria-hidden="true" />
<span className="text-ink font-bold">{reviews.averageRating.toFixed(1)}</span>
</div>
<span className="text-muted">· {reviews.totalReviews.toLocaleString()} reviews</span>
<span
className={openingStatus.isOpen ? "text-accent-foreground font-bold" : "text-danger font-bold"}
>
{openingStatus.label}
</span>
</div>
{/* Upcoming appointment */}
{upcomingAppointment && (
<section className="mb-6">
{session?.user && (
<h2 className="text-muted mb-3 text-xs font-bold tracking-widest uppercase">
{session.user.name?.split(" ")[0] ?? "Your Next Appointment"}, Your Next Appointment
</h2>
)}
<AppointmentCard
status={STATUS_MAP[upcomingAppointment.status] ?? "confirmed"}
serviceName={upcomingAppointment.services.map((s) => s.service.name).join(" + ")}
person={{
id: upcomingAppointment.staffId,
name: upcomingAppointment.staff.user.name ?? "Barber",
}}
formattedDate={upcomingAppointment.startAt.toLocaleDateString("en-GB", {
weekday: "short",
day: "numeric",
month: "short",
timeZone: business.timezone,
})}
formattedTime={upcomingAppointment.startAt.toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
timeZone: business.timezone,
})}
actions={
<Link
href="/appointments"
className="text-ink text-xs font-bold tracking-widest uppercase underline"
>
View details
</Link>
}
/>
</section>
)}
{/* Loyalty summary */}
{loyalty?.isEnabled && loyalty.accountId && (
<Link
href="/loyalty"
className={
loyalty.availableReward
? "rounded-md border-accent-loyalty bg-accent-loyalty/10 mb-6 block border p-5 shadow-sm"
: "rounded-md border-line mb-6 block border bg-white p-5 shadow-sm"
}
>
{loyalty.availableReward ? (
<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}/{resolveRewardThreshold(loyalty)} stamps
</span>
</div>
<div className="bg-stone h-2 w-full overflow-hidden rounded-full">
<div
className="bg-accent-loyalty h-full rounded-full"
style={{
width: `${Math.min(100, (loyalty.currentStamps / resolveRewardThreshold(loyalty)) * 100)}%`,
}}
/>
</div>
</>
)}
</Link>
)}
{/* Preferred barber + book again */}
{(preferredBarberName || lastCompletedAppointment) && (
<div className="mb-6 grid grid-cols-2 gap-3">
{preferredBarberName && (
<Card className="p-4">
<p className="text-muted text-[10px] font-bold tracking-widest uppercase">
Preferred Barber
</p>
<p className="mt-1 text-sm font-bold">{preferredBarberName}</p>
</Card>
)}
{/* {lastCompletedAppointment && (
<Button asChild variant="secondary" className="h-full w-full">
<Link href="/appointments">Book Again</Link>
</Button>
)} */}
</div>
)}
{/* Booking CTA */}
<Button asChild className="mb-8 w-full">
<Link href="/book">Book an Appointment</Link>
</Button>
<HomeTabs
services={services}
categories={categories.map((c) => ({ id: c.id, name: c.name }))}
portfolioImages={portfolioImages}
description={business.description}
phone={business.phone}
email={business.email}
website={business.website}
instagramUrl={business.instagramUrl}
facebookUrl={business.facebookUrl}
hoursRows={hoursRows}
staff={staff.map((s) => ({
id: s.id,
name: s.user.name ?? "Barber",
avatarUrl: s.user.image ?? undefined,
isPreferred: customerProfile?.preferredBarberId === s.id,
}))}
mapEmbedSrc={mapEmbedSrc}
/>
</main>
</div>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { auth } from "@/auth";
import { BottomNav, type BottomNavItem } from "@/components/navigation/bottom-nav";
import { prisma } from "@/lib/db/prisma";
import { getBranding } from "@/lib/business/branding";
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" },
{ href: "/appointments", label: "Bookings", icon: "calendar-check" },
{ href: "/loyalty", label: "Loyalty", icon: "gift" },
{ href: "/settings", label: "Profile", icon: "user" },
];
/**
* No auth gate here — Home must work for guests too (the Phase 4 brief:
* "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. 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 async function CustomerLayout({ children }: { children: React.ReactNode }) {
const [session, branding] = await Promise.all([auth(), getBranding()]);
const unreadNotificationCount = session?.user ? await getUnreadNotificationCount(prisma, session.user.id) : 0;
return (
<div className="min-h-screen bg-stone pb-24">
{session?.user && <NotificationBell href="/notifications" unreadCount={unreadNotificationCount} />}
<div id="main-content">{children}</div>
<BottomNav items={NAV_ITEMS} variant="customer" showLabels={branding.bottomNavShowLabels} />
</div>
);
}
+155
View File
@@ -0,0 +1,155 @@
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";
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
import { InlineNotificationBell } from "@/features/notifications/components/notification-bell";
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="pb-12">
<TopNav title="Loyalty" sticky className="bg-stone" />
<main className="mx-auto max-w-2xl px-6 py-6">
<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>
}
/>
</main>
</div>
);
}
const [customerProfile, unreadNotificationCount] = await Promise.all([
prisma.customerProfile.findUnique({ where: { userId: session.user.id } }),
getUnreadNotificationCount(prisma, session.user.id),
]);
const bell = <InlineNotificationBell href="/notifications" unreadCount={unreadNotificationCount} />;
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" sticky className="bg-stone" action={bell} />
<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;
// The number of stamps that actually earns the reward — not always the
// same as the card's stampsRequired slot count, see resolveRewardThreshold.
const rewardThreshold = resolveRewardThreshold(loyalty);
return (
<div className="pb-12">
<TopNav title="Loyalty" sticky className="bg-stone" action={bell} />
<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.termsLine ?? undefined}
cardBackgroundColor={loyalty.cardBackgroundColor}
cardBackgroundImageUrl={loyalty.cardBackgroundImageUrl}
stampIcon={loyalty.stampIcon}
rewardPosition={loyalty.rewardPosition}
cardTitleText={loyalty.cardTitleText}
cardFont={loyalty.cardFont}
cardTitleTextSize={loyalty.cardTitleTextSize}
cardTitleTextColor={loyalty.cardTitleTextColor}
cardBorderColor={loyalty.cardBorderColor}
cardMemberNumberColor={loyalty.cardMemberNumberColor}
/>
{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}/{rewardThreshold} stamps
</span>
</div>
<div className="bg-stone h-2 w-full overflow-hidden rounded-full">
<div
className="bg-accent-loyalty h-full rounded-full"
style={{ width: `${Math.min(100, (loyalty.currentStamps / rewardThreshold) * 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-md border-line border bg-white p-5">
<h2 className="mb-3 text-sm font-bold">Push Notifications</h2>
<PushSubscribeToggle />
</div>
</main>
</div>
);
}
+93
View File
@@ -0,0 +1,93 @@
import type { Metadata } from "next";
import { Bell, ChevronRight, Gift, LogIn, User } from "lucide-react";
import Link from "next/link";
import { auth } from "@/auth";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/feedback/empty-state";
import { ProfileCard } from "@/components/ui/profile-card";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { getBranding } from "@/lib/business/branding";
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
import { SignOutButton } from "@/features/auth/components/sign-out-button";
import { InstallAppButton } from "@/features/pwa/components/install-app-button";
import { InlineNotificationBell } from "@/features/notifications/components/notification-bell";
export const metadata: Metadata = { title: "Settings" };
export const dynamic = "force-dynamic";
const SETTINGS_LINKS = [
{ href: "/settings/preferred-barber", label: "Preferred Barber", icon: User },
{ href: "/loyalty", label: "Loyalty Card", icon: Gift },
{ href: "/settings/notifications", label: "Notifications", icon: Bell },
] as const;
export default async function CustomerSettingsPage() {
const session = await auth();
if (!session?.user) {
return (
<div className="pb-12">
<TopNav title="Settings" sticky className="bg-stone" />
<div className="mx-auto max-w-2xl px-6 pt-6">
<EmptyState
icon={LogIn}
title="Sign in to manage your account"
description="Save your details, track loyalty rewards, and manage your bookings."
action={
<Button asChild>
<Link href="/sign-in?callbackUrl=/settings">Sign In</Link>
</Button>
}
/>
</div>
</div>
);
}
const [customerProfile, user, branding, unreadNotificationCount] = await Promise.all([
prisma.customerProfile.findUnique({ where: { userId: session.user.id } }),
prisma.user.findUnique({ where: { id: session.user.id }, select: { image: true } }),
getBranding(),
getUnreadNotificationCount(prisma, session.user.id),
]);
return (
<div className="pb-12">
<TopNav
title="Settings"
sticky
className="bg-stone"
action={<InlineNotificationBell href="/notifications" unreadCount={unreadNotificationCount} />}
/>
<div className="mx-auto max-w-2xl space-y-6 px-6 pt-6">
<ProfileCard
name={
customerProfile
? `${customerProfile.firstName} ${customerProfile.lastName}`
: (session.user.name ?? "Signed-in customer")
}
meta={customerProfile?.email ?? session.user.email}
avatarUrl={user?.image ?? undefined}
editHref="/settings/profile"
/>
<ul className="divide-line divide-y rounded-md border border-line bg-white">
{SETTINGS_LINKS.map(({ href, label, icon: Icon }) => (
<li key={href}>
<Link href={href} className="hover:bg-stone flex items-center gap-3 px-5 py-4 text-sm font-bold">
<Icon className="text-muted h-5 w-5" aria-hidden="true" />
<span className="flex-1">{label}</span>
<ChevronRight className="text-muted h-4 w-4" aria-hidden="true" />
</Link>
</li>
))}
</ul>
<div className="border-line rounded-md border bg-white p-5">
<p className="mb-3 text-sm font-bold">Get the app</p>
<InstallAppButton businessName={branding.name} />
</div>
<SignOutButton />
</div>
</div>
);
}
@@ -0,0 +1,38 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { TopNav } from "@/components/navigation/top-nav";
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
import { prisma } from "@/lib/db/prisma";
import { PreferredBarberPicker } from "@/features/customer/components/preferred-barber-picker";
export const metadata: Metadata = { title: "Preferred Barber" };
export const dynamic = "force-dynamic";
export default async function PreferredBarberPage() {
const session = await auth();
if (!session?.user) redirect("/sign-in?callbackUrl=/settings/preferred-barber");
const [business, customerProfile] = await Promise.all([
getPrimaryBusiness(),
prisma.customerProfile.findUnique({ where: { userId: session.user.id } }),
]);
const staff = await prisma.staffProfile.findMany({
where: { businessId: business.id, status: "ACTIVE", isBookableOnline: true },
include: { user: { select: { name: true, image: true } } },
});
return (
<div className="pb-12">
<TopNav title="Preferred Barber" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl space-y-4 px-6 py-6">
<p className="text-muted text-sm">Chosen for you by default when booking — you can still pick anyone else.</p>
<PreferredBarberPicker
staff={staff.map((s) => ({ id: s.id, name: s.user.name ?? "Barber", avatarUrl: s.user.image ?? undefined }))}
initialPreferredId={customerProfile?.preferredBarberId ?? null}
/>
</main>
</div>
);
}
@@ -0,0 +1,38 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { CustomerProfileForm } from "@/features/customer/components/customer-profile-form";
export const metadata: Metadata = { title: "Edit Profile" };
export const dynamic = "force-dynamic";
export default async function CustomerEditProfilePage() {
const session = await auth();
if (!session?.user) redirect("/sign-in?callbackUrl=/settings/profile");
const [customerProfile, user] = await Promise.all([
prisma.customerProfile.findUnique({ where: { userId: session.user.id } }),
prisma.user.findUnique({ where: { id: session.user.id }, select: { image: true } }),
]);
const [sessionFirstName, ...sessionLastNameParts] = (session.user.name ?? "").trim().split(" ");
return (
<div className="pb-12">
<TopNav title="Edit Profile" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl space-y-6 px-6 py-6">
<CustomerProfileForm
initial={{
firstName: customerProfile?.firstName ?? sessionFirstName ?? "",
lastName: customerProfile?.lastName ?? sessionLastNameParts.join(" "),
email: customerProfile?.email ?? session.user.email ?? "",
phone: customerProfile?.phone ?? "",
image: user?.image ?? "",
}}
/>
</main>
</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} />;
}
+41
View File
@@ -0,0 +1,41 @@
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { StaffShell } from "@/components/navigation/staff-shell";
import { getNavRole, hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db/prisma";
import { getNavItems } from "@/lib/navigation/nav-config";
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
/**
* Owner and Super Administrator are permissions, not separate account
* types (see docs/implementation-plan.md §3, §11) — this layout reuses the
* exact same shell/nav as (staff), and only adds a permission check on
* top. There is no "switch to owner tools" control anywhere in the app —
* a user who can manage staff simply sees "Manage" in their one merged
* nav (see lib/navigation/nav-config.ts).
*/
export default async function OwnerLayout({ children }: { children: React.ReactNode }) {
const session = await auth();
if (!session?.user) {
redirect("/sign-in?callbackUrl=/owner");
}
const canManageStaff = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
if (!canManageStaff) {
redirect("/staff");
}
const role = (await getNavRole(session.user.id)) ?? "owner";
const unreadNotificationCount = await getUnreadNotificationCount(prisma, session.user.id);
return (
<StaffShell
items={getNavItems(role)}
notificationsHref="/owner/notifications"
unreadNotificationCount={unreadNotificationCount}
>
{children}
</StaffShell>
);
}
@@ -0,0 +1,270 @@
import type { Metadata } from "next";
import { CalendarPlus, Mail, Phone } from "lucide-react";
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { auth } from "@/auth";
import { Card } from "@/components/ui/card";
import { StatusPill } from "@/components/ui/status-pill";
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 { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { getLoyaltySummary } from "@/services/loyalty/loyalty.service";
import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
import { getAppointmentById } from "@/services/scheduling/appointment.service";
import { assertAppointmentInBusiness, StaffPermissionDeniedError } from "@/features/staff/authorization";
import { AppointmentStatusActions } from "@/features/staff/components/appointment-status-actions";
import { AddStampButton } from "@/features/staff/components/add-stamp-button";
import { BookFollowUpButton } from "@/features/staff/components/book-follow-up-button";
import { ownerAppointmentActions } from "@/features/owner/actions/appointment-action-set";
import { RedeemRewardButton } from "@/features/owner/components/redeem-reward-button";
import type { AppointmentStatus } from "@/types/domain";
export const metadata: Metadata = { title: "Appointment Details" };
export const dynamic = "force-dynamic";
const STATUS_MAP: Record<string, AppointmentStatus> = {
PENDING: "pending_approval",
CONFIRMED: "confirmed",
CHECKED_IN: "arrived",
IN_PROGRESS: "in_progress",
COMPLETED: "completed",
CANCELLED: "cancelled",
NO_SHOW: "no_show",
};
const RESCHEDULABLE = new Set(["PENDING", "CONFIRMED", "CHECKED_IN"]);
export default async function OwnerAppointmentDetailsPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const session = await auth();
if (!session?.user) redirect("/sign-in");
const canView = await hasPermission(session.user.id, Permission.VIEW_ALL_STAFF_APPOINTMENTS);
if (!canView) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const { id } = await params;
const appointment = await getAppointmentById(prisma, id);
if (!appointment) notFound();
try {
// `id` is unused by assertAppointmentInBusiness (only checks businessId)
// — see src/features/staff/authorization.ts. context.userId stands in
// for it since a Super Administrator has no StaffProfile id to offer.
assertAppointmentInBusiness(appointment, { id: context.userId, businessId: context.businessId });
} catch (error) {
if (error instanceof StaffPermissionDeniedError) notFound();
throw error;
}
const [notes, loyaltyTransactions, loyalty, cancelledByUser] = await Promise.all([
prisma.customerNote.findMany({
where: { customerId: appointment.customerId },
orderBy: { createdAt: "desc" },
take: 5,
include: { author: { include: { user: { select: { name: true } } } } },
}),
prisma.loyaltyTransaction.findMany({
where: { appointmentId: appointment.id },
orderBy: { createdAt: "desc" },
}),
getLoyaltySummary(prisma, { customerId: appointment.customerId, businessId: context.businessId }),
appointment.cancelledByUserId
? prisma.user.findUnique({ where: { id: appointment.cancelledByUserId }, select: { name: true } })
: null,
]);
const tz = context.businessTimezone;
const totalDuration = appointment.services.reduce((sum, s) => sum + s.durationAtBookingMinutes, 0);
const subtotal = appointment.services.reduce((sum, s) => sum + Number(s.priceAtBooking), 0);
const discount = appointment.discountAmount ? Number(appointment.discountAmount) : 0;
const total = subtotal - discount;
const canRedeemReward =
loyalty?.availableReward &&
appointment.discountAmount === null &&
appointment.status !== "CANCELLED";
return (
<div className="pb-12">
<TopNav title="Appointment Details" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl space-y-4 px-6 py-6">
<Card className="space-y-6 p-6">
<div className="flex items-center justify-between">
<StatusPill status={STATUS_MAP[appointment.status] ?? "confirmed"} />
<span className="text-muted text-[10px] font-bold tracking-widest uppercase">
Ref: {appointment.id.slice(-8)}
</span>
</div>
<div className="border-line space-y-1 border-t pt-4">
<Link href={`/owner/team/${appointment.staffId}`} className="text-muted text-xs font-bold tracking-widest uppercase underline">
{appointment.staff.user.name ?? "Staff"}
</Link>
<Link href={`/staff/clients/${appointment.customerId}`} className="block text-lg font-bold underline">
{appointment.customer.firstName} {appointment.customer.lastName}
</Link>
<p className="text-muted text-sm">
{appointment.customer.isGuest ? "Guest customer" : "Registered customer"}
</p>
</div>
<div className="flex flex-wrap gap-3">
{appointment.customer.phone && (
<a
href={`tel:${appointment.customer.phone}`}
className="border-line flex items-center gap-2 rounded-lg border bg-white px-4 py-2 text-xs font-bold"
>
<Phone className="text-accent h-3.5 w-3.5" aria-hidden="true" /> Call
</a>
)}
<a
href={`mailto:${appointment.customer.email}`}
className="border-line flex items-center gap-2 rounded-lg border bg-white px-4 py-2 text-xs font-bold"
>
<Mail className="text-accent h-3.5 w-3.5" aria-hidden="true" /> Email
</a>
</div>
<div className="border-line grid grid-cols-2 gap-4 border-t pt-4 text-sm">
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">Services</span>
<p className="font-bold">{appointment.services.map((s) => s.service.name).join(" + ")}</p>
</div>
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">Date</span>
<p className="font-bold">
{appointment.startAt.toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short", timeZone: tz })}
</p>
</div>
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">Time</span>
<p className="font-bold">
{appointment.startAt.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", timeZone: tz })} –{" "}
{appointment.endAt.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", timeZone: tz })}
</p>
</div>
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">Duration</span>
<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">£{subtotal.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">£{total.toFixed(2)}</span>
</div>
</div>
{appointment.notes && (
<div className="border-line border-t pt-4">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">Booking Notes</span>
<p className="text-muted mt-1 text-sm italic">{appointment.notes}</p>
</div>
)}
{notes.length > 0 && (
<div className="border-line border-t pt-4">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">Customer Notes</span>
<div className="mt-2 space-y-2">
{notes.map((n) => (
<p key={n.id} className="text-muted text-xs">
<span className="font-bold">{n.author.user.name ?? "Staff"}:</span> {n.body}
</p>
))}
</div>
</div>
)}
{loyalty?.isEnabled && (
<div className="border-line border-t pt-4">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">Loyalty</span>
<p className="text-sm font-bold">
{loyalty.currentStamps}/{resolveRewardThreshold(loyalty)} stamps
{loyalty.availableReward && <span className="text-accent-loyalty"> · Reward available</span>}
</p>
{loyaltyTransactions.length > 0 && (
<ul className="mt-2 space-y-1">
{loyaltyTransactions.map((t) => (
<li key={t.id} className="text-muted text-xs">
{t.amount > 0 ? "+" : ""}
{t.amount} stamp{Math.abs(t.amount) === 1 ? "" : "s"} — {t.type.replace(/_/g, " ").toLowerCase()}
{t.reason ? ` (${t.reason})` : ""}
</li>
))}
</ul>
)}
</div>
)}
{appointment.status === "CANCELLED" && (
<div className="border-line border-t pt-4 text-sm">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">Cancellation</span>
<p className="text-muted mt-1">
Cancelled {appointment.cancelledAt?.toLocaleString("en-GB", { timeZone: tz })}
{cancelledByUser ? ` by ${cancelledByUser.name}` : ""}
</p>
{appointment.cancellationReason && (
<p className="text-muted mt-1 italic">&ldquo;{appointment.cancellationReason}&rdquo;</p>
)}
</div>
)}
</Card>
<div className="flex flex-wrap gap-3">
<a
href={`/api/appointments/${appointment.id}/calendar.ics`}
className="rounded-md border-line flex items-center gap-2 border bg-white px-5 py-3 text-sm font-bold"
>
<CalendarPlus className="text-accent h-4 w-4" aria-hidden="true" /> Add to Calendar
</a>
{RESCHEDULABLE.has(appointment.status) && (
<Link
href={`/owner/appointments/${appointment.id}/reschedule`}
className="rounded-md border-line border bg-white px-5 py-3 text-sm font-bold"
>
Reschedule / Reassign
</Link>
)}
{appointment.status === "COMPLETED" && <BookFollowUpButton appointmentId={appointment.id} />}
</div>
<AppointmentStatusActions
appointmentId={appointment.id}
status={appointment.status}
actions={ownerAppointmentActions}
/>
<div className="flex flex-wrap gap-3">
{loyalty?.isEnabled && <AddStampButton customerId={appointment.customerId} appointmentId={appointment.id} />}
{canRedeemReward && loyalty.availableReward && (
<RedeemRewardButton
appointmentId={appointment.id}
rewardId={loyalty.availableReward.id}
rewardLabel={loyalty.availableReward.label}
subtotal={subtotal}
/>
)}
</div>
</main>
</div>
);
}
+291
View File
@@ -0,0 +1,291 @@
import type { Metadata } from "next";
import { CalendarX2, Plus } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/feedback/empty-state";
import { AppointmentCard } from "@/components/ui/appointment-card";
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 { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import {
calendarDateToUtcMidnight,
minutesFromMidnight,
todayIn,
weekdayOf,
} from "@/lib/time/business-time";
import type { CalendarDate } from "@/lib/time/business-time";
import { getAvailableRewardFlags } from "@/services/loyalty/loyalty.service";
import { listAppointments } from "@/services/scheduling/appointment.service";
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
import { DateStrip } from "@/features/staff/components/date-strip";
import { DayCalendar } from "@/features/staff/components/day-calendar";
import type { CalendarAppointment, CalendarBlock, CalendarColumn } from "@/features/staff/components/day-calendar";
import { ViewSwitch } from "@/features/staff/components/view-switch";
import { OwnerBlockTimeButton } from "@/features/owner/components/owner-block-time-button";
import { StaffFilterPills } from "@/features/owner/components/staff-filter-pills";
import type { StaffFilterOption } from "@/features/owner/components/staff-filter-pills";
import { InlineNotificationBell } from "@/features/notifications/components/notification-bell";
import type { AppointmentStatus } from "@/types/domain";
export const metadata: Metadata = { title: "All-Staff Calendar" };
export const dynamic = "force-dynamic";
const STATUS_MAP: Record<string, AppointmentStatus> = {
PENDING: "pending_approval",
CONFIRMED: "confirmed",
CHECKED_IN: "arrived",
IN_PROGRESS: "in_progress",
COMPLETED: "completed",
CANCELLED: "cancelled",
NO_SHOW: "no_show",
};
function formatRange(startMinutes: number, endMinutes: number): string {
const fmt = (m: number) => {
const h = Math.floor(m / 60);
const min = m % 60;
const period = h >= 12 ? "PM" : "AM";
const displayHour = h % 12 === 0 ? 12 : h % 12;
return `${displayHour}:${String(min).padStart(2, "0")} ${period}`;
};
return `${fmt(startMinutes)} – ${fmt(endMinutes)}`;
}
export default async function OwnerCalendarPage({
searchParams,
}: {
searchParams: Promise<{ date?: string; view?: string; staffId?: string }>;
}) {
const session = await auth();
if (!session?.user) redirect("/sign-in?callbackUrl=/owner/calendar");
const canView = await hasPermission(session.user.id, Permission.VIEW_ALL_STAFF_APPOINTMENTS);
if (!canView) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const unreadNotificationCount = await getUnreadNotificationCount(prisma, session.user.id);
const { date: dateParam, view: viewParam, staffId: staffIdParam } = await searchParams;
const today = todayIn(context.businessTimezone);
const date: CalendarDate = dateParam ?? today;
const view = viewParam === "agenda" ? "agenda" : "day";
const weekday = weekdayOf(date);
const dateUtcMidnight = calendarDateToUtcMidnight(date);
const businessTimezone = context.businessTimezone;
const allStaff = await prisma.staffProfile.findMany({
where: { businessId: context.businessId, status: "ACTIVE" },
include: { user: { select: { id: true, name: true, image: true } } },
orderBy: { createdAt: "asc" },
});
const selectedStaff = staffIdParam ? allStaff.find((s) => s.id === staffIdParam) : undefined;
const [appointments, workingHoursAll, breakDaysAll, timeOffAll] = await Promise.all([
listAppointments(prisma, {
businessId: context.businessId,
staffId: selectedStaff?.id,
fromDate: date,
toDate: date,
businessTimezone,
}),
prisma.workingHours.findMany({
where: { staff: { businessId: context.businessId }, dayOfWeek: weekday },
}),
prisma.breakDay.findMany({
where: { dayOfWeek: weekday, break: { staff: { businessId: context.businessId } } },
include: { break: true },
}),
prisma.timeOff.findMany({
where: {
staff: { businessId: context.businessId },
status: "APPROVED",
startDate: { lte: dateUtcMidnight },
endDate: { gte: dateUtcMidnight },
},
}),
]);
const rewardFlags = await getAvailableRewardFlags(prisma, {
businessId: context.businessId,
customerIds: [...new Set(appointments.map((a) => a.customerId))],
});
const currentTimeMinutes = date === today ? minutesFromMidnight(new Date(), businessTimezone) : null;
const staffToRender = selectedStaff ? [selectedStaff] : allStaff;
function buildColumnFor(staffId: string) {
const workingHours = workingHoursAll.find((h) => h.staffId === staffId);
const workingWindow =
workingHours && !workingHours.isOff && workingHours.startMinutes !== null && workingHours.endMinutes !== null
? { startMinutes: workingHours.startMinutes, endMinutes: workingHours.endMinutes }
: null;
const blocks: CalendarBlock[] = [
...breakDaysAll
.filter((bd) => bd.break.staffId === staffId)
.map((bd) => ({
id: `break-${bd.id}`,
startMinutes: bd.break.startMinutes,
endMinutes: bd.break.endMinutes,
label: bd.break.label,
kind: "break" as const,
})),
...timeOffAll
.filter((t) => t.staffId === staffId && t.startMinutes !== null && t.endMinutes !== null)
.map((t) => ({
id: `timeoff-${t.id}`,
startMinutes: t.startMinutes!,
endMinutes: t.endMinutes!,
label: t.reason ?? "Blocked",
kind: "timeOff" as const,
})),
];
const wholeDayOff = timeOffAll.some((t) => t.staffId === staffId && t.startMinutes === null);
const staffAppointments: CalendarAppointment[] = appointments
.filter((a) => a.staffId === staffId)
.map((a) => ({
id: a.id,
startMinutes: minutesFromMidnight(a.startAt, businessTimezone),
endMinutes: minutesFromMidnight(a.endAt, businessTimezone),
customerName: `${a.customer.firstName} ${a.customer.lastName}`,
serviceNames: a.services.map((s) => s.service.name).join(" + "),
status: STATUS_MAP[a.status] ?? "confirmed",
hasNotes: !!a.notes,
hasAvailableReward: rewardFlags.get(a.customerId) ?? false,
}));
return { workingWindow, blocks, wholeDayOff, staffAppointments };
}
const columns: CalendarColumn[] = staffToRender.map((s) => {
const column = buildColumnFor(s.id);
return {
id: s.id,
name: s.user.name ?? "Staff",
avatarUrl: s.user.image,
hoursLabel: column.workingWindow ? formatRange(column.workingWindow.startMinutes, column.workingWindow.endMinutes) : null,
wholeDayOff: column.wholeDayOff,
appointments: column.staffAppointments,
blocks: column.blocks,
workingWindow: column.workingWindow,
};
});
// Every active staff member gets a filter card (not just staffToRender,
// which narrows to the current selection) — the filter row needs to show
// everyone to filter *between*.
const staffFilterOptions: StaffFilterOption[] = allStaff.map((s) => {
const workingHours = workingHoursAll.find((h) => h.staffId === s.id);
const hoursLabel =
workingHours && !workingHours.isOff && workingHours.startMinutes !== null && workingHours.endMinutes !== null
? formatRange(workingHours.startMinutes, workingHours.endMinutes)
: null;
return { id: s.id, name: s.user.name ?? "Staff", avatarUrl: s.user.image, hoursLabel };
});
const staffFilterHrefFor = (staffId: string | null) =>
`/owner/calendar?date=${date}&view=${view}${staffId ? `&staffId=${staffId}` : ""}`;
return (
<div className="pb-6">
<TopNav
title="Calendar"
sticky
className="bg-stone"
leftAction={<InlineNotificationBell href="/owner/notifications" unreadCount={unreadNotificationCount} />}
action={
<div className="flex gap-2">
{selectedStaff && <OwnerBlockTimeButton staffId={selectedStaff.id} staffName={selectedStaff.user.name ?? "Staff"} date={date} />}
<Button asChild size="sm">
{/* /staff/book already lets whoever is signed in book with any eligible barber — the
owner is a staff member too, so this is the same "book for a customer" flow Phase 5
built, not a duplicate. See docs/phase-6-summary.md, "Owner appointment access". */}
<Link href="/staff/book">
<Plus className="h-4 w-4" aria-hidden="true" />
New
</Link>
</Button>
</div>
}
/>
<DateStrip
selectedDate={date}
businessTimezone={businessTimezone}
basePath="/owner/calendar"
view={view}
extraParams={selectedStaff ? { staffId: selectedStaff.id } : undefined}
/>
<div className="mt-4 px-6">
<ViewSwitch
view={view}
dayHref={`/owner/calendar?date=${date}&view=day${selectedStaff ? `&staffId=${selectedStaff.id}` : ""}`}
agendaHref={`/owner/calendar?date=${date}&view=agenda${selectedStaff ? `&staffId=${selectedStaff.id}` : ""}`}
/>
</div>
<div className="mt-4">
<StaffFilterPills options={staffFilterOptions} selectedId={selectedStaff?.id} hrefFor={staffFilterHrefFor} />
</div>
<div className="mt-4">
{view === "day" ? (
staffToRender.length === 0 ? (
<div className="px-6">
<EmptyState icon={CalendarX2} title="No active staff" description="Add a staff member from Team." />
</div>
) : (
<DayCalendar columns={columns} currentTimeMinutes={currentTimeMinutes} appointmentHrefBase="/owner/appointments" />
)
) : appointments.length === 0 ? (
<div className="px-6">
<EmptyState icon={CalendarX2} title="No appointments" description="Nothing scheduled for this day." />
</div>
) : (
<div className="space-y-4 px-6 lg:mx-auto lg:max-w-3xl">
{[...appointments]
.sort((a, b) => a.startAt.getTime() - b.startAt.getTime())
.map((a) => (
<div key={a.id}>
{!selectedStaff && (
<p className="text-muted mb-1 text-[10px] font-bold tracking-widest uppercase">
{a.staff.user.name}
</p>
)}
<AppointmentCard
status={STATUS_MAP[a.status] ?? "confirmed"}
serviceName={a.services.map((s) => s.service.name).join(" + ")}
person={{ id: a.customerId, name: `${a.customer.firstName} ${a.customer.lastName}` }}
personLabel="for"
formattedTime={formatRange(
minutesFromMidnight(a.startAt, businessTimezone),
minutesFromMidnight(a.endAt, businessTimezone),
)}
hasNotes={!!a.notes}
hasAvailableReward={rewardFlags.get(a.customerId) ?? false}
actions={
<Link
href={`/owner/appointments/${a.id}`}
className="border-line rounded-lg border px-4 py-2 text-xs font-bold tracking-widest uppercase"
>
View
</Link>
}
/>
</div>
))}
</div>
)}
</div>
</div>
);
}
@@ -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 { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
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 context = await getActingBusinessContext();
if (!context) redirect("/home");
const notifications = await listInAppNotifications(prisma, { userId: context.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>
);
}
+256
View File
@@ -0,0 +1,256 @@
import type { Metadata } from "next";
import Link from "next/link";
import {
AlertTriangle,
BarChart3,
Building2,
Calendar,
CalendarClock,
CalendarX2,
CheckCheck,
ClipboardList,
Crown,
Gift,
Images,
ScissorsSquare,
Smartphone,
Stamp,
Star,
TrendingUp,
UserX,
Users,
} from "lucide-react";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { Card } from "@/components/ui/card";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { hasPermission, isSuperAdmin } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { todayIn } from "@/lib/time/business-time";
import { getDashboardSummary } from "@/services/reporting/owner-reporting.service";
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
import { InlineNotificationBell } from "@/features/notifications/components/notification-bell";
export const metadata: Metadata = { title: "Business Dashboard" };
export const dynamic = "force-dynamic";
function StatTile({ icon: Icon, label, value, hint }: { icon: typeof Calendar; label: string; value: string | number; hint?: string }) {
return (
<Card className="p-5">
<div className="text-muted mb-2 flex items-center gap-2">
<Icon className="h-4 w-4" aria-hidden="true" />
<span className="text-[10px] font-bold tracking-widest uppercase">{label}</span>
</div>
<p className="text-2xl font-bold">{value}</p>
{hint && <p className="text-muted mt-1 text-xs">{hint}</p>}
</Card>
);
}
/** App Icon & Home Screen is Super Administrator only (see /owner/settings/app) — everything else here is any Owner. */
function getManageSections(isSuper: boolean) {
return [
{
title: "Business",
links: [
{ href: "/owner/settings/business", label: "Business Details", icon: Building2 },
{ href: "/owner/settings/hours", label: "Opening Hours", icon: CalendarClock },
{ href: "/owner/settings/gallery", label: "Gallery", icon: Images },
...(isSuper
? [{ href: "/owner/settings/app", label: "App Icon & Home Screen", icon: Smartphone }]
: []),
],
},
{
title: "Booking",
links: [
{ href: "/owner/services", label: "Services", icon: ScissorsSquare },
{ href: "/owner/settings/booking-rules", label: "Booking Rules", icon: ClipboardList },
{ href: "/owner/settings/loyalty", label: "Loyalty Programme", icon: Crown },
],
},
{
title: "Customers",
links: [{ href: "/owner/settings/reviews", label: "Reviews", icon: Star }],
},
{
title: "Team",
links: [{ href: "/owner/team", label: "Team Management", icon: Users }],
},
{
title: "Reports",
links: [{ href: "/owner/reports", label: "Reports", icon: BarChart3 }],
},
];
}
function ManageSection({ title, links }: ReturnType<typeof getManageSections>[number]) {
return (
<div>
<p className="text-muted mb-2 px-1 text-[10px] font-bold tracking-widest uppercase">{title}</p>
<ul className="divide-line rounded-md border-line divide-y border bg-white">
{links.map(({ href, label, icon: Icon }) => (
<li key={href}>
<Link href={href} className="hover:bg-stone flex items-center gap-3 px-5 py-4 text-sm font-bold">
<Icon className="text-muted h-5 w-5" aria-hidden="true" />
{label}
</Link>
</li>
))}
</ul>
</div>
);
}
export default async function OwnerDashboardPage() {
const session = await auth();
if (!session?.user) redirect("/sign-in?callbackUrl=/owner");
const canView = await hasPermission(session.user.id, Permission.VIEW_REPORTING);
if (!canView) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const today = todayIn(context.businessTimezone);
const [summary, unreadNotificationCount, isSuper] = await Promise.all([
getDashboardSummary(prisma, {
businessId: context.businessId,
businessTimezone: context.businessTimezone,
date: today,
}),
getUnreadNotificationCount(prisma, session.user.id),
isSuperAdmin(session.user.id),
]);
const manageSections = getManageSections(isSuper);
return (
<div className="pb-12">
<TopNav
title="Manage"
sticky
className="bg-stone"
leftAction={<InlineNotificationBell href="/owner/notifications" unreadCount={unreadNotificationCount} />}
/>
<div className="space-y-6 px-6 pt-6">
<p className="text-muted text-sm">{context.businessName} — today&apos;s overview</p>
{summary.warnings.length > 0 && (
<section className="space-y-2">
{summary.warnings.map((w, i) => (
<div
key={`${w.code}-${i}`}
role="alert"
className="bg-warning/10 text-warning-foreground border-warning/30 flex items-start gap-3 rounded-md border p-4 text-sm"
>
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
<p>{w.message}</p>
</div>
))}
</section>
)}
<section className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatTile icon={Calendar} label="Today's appointments" value={summary.totals.total} />
<StatTile icon={CheckCheck} label="Completed" value={summary.totals.completed} />
<StatTile icon={CalendarX2} label="Cancelled" value={summary.totals.cancelled} />
<StatTile icon={UserX} label="No-shows" value={summary.totals.noShow} />
</section>
<section className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatTile icon={TrendingUp} label="In progress now" value={summary.totals.inProgress} />
<StatTile icon={Calendar} label="Still to come today" value={summary.totals.upcoming} />
<StatTile
icon={TrendingUp}
label="Estimated occupancy"
value={summary.occupancy ? `${summary.occupancy.percent}%` : "—"}
hint={summary.occupancy ? "Booked vs. working minutes today — an estimate, not a financial figure." : "No staff working today."}
/>
<StatTile icon={Gift} label="Customers with a reward" value={summary.customersWithAvailableRewards} />
</section>
<section className="grid gap-4 lg:grid-cols-2">
<Card className="p-5">
<h2 className="mb-3 flex items-center gap-2 text-sm font-bold">
<Users className="text-accent h-4 w-4" aria-hidden="true" /> Staff Today
</h2>
{summary.staffWorkingToday.length === 0 ? (
<p className="text-muted text-sm">No one is scheduled to work today.</p>
) : (
<ul className="space-y-2">
{summary.staffWorkingToday.map((s) => (
<li key={s.staffId} className="flex items-center justify-between text-sm">
<span className="font-bold">{s.name}</span>
<span className="text-accent text-xs font-bold tracking-widest uppercase">Working</span>
</li>
))}
</ul>
)}
{summary.staffOnTimeOffToday.length > 0 && (
<ul className="border-line mt-3 space-y-2 border-t pt-3">
{summary.staffOnTimeOffToday.map((s) => (
<li key={s.staffId} className="flex items-center justify-between text-sm">
<span className="font-bold">{s.name}</span>
<span className="text-muted text-xs font-bold tracking-widest uppercase">
{s.reason ?? "Time off"}
</span>
</li>
))}
</ul>
)}
</Card>
<Card className="p-5">
<h2 className="mb-3 flex items-center gap-2 text-sm font-bold">
<TrendingUp className="text-accent h-4 w-4" aria-hidden="true" /> Popular Services
</h2>
<p className="text-muted mb-3 text-xs">Completed visits, last 30 days.</p>
{summary.popularServices.length === 0 ? (
<p className="text-muted text-sm">No completed visits in the last 30 days yet.</p>
) : (
<ul className="space-y-2">
{summary.popularServices.map((s) => (
<li key={s.serviceId} className="flex items-center justify-between text-sm">
<span className="font-bold">{s.name}</span>
<span className="text-muted">{s.count}</span>
</li>
))}
</ul>
)}
</Card>
</section>
<Card className="p-5">
<h2 className="mb-3 flex items-center gap-2 text-sm font-bold">
<Stamp className="text-accent h-4 w-4" aria-hidden="true" /> Recent Loyalty Activity
</h2>
{summary.recentLoyaltyActivity.length === 0 ? (
<p className="text-muted text-sm">No loyalty activity yet.</p>
) : (
<ul className="space-y-2">
{summary.recentLoyaltyActivity.map((t) => (
<li key={t.id} className="text-muted text-xs">
<span className="text-ink font-bold">
{t.loyaltyAccount.customer.firstName} {t.loyaltyAccount.customer.lastName}
</span>{" "}
— {t.amount > 0 ? "+" : ""}
{t.amount} stamp{Math.abs(t.amount) === 1 ? "" : "s"} ({t.type.replace(/_/g, " ").toLowerCase()})
{t.authoredByStaff?.user.name ? ` · ${t.authoredByStaff.user.name}` : ""}
</li>
))}
</ul>
)}
</Card>
<div className="space-y-5">
{manageSections.map((section) => (
<ManageSection key={section.title} {...section} />
))}
</div>
</div>
</div>
);
}
+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 { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
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 context = await getActingBusinessContext();
if (!context) redirect("/staff");
const tz = context.businessTimezone;
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: context.businessId, range }),
getServiceReport(prisma, { businessId: context.businessId, range }),
getStaffReport(prisma, { businessId: context.businessId, range }),
getCustomerReport(prisma, { businessId: context.businessId, range }),
getLoyaltyReport(prisma, { businessId: context.businessId, range }),
getNotificationReport(prisma, { businessId: context.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>
);
}
+51
View File
@@ -0,0 +1,51 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { listCategories } from "@/services/catalogue/service-catalogue.service";
import { CatalogueManager } from "@/features/owner/components/catalogue-manager";
export const metadata: Metadata = { title: "Services" };
export const dynamic = "force-dynamic";
export default async function OwnerServicesPage() {
const session = await auth();
if (!session?.user) redirect("/sign-in");
const canManage = await hasPermission(session.user.id, Permission.MANAGE_SERVICES);
if (!canManage) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const categories = await listCategories(prisma, context.businessId);
return (
<div className="px-6 pt-12 pb-12 lg:max-w-3xl">
<h1 className="mb-6 text-2xl font-bold tracking-tight">Services</h1>
<CatalogueManager
categories={categories.map((c) => ({
id: c.id,
name: c.name,
isActive: c.isActive,
services: c.services.map((s) => ({
id: s.id,
categoryId: s.categoryId,
name: s.name,
description: s.description ?? "",
imageUrl: s.imageUrl,
durationMinutes: s.durationMinutes,
price: s.price.toString(),
bufferBeforeMinutes: s.bufferBeforeMinutes,
bufferAfterMinutes: s.bufferAfterMinutes,
isActive: s.isActive,
allowOnlineBooking: s.allowOnlineBooking,
})),
}))}
/>
</div>
);
}
@@ -0,0 +1,46 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { SettingsPageShell } from "@/components/settings/settings-page-shell";
import { prisma } from "@/lib/db/prisma";
import { isSuperAdmin } from "@/lib/auth/permissions";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { parseSplashScreens } from "@/lib/business/branding";
import { getBusinessProfile } from "@/services/business/business.service";
import { AppIconSettingsForm } from "@/features/owner/components/app-icon-settings-form";
export const metadata: Metadata = { title: "App Icon & Home Screen" };
export const dynamic = "force-dynamic";
/** Super Administrator only — not Owner, unlike every other /owner/settings/* page. See requireSuperAdminBusinessManager in features/owner/actions/business.ts for the matching action-layer check. */
export default async function OwnerAppIconSettingsPage() {
const session = await auth();
if (!session?.user) redirect("/sign-in");
if (!(await isSuperAdmin(session.user.id))) redirect("/owner");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const business = await getBusinessProfile(prisma, context.businessId);
if (!business) redirect("/owner");
return (
<SettingsPageShell title="App Icon & Home Screen">
<AppIconSettingsForm
initial={{
shortName: business.shortName?.trim() || business.name,
appIconUrl: business.appIconUrl,
themeColor: business.themeColor,
backgroundColor: business.backgroundColor,
icon192Url: business.icon192Url,
icon512Url: business.icon512Url,
maskableIcon192Url: business.maskableIcon192Url,
maskableIcon512Url: business.maskableIcon512Url,
splashScreens: parseSplashScreens(business.splashScreens),
bottomNavShowLabels: business.bottomNavShowLabels,
}}
/>
</SettingsPageShell>
);
}
@@ -0,0 +1,48 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { SettingsPageShell } from "@/components/settings/settings-page-shell";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { getBookingRule } from "@/services/scheduling/booking-rule.service";
import { BookingRulesForm } from "@/features/owner/components/booking-rules-form";
export const metadata: Metadata = { title: "Booking Rules" };
export const dynamic = "force-dynamic";
export default async function OwnerBookingRulesPage() {
const session = await auth();
if (!session?.user) redirect("/sign-in");
const canManage = await hasPermission(session.user.id, Permission.MANAGE_BOOKING_RULES);
if (!canManage) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const rule = await getBookingRule(prisma, context.businessId);
if (!rule) redirect("/owner");
return (
<SettingsPageShell title="Booking Rules">
<BookingRulesForm
initial={{
minNoticeMinutes: rule.minNoticeMinutes,
maxAdvanceDays: rule.maxAdvanceDays,
cancellationWindowHours: rule.cancellationWindowHours,
rescheduleWindowHours: rule.rescheduleWindowHours,
slotIntervalMinutes: rule.slotIntervalMinutes,
bufferBeforeMinutes: rule.bufferBeforeMinutes,
bufferAfterMinutes: rule.bufferAfterMinutes,
maxAppointmentsPerDay: rule.maxAppointmentsPerDay,
maxAppointmentsPerStaffPerDay: rule.maxAppointmentsPerStaffPerDay,
customerCanChooseStaff: rule.customerCanChooseStaff,
allowGuestBooking: rule.allowGuestBooking,
requireApproval: rule.requireApproval,
}}
/>
</SettingsPageShell>
);
}
@@ -0,0 +1,63 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { SettingsPageShell } from "@/components/settings/settings-page-shell";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { getBusinessProfile } from "@/services/business/business.service";
import { BusinessDetailsForm } from "@/features/owner/components/business-details-form";
export const metadata: Metadata = { title: "Business Details" };
export const dynamic = "force-dynamic";
export default async function OwnerBusinessDetailsPage() {
const session = await auth();
if (!session?.user) redirect("/sign-in");
const canManage = await hasPermission(session.user.id, Permission.MANAGE_BUSINESS_PROFILE);
if (!canManage) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const business = await getBusinessProfile(prisma, context.businessId);
if (!business) redirect("/owner");
const location = business.locations[0];
return (
<SettingsPageShell title="Business Details">
<BusinessDetailsForm
initial={{
name: business.name,
description: business.description ?? "",
logoUrl: business.logoUrl,
coverImageUrl: business.coverImageUrl,
welcomeBackgroundDesktopUrl: business.welcomeBackgroundDesktopUrl,
welcomeBackgroundMobileUrl: business.welcomeBackgroundMobileUrl,
faviconUrl: business.faviconUrl,
phone: business.phone ?? "",
email: business.email ?? "",
website: business.website ?? "",
instagramUrl: business.instagramUrl ?? "",
facebookUrl: business.facebookUrl ?? "",
timezone: business.timezone,
currency: business.currency,
locale: business.locale,
addressLine1: location?.addressLine1 ?? "",
addressLine2: location?.addressLine2 ?? "",
city: location?.city ?? "",
region: location?.region ?? "",
postalCode: location?.postalCode ?? "",
country: location?.country ?? "GB",
cancellationPolicy: business.settings?.cancellationPolicy ?? "",
lateArrivalPolicy: business.settings?.lateArrivalPolicy ?? "",
bookingTerms: business.settings?.bookingTerms ?? "",
privacyPolicyUrl: business.settings?.privacyPolicyUrl ?? "",
}}
/>
</SettingsPageShell>
);
}
@@ -0,0 +1,49 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { SettingsPageShell } from "@/components/settings/settings-page-shell";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { GalleryPlacement } from "@/generated/prisma/enums";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { listGalleryImages } from "@/services/gallery/gallery.service";
import { GalleryManager } from "@/features/owner/components/gallery-manager";
export const metadata: Metadata = { title: "Gallery" };
export const dynamic = "force-dynamic";
export default async function OwnerGalleryPage() {
const session = await auth();
if (!session?.user) redirect("/sign-in");
const canManage = await hasPermission(session.user.id, Permission.MANAGE_BUSINESS_PROFILE);
if (!canManage) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const [heroImages, portfolioImages] = await Promise.all([
listGalleryImages(prisma, { businessId: context.businessId, placement: GalleryPlacement.HERO }),
listGalleryImages(prisma, { businessId: context.businessId, placement: GalleryPlacement.PORTFOLIO }),
]);
return (
<SettingsPageShell title="Gallery" mainClassName="space-y-8">
<GalleryManager
placement={GalleryPlacement.HERO}
title="Home Hero Gallery"
description="Shown at the top of the customer Home page. Falls back to your cover image, then your logo, when empty."
showCategory={false}
initialImages={heroImages}
/>
<GalleryManager
placement={GalleryPlacement.PORTFOLIO}
title="Portfolio"
description="Shown in the Portfolio tab on Home. Add a category to each image to power the filter pills there."
showCategory
initialImages={portfolioImages}
/>
</SettingsPageShell>
);
}
@@ -0,0 +1,32 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { SettingsPageShell } from "@/components/settings/settings-page-shell";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { getOpeningHours } from "@/services/business/business.service";
import { OpeningHoursForm } from "@/features/owner/components/opening-hours-form";
export const metadata: Metadata = { title: "Opening Hours" };
export const dynamic = "force-dynamic";
export default async function OwnerOpeningHoursPage() {
const session = await auth();
if (!session?.user) redirect("/sign-in");
const canManage = await hasPermission(session.user.id, Permission.MANAGE_BUSINESS_PROFILE);
if (!canManage) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const hours = await getOpeningHours(prisma, context.businessId);
return (
<SettingsPageShell title="Opening Hours" mainClassName="lg:max-w-2xl">
<OpeningHoursForm initialRows={hours} />
</SettingsPageShell>
);
}
@@ -0,0 +1,86 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { SettingsPageShell } from "@/components/settings/settings-page-shell";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { LoyaltyCardFont, LoyaltyCardTextSize, Permission, RewardPositionMode } from "@/generated/prisma/enums";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { getProgrammeConfig } from "@/services/loyalty/loyalty.service";
import { LoyaltyConfigForm } from "@/features/owner/components/loyalty-config-form";
export const metadata: Metadata = { title: "Loyalty Programme" };
export const dynamic = "force-dynamic";
export default async function OwnerLoyaltyConfigPage() {
const session = await auth();
if (!session?.user) redirect("/sign-in");
const canManage = await hasPermission(session.user.id, Permission.MANAGE_LOYALTY_PROGRAMME);
if (!canManage) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const [programme, services, staff] = await Promise.all([
getProgrammeConfig(prisma, context.businessId),
prisma.service.findMany({ where: { businessId: context.businessId, isActive: true }, orderBy: { displayOrder: "asc" } }),
prisma.staffProfile.findMany({
where: { businessId: context.businessId, status: "ACTIVE" },
include: { user: { select: { name: true } } },
}),
]);
const initialConfig = programme
? {
isEnabled: programme.isEnabled,
stampsRequired: programme.stampsRequired,
rewardPosition: programme.rewardPosition,
rewardType: programme.rewardType,
rewardValue: programme.rewardValue?.toString(),
rewardLabel: programme.rewardLabel ?? undefined,
maxRewardValue: programme.maxRewardValue?.toString(),
freeServiceId: programme.freeServiceId ?? undefined,
autoApplyReward: programme.autoApplyReward,
requiresStaffApproval: programme.requiresStaffApproval,
stampsExpireAfterDays: programme.stampsExpireAfterDays ?? undefined,
rewardExpiresAfterDays: programme.rewardExpiresAfterDays ?? undefined,
earnOnCancelled: programme.earnOnCancelled,
earnOnNoShow: programme.earnOnNoShow,
cardBackgroundColor: programme.cardBackgroundColor ?? undefined,
cardBackgroundImageUrl: programme.cardBackgroundImageUrl ?? undefined,
stampIcon: programme.stampIcon ?? undefined,
termsLine: programme.termsLine ?? undefined,
cardTitleText: programme.cardTitleText ?? undefined,
cardFont: programme.cardFont,
cardTitleTextSize: programme.cardTitleTextSize,
cardTitleTextColor: programme.cardTitleTextColor ?? undefined,
cardBorderColor: programme.cardBorderColor ?? undefined,
cardMemberNumberColor: programme.cardMemberNumberColor ?? undefined,
}
: {
isEnabled: false,
stampsRequired: 10,
rewardPosition: RewardPositionMode.AFTER_ALL_STAMPS,
rewardType: "PERCENTAGE" as const,
autoApplyReward: true,
requiresStaffApproval: false,
earnOnCancelled: false,
earnOnNoShow: false,
cardFont: LoyaltyCardFont.SANS,
cardTitleTextSize: LoyaltyCardTextSize.MD,
};
return (
<SettingsPageShell title="Loyalty Programme">
<LoyaltyConfigForm
businessName={context.businessName}
initialConfig={initialConfig}
initialEligibleServiceIds={programme?.eligibleServices.map((s) => s.serviceId) ?? []}
initialEligibleStaffIds={programme?.eligibleStaff.map((s) => s.staffId) ?? []}
services={services.map((s) => ({ id: s.id, name: s.name }))}
staff={staff.map((s) => ({ id: s.id, name: s.user.name ?? "Staff" }))}
/>
</SettingsPageShell>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { redirect } from "next/navigation";
/**
* Kept as a redirect rather than deleted so any existing bookmark to
* `/owner/settings` still works (see the Phase 11 merged-nav brief: "avoid
* unnecessary route changes... add redirects only where old nav entry
* routes become obsolete"). The page it used to render — a mix of personal
* settings (Notifications) and business settings (Business Details,
* Gallery, Hours, Booking Rules, Loyalty, Reviews) plus a "Switch to
* Barber Tools" link — is now split cleanly: personal settings live at the
* one shared `/staff/settings` hub, and business settings are reachable
* from the "Manage" nav item's grouped links (see (owner)/owner/page.tsx).
*/
export default function OwnerSettingsRedirectPage() {
redirect("/staff/settings");
}
@@ -0,0 +1,37 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { SettingsPageShell } from "@/components/settings/settings-page-shell";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { getBusinessProfile } from "@/services/business/business.service";
import { ReviewsManager } from "@/features/owner/components/reviews-manager";
export const metadata: Metadata = { title: "Reviews" };
export const dynamic = "force-dynamic";
export default async function OwnerReviewsPage() {
const session = await auth();
if (!session?.user) redirect("/sign-in");
const canManage = await hasPermission(session.user.id, Permission.MANAGE_REVIEWS);
if (!canManage) redirect("/staff");
const context = await getActingBusinessContext();
if (!context) redirect("/staff");
const business = await getBusinessProfile(prisma, context.businessId);
if (!business) redirect("/owner");
return (
<SettingsPageShell title="Reviews">
<ReviewsManager
initialGooglePlaceId={business.googlePlaceId ?? ""}
googleRating={business.googleRating ? Number(business.googleRating) : null}
googleRatingCount={business.googleRatingCount}
/>
</SettingsPageShell>
);
}
@@ -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 { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
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 context = await getActingBusinessContext();
if (!context) redirect("/staff");
const { id } = await params;
const target = await prisma.staffProfile.findFirst({
where: { id, businessId: context.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>
);
}
@@ -0,0 +1,84 @@
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, Weekday } from "@/generated/prisma/enums";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { getWorkingHours } from "@/services/staff/staff.service";
import { WorkingHoursForm } from "@/features/staff/components/working-hours-form";
import { updateStaffWorkingHoursAction } from "@/features/owner/actions/staff";
export const metadata: Metadata = { title: "Staff Working Hours" };
export const dynamic = "force-dynamic";
const DAY_ORDER: Weekday[] = [
Weekday.MONDAY,
Weekday.TUESDAY,
Weekday.WEDNESDAY,
Weekday.THURSDAY,
Weekday.FRIDAY,
Weekday.SATURDAY,
Weekday.SUNDAY,
];
function formatMinutes(m: number): string {
const h = Math.floor(m / 60);
const min = m % 60;
return `${String(h).padStart(2, "0")}:${String(min).padStart(2, "0")}`;
}
export default async function OwnerStaffWorkingHoursPage({ 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 context = await getActingBusinessContext();
if (!context) redirect("/staff");
const { id } = await params;
const target = await prisma.staffProfile.findFirst({
where: { id, businessId: context.businessId },
include: { user: { select: { name: true } } },
});
if (!target) notFound();
const [workingHours, businessHours] = await Promise.all([
getWorkingHours(prisma, id),
prisma.businessHours.findMany({ where: { businessId: context.businessId } }),
]);
const businessHoursLabel = Object.fromEntries(
DAY_ORDER.map((day) => {
const hours = businessHours.find((h) => h.dayOfWeek === day);
const label =
!hours || hours.isClosed || hours.opensAtMinutes === null || hours.closesAtMinutes === null
? "Closed"
: `${formatMinutes(hours.opensAtMinutes)}–${formatMinutes(hours.closesAtMinutes)}`;
return [day, label];
}),
) as Record<Weekday, string>;
return (
<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={updateStaffWorkingHoursAction.bind(null, id)}
appointmentHrefBase="/owner/appointments"
/>
</main>
</div>
);
}
+108
View File
@@ -0,0 +1,108 @@
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";
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 { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { getStaffDetailById, listRolesForBusiness } from "@/services/staff/staff.service";
import { StaffDetailPanel } from "@/features/owner/components/staff-detail-panel";
export const metadata: Metadata = { title: "Staff Details" };
export const dynamic = "force-dynamic";
export default async function OwnerStaffDetailPage({ 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 context = await getActingBusinessContext();
if (!context) redirect("/staff");
const { id } = await params;
const [target, roles, allServices, calendarConnection] = await Promise.all([
getStaffDetailById(prisma, { staffId: id, businessId: context.businessId }),
listRolesForBusiness(prisma, context.businessId),
prisma.service.findMany({ where: { businessId: context.businessId, isActive: true }, orderBy: { displayOrder: "asc" } }),
// Status only — never tokens (see the brief's "Owners must not see
// OAuth tokens, refresh tokens, or unrelated private calendar
// events"). Owners can see whether a barber has connected, not
// connect on their behalf.
prisma.staffCalendarConnection.findUnique({ where: { staffId: id }, select: { revokedAt: true, lastSyncError: true } }),
]);
if (!target) notFound();
return (
<div className="pb-12">
<TopNav title="Staff Details" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl space-y-6 px-6 py-6 lg:max-w-4xl lg:grid lg:max-w-none lg:grid-cols-[320px_1fr] lg:items-start lg:gap-6 lg:space-y-0">
<div className="space-y-4">
<Card className="flex flex-col items-center p-6 text-center">
{target.user.image ? (
<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)}
</div>
)}
<h2 className="text-xl font-bold">{target.user.name}</h2>
<p className="text-accent mt-1 text-xs font-bold tracking-widest uppercase">
{target.role.name} · {target.status === "ACTIVE" ? "Active" : target.status === "ON_BREAK" ? "On Break" : "Inactive"}
</p>
<p className="text-muted mt-2 text-xs">Joined {target.createdAt.toLocaleDateString("en-GB", { month: "short", year: "numeric" })}</p>
{target.bio && <p className="text-muted mt-3 text-sm">{target.bio}</p>}
</Card>
<div className="grid grid-cols-2 gap-3">
{target.user.phone && (
<a href={`tel:${target.user.phone}`} className="border-line flex items-center justify-center gap-2 rounded-xl border bg-white py-4 text-sm font-bold">
<Phone className="text-accent h-4 w-4" aria-hidden="true" /> Call
</a>
)}
{target.user.email && (
<a href={`mailto:${target.user.email}`} className="border-line flex items-center justify-center gap-2 rounded-xl border bg-white py-4 text-sm font-bold">
<Mail className="text-accent h-4 w-4" aria-hidden="true" /> Email
</a>
)}
</div>
<Card className="p-4">
<p className="text-muted mb-1 text-[10px] font-bold tracking-widest uppercase">Google Calendar</p>
<p className="text-sm font-bold">
{!calendarConnection || calendarConnection.revokedAt
? "Not connected"
: calendarConnection.lastSyncError
? "Sync error"
: "Connected"}
</p>
{calendarConnection?.lastSyncError && !calendarConnection.revokedAt && (
<p className="text-danger mt-1 text-xs">{calendarConnection.lastSyncError}</p>
)}
</Card>
</div>
<StaffDetailPanel
staffId={target.id}
currentRoleId={target.roleId}
roles={roles.map((r) => ({ id: r.id, name: r.name }))}
allServices={allServices.map((s) => ({ id: s.id, name: s.name }))}
initialServiceIds={target.services.map((s) => s.serviceId)}
status={target.status}
hasPendingInvitation={!!target.invitation && !target.invitation.acceptedAt}
/>
</main>
</div>
);
}
@@ -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 { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
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 context = await getActingBusinessContext();
if (!context) redirect("/staff");
const { id } = await params;
const target = await prisma.staffProfile.findFirst({
where: { id, businessId: context.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>
);
}
+38
View File
@@ -0,0 +1,38 @@
import type { Metadata } from "next";
import { 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 { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { listRolesForBusiness } from "@/services/staff/staff.service";
import { AddStaffForm } from "@/features/owner/components/add-staff-form";
export const metadata: Metadata = { title: "Add Staff" };
export const dynamic = "force-dynamic";
export default async function OwnerAddStaffPage() {
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 context = await getActingBusinessContext();
if (!context) redirect("/staff");
const [roles, services] = await Promise.all([
listRolesForBusiness(prisma, context.businessId),
prisma.service.findMany({ where: { businessId: context.businessId, isActive: true }, orderBy: { displayOrder: "asc" } }),
]);
return (
<div className="pb-12">
<TopNav title="Add Staff" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl px-6 py-6">
<AddStaffForm roles={roles.map((r) => ({ id: r.id, name: r.name }))} services={services.map((s) => ({ id: s.id, name: s.name }))} />
</main>
</div>
);
}
+96
View File
@@ -0,0 +1,96 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Plus, Search } from "lucide-react";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { prisma } from "@/lib/db/prisma";
import { hasPermission } from "@/lib/auth/permissions";
import { Permission } from "@/generated/prisma/enums";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { listStaffForBusiness } from "@/services/staff/staff.service";
import { cn } from "@/lib/utils/cn";
export const metadata: Metadata = { title: "Team" };
export const dynamic = "force-dynamic";
export default async function OwnerTeamPage({
searchParams,
}: {
searchParams: Promise<{ q?: 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 context = await getActingBusinessContext();
if (!context) redirect("/staff");
const { q = "" } = await searchParams;
const staff = await listStaffForBusiness(prisma, { businessId: context.businessId, q, includeInactive: true });
return (
<div className="px-6 pt-12 pb-12 lg:max-w-4xl">
<div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-bold tracking-tight">Team</h1>
<Link
href="/owner/team/add"
className="bg-ink flex h-10 w-10 items-center justify-center rounded-full text-white"
aria-label="Add staff member"
>
<Plus className="h-4 w-4" aria-hidden="true" />
</Link>
</div>
<form action="/owner/team" method="GET" className="relative mb-4">
<Search className="text-muted absolute top-1/2 left-4 h-4 w-4 -translate-y-1/2" aria-hidden="true" />
<label htmlFor="team-search" className="sr-only">
Search staff by name or email
</label>
<input
id="team-search"
type="search"
name="q"
defaultValue={q}
placeholder="Search by name or email"
className="border-line focus:ring-ink w-full rounded-xl border bg-white py-3 pr-4 pl-11 text-sm outline-none focus:ring-2"
/>
</form>
<div className="grid gap-3 lg:grid-cols-2">
{staff.length === 0 ? (
<p className="text-muted col-span-full rounded-md border border-line bg-white p-5 text-center text-sm">
No staff match &ldquo;{q}&rdquo;.
</p>
) : (
staff.map((s) => (
<Link
key={s.id}
href={`/owner/team/${s.id}`}
className={cn(
"rounded-md border-line flex items-center gap-4 border bg-white p-5",
s.status === "INACTIVE" && "opacity-60",
)}
>
<div className="bg-stone text-muted flex h-12 w-12 shrink-0 items-center justify-center rounded-full text-sm font-bold">
{(s.user.name ?? "?").charAt(0)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-bold">{s.user.name}</p>
<p className="text-muted truncate text-xs">{s.user.email}</p>
<p className="text-accent mt-0.5 text-[10px] font-bold tracking-widest uppercase">
{s.role.name}
{s.status === "INACTIVE" && <span className="text-muted"> · Inactive</span>}
{s.status === "ACTIVE" && s.invitation && !s.invitation.acceptedAt && (
<span className="text-warning-foreground"> · Invite Pending</span>
)}
</p>
</div>
</Link>
))
)}
</div>
</div>
);
}
+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} />;
}
+39
View File
@@ -0,0 +1,39 @@
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { StaffShell } from "@/components/navigation/staff-shell";
import { getNavRole } from "@/lib/auth/permissions";
import { getNavItems } from "@/lib/navigation/nav-config";
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();
if (!session?.user) {
redirect("/sign-in?callbackUrl=/staff");
}
// Real role gating, not just "is signed in" — a registered customer with
// no StaffProfile (and no Super Administrator flag) is bounced back to
// their own home rather than seeing an empty staff shell. See
// docs/implementation-plan.md §3 (role conflicts) and §11 (permission
// enforcement). A Super Administrator has no StaffProfile by design (see
// isSuperAdmin) but is still admitted here — they need this route group
// for Clients and the personal Settings hub (Profile/Notifications/Sign
// Out), even though they have no personal barber schedule.
const role = await getNavRole(session.user.id);
if (!role) {
redirect("/home");
}
const unreadNotificationCount = await getUnreadNotificationCount(prisma, session.user.id);
return (
<StaffShell
items={getNavItems(role)}
notificationsHref="/staff/notifications"
unreadNotificationCount={unreadNotificationCount}
>
{children}
</StaffShell>
);
}
@@ -0,0 +1,296 @@
import type { Metadata } from "next";
import { CalendarPlus, Mail, Phone } from "lucide-react";
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { Card } from "@/components/ui/card";
import { StatusPill } from "@/components/ui/status-pill";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { getLoyaltySummary } from "@/services/loyalty/loyalty.service";
import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
import { getAppointmentById } from "@/services/scheduling/appointment.service";
import { assertOwnAppointment, StaffPermissionDeniedError } from "@/features/staff/authorization";
import { AppointmentStatusActions } from "@/features/staff/components/appointment-status-actions";
import { AddStampButton } from "@/features/staff/components/add-stamp-button";
import { BookFollowUpButton } from "@/features/staff/components/book-follow-up-button";
import { RedeemRewardButton } from "@/features/owner/components/redeem-reward-button";
import type { AppointmentStatus } from "@/types/domain";
export const metadata: Metadata = { title: "Appointment Details" };
export const dynamic = "force-dynamic";
const STATUS_MAP: Record<string, AppointmentStatus> = {
PENDING: "pending_approval",
CONFIRMED: "confirmed",
CHECKED_IN: "arrived",
IN_PROGRESS: "in_progress",
COMPLETED: "completed",
CANCELLED: "cancelled",
NO_SHOW: "no_show",
};
const RESCHEDULABLE = new Set(["PENDING", "CONFIRMED", "CHECKED_IN"]);
export default async function StaffAppointmentDetailsPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const staff = await getActingStaff();
if (!staff) redirect("/home");
const { id } = await params;
const appointment = await getAppointmentById(prisma, id);
if (!appointment) notFound();
try {
assertOwnAppointment(appointment, staff);
} catch (error) {
if (error instanceof StaffPermissionDeniedError) notFound();
throw error;
}
const [notes, loyaltyTransactions, loyalty, cancelledByUser] = await Promise.all([
prisma.customerNote.findMany({
where: { customerId: appointment.customerId },
orderBy: { createdAt: "desc" },
take: 5,
include: { author: { include: { user: { select: { name: true } } } } },
}),
prisma.loyaltyTransaction.findMany({
where: { appointmentId: appointment.id },
orderBy: { createdAt: "desc" },
}),
getLoyaltySummary(prisma, { customerId: appointment.customerId, businessId: staff.businessId }),
appointment.cancelledByUserId
? prisma.user.findUnique({
where: { id: appointment.cancelledByUserId },
select: { name: true },
})
: null,
]);
const tz = staff.business.timezone;
const totalDuration = appointment.services.reduce(
(sum, s) => sum + s.durationAtBookingMinutes,
0,
);
const totalPrice = appointment.services.reduce((sum, s) => sum + Number(s.priceAtBooking), 0);
const discount = appointment.discountAmount ? Number(appointment.discountAmount) : 0;
const canRedeemReward =
loyalty?.availableReward && appointment.discountAmount === null && appointment.status !== "CANCELLED";
return (
<div className="pb-12">
<TopNav title="Appointment Details" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl space-y-4 px-6 py-6">
<Card className="space-y-6 p-6">
<div className="flex items-center justify-between">
<StatusPill status={STATUS_MAP[appointment.status] ?? "confirmed"} />
<span className="text-muted text-[10px] font-bold tracking-widest uppercase">
Ref: {appointment.id.slice(-8)}
</span>
</div>
<div className="border-line space-y-1 border-t pt-4">
<Link
href={`/staff/clients/${appointment.customerId}`}
className="text-lg font-bold underline"
>
{appointment.customer.firstName} {appointment.customer.lastName}
</Link>
<p className="text-muted text-sm">
{appointment.customer.isGuest ? "Guest customer" : "Registered customer"}
</p>
</div>
<div className="flex flex-wrap gap-3">
{appointment.customer.phone && (
<a
href={`tel:${appointment.customer.phone}`}
className="border-line flex items-center gap-2 rounded-lg border bg-white px-4 py-2 text-xs font-bold"
>
<Phone className="text-accent h-3.5 w-3.5" aria-hidden="true" /> Call
</a>
)}
<a
href={`mailto:${appointment.customer.email}`}
className="border-line flex items-center gap-2 rounded-lg border bg-white px-4 py-2 text-xs font-bold"
>
<Mail className="text-accent h-3.5 w-3.5" aria-hidden="true" /> Email
</a>
</div>
<div className="border-line grid grid-cols-2 gap-4 border-t pt-4 text-sm">
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Services
</span>
<p className="font-bold">
{appointment.services.map((s) => s.service.name).join(" + ")}
</p>
</div>
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Date
</span>
<p className="font-bold">
{appointment.startAt.toLocaleDateString("en-GB", {
weekday: "short",
day: "numeric",
month: "short",
timeZone: tz,
})}
</p>
</div>
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Time
</span>
<p className="font-bold">
{appointment.startAt.toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
timeZone: tz,
})}{" "}
–{" "}
{appointment.endAt.toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
timeZone: tz,
})}
</p>
</div>
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Duration
</span>
<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>
{appointment.notes && (
<div className="border-line border-t pt-4">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Booking Notes
</span>
<p className="text-muted mt-1 text-sm italic">{appointment.notes}</p>
</div>
)}
{notes.length > 0 && (
<div className="border-line border-t pt-4">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Customer Notes
</span>
<div className="mt-2 space-y-2">
{notes.map((n) => (
<p key={n.id} className="text-muted text-xs">
<span className="font-bold">{n.author.user.name ?? "Staff"}:</span> {n.body}
</p>
))}
</div>
</div>
)}
{loyalty?.isEnabled && (
<div className="border-line border-t pt-4">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Loyalty
</span>
<p className="text-sm font-bold">
{loyalty.currentStamps}/{resolveRewardThreshold(loyalty)} stamps
{loyalty.availableReward && (
<span className="text-accent-loyalty"> · Reward available</span>
)}
</p>
{loyaltyTransactions.length > 0 && (
<ul className="mt-2 space-y-1">
{loyaltyTransactions.map((t) => (
<li key={t.id} className="text-muted text-xs">
{t.amount > 0 ? "+" : ""}
{t.amount} stamp{Math.abs(t.amount) === 1 ? "" : "s"} —{" "}
{t.type.replace("_", " ").toLowerCase()}
{t.reason ? ` (${t.reason})` : ""}
</li>
))}
</ul>
)}
</div>
)}
{appointment.status === "CANCELLED" && (
<div className="border-line border-t pt-4 text-sm">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Cancellation
</span>
<p className="text-muted mt-1">
Cancelled {appointment.cancelledAt?.toLocaleString("en-GB", { timeZone: tz })}
{cancelledByUser ? ` by ${cancelledByUser.name}` : ""}
</p>
{appointment.cancellationReason && (
<p className="text-muted mt-1 italic">
&ldquo;{appointment.cancellationReason}&rdquo;
</p>
)}
</div>
)}
</Card>
<div className="flex flex-wrap gap-3">
<a
href={`/api/appointments/${appointment.id}/calendar.ics`}
className="rounded-md border-line flex items-center gap-2 border bg-white px-5 py-3 text-sm font-bold"
>
<CalendarPlus className="text-accent h-4 w-4" aria-hidden="true" /> Add to Calendar
</a>
{RESCHEDULABLE.has(appointment.status) && (
<Link
href={`/staff/appointments/${appointment.id}/reschedule`}
className="rounded-md border-line border bg-white px-5 py-3 text-sm font-bold"
>
Reschedule
</Link>
)}
{appointment.status === "COMPLETED" && (
<BookFollowUpButton appointmentId={appointment.id} />
)}
</div>
<AppointmentStatusActions appointmentId={appointment.id} status={appointment.status} />
<div className="flex flex-wrap gap-3">
{loyalty?.isEnabled && (
<AddStampButton customerId={appointment.customerId} appointmentId={appointment.id} />
)}
{canRedeemReward && loyalty?.availableReward && (
<RedeemRewardButton
appointmentId={appointment.id}
rewardId={loyalty.availableReward.id}
rewardLabel={loyalty.availableReward.label}
subtotal={totalPrice}
/>
)}
</div>
</main>
</div>
);
}
+281
View File
@@ -0,0 +1,281 @@
import type { Metadata } from "next";
import Image from "next/image";
import Link from "next/link";
import { CalendarX2, Mail, Phone } from "lucide-react";
import { notFound, redirect } from "next/navigation";
import { AppointmentCard } from "@/components/ui/appointment-card";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { EmptyState } from "@/components/feedback/empty-state";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { getLoyaltyHistory, getLoyaltySummary } from "@/services/loyalty/loyalty.service";
import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
import { listAppointments } from "@/services/scheduling/appointment.service";
import { AddStampButton } from "@/features/staff/components/add-stamp-button";
import { CorrectStampButton } from "@/features/staff/components/correct-stamp-button";
import { CustomerNotes } from "@/features/staff/components/customer-notes";
import type { CustomerNoteItem } from "@/features/staff/components/customer-notes";
import type { AppointmentStatus } from "@/types/domain";
export const metadata: Metadata = { title: "Client Details" };
export const dynamic = "force-dynamic";
const STATUS_MAP: Record<string, AppointmentStatus> = {
PENDING: "pending_approval",
CONFIRMED: "confirmed",
CHECKED_IN: "arrived",
IN_PROGRESS: "in_progress",
COMPLETED: "completed",
CANCELLED: "cancelled",
NO_SHOW: "no_show",
};
export default async function StaffCustomerDetailsPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const context = await getActingBusinessContext();
if (!context) redirect("/home");
const { id } = await params;
const customer = await prisma.customerProfile.findUnique({
where: { id },
include: {
preferredBarber: { include: { user: { select: { name: true } } } },
user: { select: { image: true } },
},
});
if (!customer) notFound();
// "Belongs to this business" is via the appointment relation — see
// docs/phase-5-summary.md, same reasoning as the clients list query.
const belongsToBusiness = await prisma.appointment.findFirst({
where: { customerId: id, businessId: context.businessId },
select: { id: true },
});
if (!belongsToBusiness) notFound();
const [upcoming, past, cancelled, notesRaw, loyalty] = await Promise.all([
listAppointments(prisma, {
customerId: id,
businessId: context.businessId,
statuses: ["PENDING", "CONFIRMED", "CHECKED_IN", "IN_PROGRESS"],
}),
listAppointments(prisma, {
customerId: id,
businessId: context.businessId,
statuses: ["COMPLETED"],
}),
listAppointments(prisma, {
customerId: id,
businessId: context.businessId,
statuses: ["CANCELLED", "NO_SHOW"],
}),
prisma.customerNote.findMany({
where: { customerId: id },
orderBy: { createdAt: "desc" },
include: { author: { include: { user: { select: { name: true } } } } },
}),
getLoyaltySummary(prisma, { customerId: id, businessId: context.businessId }),
]);
const loyaltyTransactions = loyalty?.accountId
? await getLoyaltyHistory(prisma, { loyaltyAccountId: loyalty.accountId, limit: 10 })
: [];
const notes: CustomerNoteItem[] = notesRaw.map((n) => ({
id: n.id,
body: n.body,
createdAt: n.createdAt.toISOString(),
updatedAt: n.updatedAt.toISOString(),
authorStaffId: n.authorStaffId,
authorName: n.author.user.name ?? "Staff",
}));
const tz = context.businessTimezone;
const renderList = (appointments: typeof upcoming, emptyLabel: string) => {
if (appointments.length === 0) {
return <EmptyState icon={CalendarX2} title={emptyLabel} />;
}
return (
<div className="space-y-3">
{appointments.map((a) => (
<AppointmentCard
key={a.id}
status={STATUS_MAP[a.status] ?? "confirmed"}
serviceName={a.services.map((s) => s.service.name).join(" + ")}
person={{ id: a.staffId, name: a.staff.user.name ?? "Barber" }}
formattedDate={a.startAt.toLocaleDateString("en-GB", {
weekday: "short",
day: "numeric",
month: "short",
timeZone: tz,
})}
formattedTime={a.startAt.toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
timeZone: tz,
})}
actions={
<Link
href={`/staff/appointments/${a.id}`}
className="border-line rounded-lg border px-4 py-2 text-xs font-bold tracking-widest uppercase"
>
View
</Link>
}
/>
))}
</div>
);
};
return (
<div className="pb-12">
<TopNav title="Client Details" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl space-y-4 px-6 py-6 lg:max-w-5xl lg:items-start lg:gap-6 lg:space-y-0 lg:[display:grid] lg:grid-cols-[360px_1fr]">
<Card className="space-y-5 p-6">
<div className="flex items-center gap-4">
{customer.user?.image ? (
<Image
src={customer.user.image}
alt=""
width={56}
height={56}
className="h-14 w-14 rounded-full object-cover"
/>
) : (
<div className="bg-stone text-muted flex h-14 w-14 items-center justify-center rounded-full text-lg font-bold">
{customer.firstName.charAt(0)}
{customer.lastName.charAt(0)}
</div>
)}
<div>
<p className="text-lg font-bold">
{customer.firstName} {customer.lastName}
</p>
<p className="text-muted text-xs">
{customer.isGuest ? "Guest customer" : "Registered customer"}
</p>
</div>
</div>
<div className="border-line space-y-1 border-t pt-4 text-sm">
<p className="text-muted">{customer.email}</p>
{customer.phone && <p className="text-muted">{customer.phone}</p>}
{customer.preferredBarber && (
<p className="text-muted">
Preferred barber: {customer.preferredBarber.user.name ?? "—"}
</p>
)}
</div>
<div className="border-line flex flex-wrap gap-3 border-t pt-4">
{customer.phone && (
<a
href={`tel:${customer.phone}`}
className="border-line flex h-12 min-w-[104px] flex-1 items-center justify-center gap-2 rounded-md border bg-white px-4 text-xs font-bold"
>
<Phone className="text-accent h-3.5 w-3.5" aria-hidden="true" /> Call
</a>
)}
<a
href={`mailto:${customer.email}`}
className="border-line flex h-12 min-w-[104px] flex-1 items-center justify-center gap-2 rounded-md border bg-white px-4 text-xs font-bold"
>
<Mail className="text-accent h-3.5 w-3.5" aria-hidden="true" /> Email
</a>
<Button asChild size="md" className="min-w-[104px] flex-1">
<Link href={`/staff/book?customerId=${customer.id}`}>Book Appointment</Link>
</Button>
</div>
{loyalty?.isEnabled && loyalty.accountId && (
<div className="border-line border-t pt-4">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Loyalty
</span>
<p className="mb-3 text-sm font-bold">
{loyalty.currentStamps}/{resolveRewardThreshold(loyalty)} stamps
{loyalty.availableReward && (
<span className="text-accent-loyalty"> · Reward available</span>
)}
</p>
<div className="mb-3 flex flex-wrap gap-2">
<AddStampButton customerId={id} className="min-w-[140px] flex-1" />
<CorrectStampButton
loyaltyAccountId={loyalty.accountId}
className="min-w-[140px] flex-1"
/>
</div>
{loyaltyTransactions.length > 0 && (
<ul className="space-y-1">
{loyaltyTransactions.map((t) => (
<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.type.replace(/_/g, " ").toLowerCase()}
{t.reason ? ` (${t.reason})` : ""}
{t.authoredByStaff?.user.name ? ` · ${t.authoredByStaff.user.name}` : ""}
</li>
))}
</ul>
)}
</div>
)}
<div className="border-line grid grid-cols-3 gap-3 border-t pt-4 text-center">
<div>
<p className="text-lg font-bold">{past.length}</p>
<p className="text-muted text-[10px] font-bold tracking-widest uppercase">Visits</p>
</div>
<div>
<p className="text-lg font-bold">{upcoming.length}</p>
<p className="text-muted text-[10px] font-bold tracking-widest uppercase">Upcoming</p>
</div>
<div>
<p className="text-lg font-bold">{cancelled.length}</p>
<p className="text-muted text-[10px] font-bold tracking-widest uppercase">
Cancelled
</p>
</div>
</div>
</Card>
<div className="mt-4 space-y-4 lg:mt-0">
<Card className="p-6">
<h2 className="text-muted mb-4 text-sm font-bold tracking-widest uppercase">Notes</h2>
{/* A Super Administrator has no StaffProfile/staffId — passing "" is a
deliberate never-matches sentinel (see CustomerNotes' isOwn check),
so they can view every note but own none. Adding a note as a
Super Administrator fails gracefully server-side (see
addCustomerNoteAction: "You must be signed in as staff.") rather
than fabricating a staff identity for them. */}
<CustomerNotes customerId={customer.id} notes={notes} actingStaffId={context.staffId ?? ""} />
</Card>
<Tabs defaultValue="upcoming">
<TabsList>
<TabsTrigger value="upcoming">Upcoming</TabsTrigger>
<TabsTrigger value="past">Past</TabsTrigger>
<TabsTrigger value="cancelled">Cancelled</TabsTrigger>
</TabsList>
<TabsContent value="upcoming">
{renderList(upcoming, "No upcoming appointments")}
</TabsContent>
<TabsContent value="past">{renderList(past, "No past appointments")}</TabsContent>
<TabsContent value="cancelled">
{renderList(cancelled, "No cancelled appointments")}
</TabsContent>
</Tabs>
</div>
</main>
</div>
);
}
+184
View File
@@ -0,0 +1,184 @@
import type { Metadata } from "next";
import { Search, Users } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { EmptyState } from "@/components/feedback/empty-state";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
import { matchesCustomerSearch } from "@/services/customer/customer.service";
import { getLoyaltySummariesForCustomers } from "@/services/loyalty/loyalty.service";
import { InlineNotificationBell } from "@/features/notifications/components/notification-bell";
import { cn } from "@/lib/utils/cn";
export const metadata: Metadata = { title: "Clients" };
export const dynamic = "force-dynamic";
const UPCOMING_STATUSES = ["PENDING", "CONFIRMED", "CHECKED_IN"];
export default async function StaffClientsPage({
searchParams,
}: {
searchParams: Promise<{ q?: string; sort?: string }>;
}) {
const context = await getActingBusinessContext();
if (!context) redirect("/home");
const { q = "", sort } = await searchParams;
const unreadNotificationCount = await getUnreadNotificationCount(prisma, context.userId);
// 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). `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: context.businessId } } },
select: {
id: true,
firstName: true,
lastName: true,
email: true,
phone: true,
appointments: {
where: { businessId: context.businessId },
orderBy: { startAt: "desc" },
select: { status: true, startAt: true },
},
},
});
const loyaltySummaries = await getLoyaltySummariesForCustomers(prisma, {
businessId: context.businessId,
customerIds: customers.map((c) => c.id),
});
const filtered = customers.filter((c) => matchesCustomerSearch(c, q));
const sorted = [...filtered].sort((a, b) => {
if (sort === "recent") {
const aLatest = a.appointments[0]?.startAt.getTime() ?? 0;
const bLatest = b.appointments[0]?.startAt.getTime() ?? 0;
return bLatest - aLatest;
}
return `${a.lastName} ${a.firstName}`.localeCompare(`${b.lastName} ${b.firstName}`);
});
const now = new Date();
return (
<div className="pb-8 lg:max-w-5xl">
<TopNav
title="Clients"
sticky
className="bg-stone"
leftAction={<InlineNotificationBell href="/staff/notifications" unreadCount={unreadNotificationCount} />}
/>
<div className="px-6 pt-6">
<form action="/staff/clients" method="GET" className="relative mb-4">
{sort && <input type="hidden" name="sort" value={sort} />}
<Search
className="text-muted absolute top-1/2 left-4 h-4 w-4 -translate-y-1/2"
aria-hidden="true"
/>
<label htmlFor="client-search" className="sr-only">
Search clients by name, email, or phone
</label>
<input
id="client-search"
type="search"
name="q"
defaultValue={q}
placeholder="Search by name, email, or phone"
className="border-line focus:ring-ink w-full rounded-xl border bg-white py-3 pr-4 pl-11 text-sm outline-none focus:ring-2"
/>
</form>
<div className="mb-4 flex gap-2">
<Link
href={`/staff/clients?q=${encodeURIComponent(q)}&sort=name`}
className={cn(
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
sort !== "recent" ? "bg-ink text-white" : "border-line text-muted border bg-white",
)}
>
Alphabetical
</Link>
<Link
href={`/staff/clients?q=${encodeURIComponent(q)}&sort=recent`}
className={cn(
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
sort === "recent" ? "bg-ink text-white" : "border-line text-muted border bg-white",
)}
>
Recent
</Link>
</div>
{sorted.length === 0 ? (
<EmptyState
icon={Users}
title={q ? "No matching clients" : "No clients yet"}
description={
q
? "Try a different name, email, or phone number."
: "Clients will show up here once they've booked with you."
}
/>
) : (
<div className="grid gap-3 lg:grid-cols-2">
{sorted.map((customer) => {
const completedVisits = customer.appointments.filter(
(a) => a.status === "COMPLETED",
).length;
const lastVisit = customer.appointments.find((a) => a.status === "COMPLETED");
const upcoming = customer.appointments.find(
(a) => UPCOMING_STATUSES.includes(a.status) && a.startAt > now,
);
const loyalty = loyaltySummaries.get(customer.id);
return (
<Link
key={customer.id}
href={`/staff/clients/${customer.id}`}
className="rounded-md border-line hover:bg-stone flex items-center gap-3 border bg-white px-5 py-4"
>
<div className="bg-stone text-muted flex h-11 w-11 shrink-0 items-center justify-center rounded-full text-sm font-bold">
{customer.firstName.charAt(0)}
{customer.lastName.charAt(0)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-bold">
{customer.firstName} {customer.lastName}
</p>
<p className="text-muted truncate text-xs">
{customer.phone ?? customer.email}
{lastVisit &&
` · Last visit ${lastVisit.startAt.toLocaleDateString("en-GB", { day: "numeric", month: "short" })}`}
</p>
<p className="text-muted mt-0.5 text-[10px] font-bold tracking-widest uppercase">
{completedVisits} visit{completedVisits === 1 ? "" : "s"}
{upcoming && <span className="text-accent"> · Upcoming</span>}
{loyalty && (
<span className={loyalty.hasAvailableReward ? "text-accent-loyalty" : ""}>
{" "}
· {loyalty.currentStamps}/{loyalty.rewardThreshold} stamps
</span>
)}
</p>
</div>
</Link>
);
})}
</div>
)}
</div>
</div>
);
}
+249
View File
@@ -0,0 +1,249 @@
import type { Metadata } from "next";
import { Mail, NotebookText, Phone, Scissors } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { Card } from "@/components/ui/card";
import { StatusPill } from "@/components/ui/status-pill";
import { EmptyState } from "@/components/feedback/empty-state";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { todayIn } from "@/lib/time/business-time";
import { getLoyaltySummary } from "@/services/loyalty/loyalty.service";
import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
import {
listAppointments,
selectCurrentAppointment,
} from "@/services/scheduling/appointment.service";
import { AppointmentStatusActions } from "@/features/staff/components/appointment-status-actions";
import { BookFollowUpButton } from "@/features/staff/components/book-follow-up-button";
import { AddStampButton } from "@/features/staff/components/add-stamp-button";
import { InlineNotificationBell } from "@/features/notifications/components/notification-bell";
import type { AppointmentStatus } from "@/types/domain";
export const metadata: Metadata = { title: "Current Appointment" };
export const dynamic = "force-dynamic";
const STATUS_MAP: Record<string, AppointmentStatus> = {
PENDING: "pending_approval",
CONFIRMED: "confirmed",
CHECKED_IN: "arrived",
IN_PROGRESS: "in_progress",
COMPLETED: "completed",
CANCELLED: "cancelled",
NO_SHOW: "no_show",
};
export default async function StaffCurrentPage() {
const staff = await getActingStaff();
if (!staff) redirect("/home");
const today = todayIn(staff.business.timezone);
const todaysAppointments = await listAppointments(prisma, {
businessId: staff.businessId,
staffId: staff.id,
fromDate: today,
toDate: today,
businessTimezone: staff.business.timezone,
});
const current = selectCurrentAppointment(todaysAppointments, new Date());
const unreadNotificationCount = await getUnreadNotificationCount(prisma, staff.user.id);
if (!current) {
return (
<div className="pb-8">
<TopNav
title="Current Appointment"
sticky
className="bg-stone"
leftAction={<InlineNotificationBell href="/staff/notifications" unreadCount={unreadNotificationCount} />}
/>
<div className="px-6 pt-6">
<EmptyState
icon={Scissors}
title="Nothing scheduled right now"
description="Your next appointment today will show up here."
/>
</div>
</div>
);
}
const [notes, lastCompleted, loyalty] = await Promise.all([
prisma.customerNote.findMany({
where: { customerId: current.customerId },
orderBy: { createdAt: "desc" },
take: 2,
include: { author: { include: { user: { select: { name: true } } } } },
}),
prisma.appointment.findFirst({
where: { customerId: current.customerId, status: "COMPLETED", id: { not: current.id } },
orderBy: { startAt: "desc" },
include: { services: { include: { service: true } } },
}),
getLoyaltySummary(prisma, { customerId: current.customerId, businessId: staff.businessId }),
]);
const totalDuration = current.services.reduce((sum, s) => sum + s.durationAtBookingMinutes, 0);
const totalPrice = current.services.reduce((sum, s) => sum + Number(s.priceAtBooking), 0);
const tz = staff.business.timezone;
return (
<div className="pb-8">
<TopNav
title="Current Appointment"
sticky
className="bg-stone"
leftAction={<InlineNotificationBell href="/staff/notifications" unreadCount={unreadNotificationCount} />}
/>
<div className="space-y-4 px-6 pt-6">
<Card className="space-y-5 p-6">
<div className="flex items-start justify-between">
<div>
<p className="text-lg font-bold">
{current.customer.firstName} {current.customer.lastName}
</p>
<p className="text-muted text-xs">
{current.customer.isGuest ? "Guest customer" : "Registered customer"}
</p>
</div>
<StatusPill status={STATUS_MAP[current.status] ?? "confirmed"} />
</div>
<div className="flex flex-wrap gap-3">
{current.customer.phone && (
<a
href={`tel:${current.customer.phone}`}
className="border-line flex items-center gap-2 rounded-lg border bg-white px-4 py-2 text-xs font-bold"
>
<Phone className="text-accent h-3.5 w-3.5" aria-hidden="true" /> Call
</a>
)}
<a
href={`mailto:${current.customer.email}`}
className="border-line flex items-center gap-2 rounded-lg border bg-white px-4 py-2 text-xs font-bold"
>
<Mail className="text-accent h-3.5 w-3.5" aria-hidden="true" /> Email
</a>
<Link
href={`/staff/clients/${current.customerId}`}
className="border-line flex items-center gap-2 rounded-lg border bg-white px-4 py-2 text-xs font-bold"
>
View Customer
</Link>
</div>
<div className="border-line grid grid-cols-2 gap-4 border-t pt-4 text-sm">
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Services
</span>
<p className="font-bold">{current.services.map((s) => s.service.name).join(" + ")}</p>
</div>
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Time
</span>
<p className="font-bold">
{current.startAt.toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
timeZone: tz,
})}{" "}
–{" "}
{current.endAt.toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
timeZone: tz,
})}
</p>
</div>
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Duration
</span>
<p className="font-bold">{totalDuration} min</p>
</div>
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Price
</span>
<p className="font-bold">£{totalPrice.toFixed(2)}</p>
</div>
</div>
{current.notes && (
<div className="border-line border-t pt-4">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Booking Notes
</span>
<p className="text-muted mt-1 text-sm italic">{current.notes}</p>
</div>
)}
{notes.length > 0 && (
<div className="border-line border-t pt-4">
<span className="text-muted mb-2 flex items-center gap-1.5 text-[10px] font-bold tracking-widest uppercase">
<NotebookText className="h-3 w-3" aria-hidden="true" /> Customer Notes
</span>
<div className="space-y-2">
{notes.map((n) => (
<p key={n.id} className="text-muted text-xs">
<span className="font-bold">{n.author.user.name ?? "Staff"}:</span> {n.body}
</p>
))}
</div>
</div>
)}
{loyalty?.isEnabled && (
<div className="border-line border-t pt-4">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Loyalty
</span>
<p className="text-sm font-bold">
{loyalty.currentStamps}/{resolveRewardThreshold(loyalty)} stamps
{loyalty.availableReward && (
<span className="text-accent-loyalty"> · Reward available</span>
)}
</p>
</div>
)}
{lastCompleted && (
<div className="border-line border-t pt-4">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Last Visit
</span>
<p className="text-muted text-sm">
{lastCompleted.services.map((s) => s.service.name).join(" + ")} ·{" "}
{lastCompleted.startAt.toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
timeZone: tz,
})}
</p>
</div>
)}
</Card>
<AppointmentStatusActions appointmentId={current.id} status={current.status} layout="stack" />
<div className="flex flex-col gap-2">
{loyalty?.isEnabled && (
<AddStampButton
customerId={current.customerId}
appointmentId={current.id}
size="lg"
className="w-full"
/>
)}
<BookFollowUpButton appointmentId={current.id} className="w-full" />
</div>
</div>
</div>
);
}
@@ -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>
);
}
+277
View File
@@ -0,0 +1,277 @@
import type { Metadata } from "next";
import { CalendarX2, Plus } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/feedback/empty-state";
import { AppointmentCard } from "@/components/ui/appointment-card";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import {
calendarDateToUtcMidnight,
minutesFromMidnight,
todayIn,
weekdayOf,
} from "@/lib/time/business-time";
import type { CalendarDate } from "@/lib/time/business-time";
import { getAvailableRewardFlags } from "@/services/loyalty/loyalty.service";
import { listAppointments, selectCurrentAppointment } from "@/services/scheduling/appointment.service";
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
import { DateStrip } from "@/features/staff/components/date-strip";
import { DayCalendar } from "@/features/staff/components/day-calendar";
import type { CalendarAppointment, CalendarBlock, CalendarColumn } from "@/features/staff/components/day-calendar";
import { ViewSwitch } from "@/features/staff/components/view-switch";
import { BlockTimeButton } from "@/features/staff/components/block-time-button";
import { InlineNotificationBell } from "@/features/notifications/components/notification-bell";
import type { AppointmentStatus } from "@/types/domain";
export const metadata: Metadata = { title: "Calendar" };
export const dynamic = "force-dynamic";
const STATUS_MAP: Record<string, AppointmentStatus> = {
PENDING: "pending_approval",
CONFIRMED: "confirmed",
CHECKED_IN: "arrived",
IN_PROGRESS: "in_progress",
COMPLETED: "completed",
CANCELLED: "cancelled",
NO_SHOW: "no_show",
};
export default async function StaffCalendarPage({
searchParams,
}: {
searchParams: Promise<{ date?: string; view?: string }>;
}) {
const staff = await getActingStaff();
if (!staff) redirect("/home");
const { date: dateParam, view: viewParam } = await searchParams;
const today = todayIn(staff.business.timezone);
const date: CalendarDate = dateParam ?? today;
const view = viewParam === "agenda" ? "agenda" : "day";
const weekday = weekdayOf(date);
const dateUtcMidnight = calendarDateToUtcMidnight(date);
const [workingHours, breakDays, timeOffRows, appointments] = await Promise.all([
prisma.workingHours.findUnique({
where: { staffId_dayOfWeek: { staffId: staff.id, dayOfWeek: weekday } },
}),
prisma.breakDay.findMany({
where: { dayOfWeek: weekday, break: { staffId: staff.id } },
include: { break: true },
}),
prisma.timeOff.findMany({
where: {
staffId: staff.id,
status: "APPROVED",
startDate: { lte: dateUtcMidnight },
endDate: { gte: dateUtcMidnight },
},
}),
listAppointments(prisma, {
businessId: staff.businessId,
staffId: staff.id,
fromDate: date,
toDate: date,
businessTimezone: staff.business.timezone,
}),
]);
const wholeDayOff = timeOffRows.find((t) => t.startMinutes === null);
const rewardFlags = await getAvailableRewardFlags(prisma, {
businessId: staff.businessId,
customerIds: [...new Set(appointments.map((a) => a.customerId))],
});
const workingWindow =
workingHours &&
!workingHours.isOff &&
workingHours.startMinutes !== null &&
workingHours.endMinutes !== null
? { startMinutes: workingHours.startMinutes, endMinutes: workingHours.endMinutes }
: null;
const blocks: CalendarBlock[] = [
...breakDays.map((bd) => ({
id: `break-${bd.id}`,
startMinutes: bd.break.startMinutes,
endMinutes: bd.break.endMinutes,
label: bd.break.label,
kind: "break" as const,
})),
...timeOffRows
.filter((t) => t.startMinutes !== null && t.endMinutes !== null)
.map((t) => ({
id: `timeoff-${t.id}`,
startMinutes: t.startMinutes!,
endMinutes: t.endMinutes!,
label: t.reason ?? "Blocked",
kind: "timeOff" as const,
})),
];
const calendarAppointments: CalendarAppointment[] = appointments.map((a) => ({
id: a.id,
startMinutes: minutesFromMidnight(a.startAt, staff.business.timezone),
endMinutes: minutesFromMidnight(a.endAt, staff.business.timezone),
customerName: `${a.customer.firstName} ${a.customer.lastName}`,
serviceNames: a.services.map((s) => s.service.name).join(" + "),
status: STATUS_MAP[a.status] ?? "confirmed",
hasNotes: !!a.notes,
hasAvailableReward: rewardFlags.get(a.customerId) ?? false,
}));
const currentTimeMinutes =
date === today ? minutesFromMidnight(new Date(), staff.business.timezone) : null;
const columns: CalendarColumn[] = [
{
id: staff.id,
name: staff.user.name ?? "You",
avatarUrl: staff.user.image,
hoursLabel: workingWindow ? formatRange(workingWindow.startMinutes, workingWindow.endMinutes) : null,
wholeDayOff: false,
appointments: calendarAppointments,
blocks,
workingWindow,
},
];
// Desktop-only: a "Right Now" panel beside the calendar so a wide screen
// does more than center the mobile layout (see docs/phase-5-summary.md,
// "Responsive Design"). Only meaningful when viewing today.
const rightNow = date === today ? selectCurrentAppointment(appointments, new Date()) : null;
const unreadNotificationCount = await getUnreadNotificationCount(prisma, staff.user.id);
return (
<div className="pb-6">
<TopNav
title="Calendar"
sticky
className="bg-stone"
leftAction={<InlineNotificationBell href="/staff/notifications" unreadCount={unreadNotificationCount} />}
action={
<div className="flex gap-2">
<BlockTimeButton date={date} />
<Button asChild size="sm">
<Link href="/staff/book">
<Plus className="h-4 w-4" aria-hidden="true" />
New
</Link>
</Button>
</div>
}
/>
<DateStrip
selectedDate={date}
businessTimezone={staff.business.timezone}
basePath="/staff"
view={view}
/>
<div className="mt-4 px-6">
<ViewSwitch view={view} dayHref={`/staff?date=${date}&view=day`} agendaHref={`/staff?date=${date}&view=agenda`} />
</div>
<div className="mt-4 lg:flex lg:items-start lg:gap-6 lg:px-6">
<div className="lg:flex-1">
{wholeDayOff ? (
<EmptyState
icon={CalendarX2}
title="You're off this day"
description={wholeDayOff.reason ?? "Approved time off."}
/>
) : view === "day" ? (
<DayCalendar columns={columns} currentTimeMinutes={currentTimeMinutes} />
) : calendarAppointments.length === 0 ? (
<div className="px-6 lg:px-0">
<EmptyState
icon={CalendarX2}
title="No appointments today"
description="Your schedule is clear for this day."
/>
</div>
) : (
<div className="space-y-4 px-6 lg:px-0">
{calendarAppointments.map((a) => (
<AppointmentCard
key={a.id}
status={a.status}
serviceName={a.serviceNames}
person={{ id: a.id, name: a.customerName }}
personLabel="for"
formattedTime={formatRange(a.startMinutes, a.endMinutes)}
hasNotes={a.hasNotes}
hasAvailableReward={a.hasAvailableReward}
actions={
<Link
href={`/staff/appointments/${a.id}`}
className="border-line rounded-lg border px-4 py-2 text-xs font-bold tracking-widest uppercase"
>
View
</Link>
}
/>
))}
</div>
)}
</div>
{/* Desktop-only "Right Now" panel — a wide screen shows the current
appointment alongside the calendar instead of requiring a
separate tab, since there's room for both at once. */}
<aside className="hidden shrink-0 lg:block lg:w-80">
<div className="rounded-md border-line sticky top-6 border bg-white p-5">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Right Now
</span>
{rightNow ? (
<div className="mt-3 space-y-3">
<AppointmentCard
status={STATUS_MAP[rightNow.status] ?? "confirmed"}
serviceName={rightNow.services.map((s) => s.service.name).join(" + ")}
person={{ id: rightNow.customerId, name: `${rightNow.customer.firstName} ${rightNow.customer.lastName}` }}
personLabel="for"
formattedTime={formatRange(
minutesFromMidnight(rightNow.startAt, staff.business.timezone),
minutesFromMidnight(rightNow.endAt, staff.business.timezone),
)}
/>
<Link
href={`/staff/appointments/${rightNow.id}`}
className="border-line block rounded-lg border px-4 py-2 text-center text-xs font-bold tracking-widest uppercase"
>
Open Details
</Link>
</div>
) : (
<p className="text-muted mt-3 text-sm">Nothing in progress or up next right now.</p>
)}
<Link
href="/staff/current"
className="text-accent mt-4 block text-center text-xs font-bold tracking-widest uppercase underline"
>
Full Current Appointment View
</Link>
</div>
</aside>
</div>
</div>
);
}
function formatRange(startMinutes: number, endMinutes: number): string {
const fmt = (m: number) => {
const h = Math.floor(m / 60);
const min = m % 60;
const period = h >= 12 ? "PM" : "AM";
const displayHour = h % 12 === 0 ? 12 : h % 12;
return `${displayHour}:${String(min).padStart(2, "0")} ${period}`;
};
return `${fmt(startMinutes)} – ${fmt(endMinutes)}`;
}
@@ -0,0 +1,34 @@
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 { listBreaks } from "@/services/staff/staff.service";
import { BreaksList } from "@/features/staff/components/breaks-list";
export const metadata: Metadata = { title: "Breaks" };
export const dynamic = "force-dynamic";
export default async function StaffBreaksPage() {
const staff = await getActingStaff();
if (!staff) redirect("/home");
const breaks = await listBreaks(prisma, staff.id);
return (
<div className="pb-12">
<TopNav title="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),
}))}
/>
</main>
</div>
);
}
@@ -0,0 +1,49 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { getBranding } from "@/lib/business/branding";
import { CalendarConnectionPanel } from "@/features/calendar/components/calendar-connection-panel";
export const metadata: Metadata = { title: "Google Calendar" };
export const dynamic = "force-dynamic";
export default async function StaffCalendarSettingsPage() {
const session = await auth();
if (!session?.user?.id) redirect("/sign-in?callbackUrl=/staff/settings/calendar");
const staffProfile = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
if (!staffProfile) redirect("/home");
const [connection, branding] = await Promise.all([
prisma.staffCalendarConnection.findUnique({ where: { staffId: staffProfile.id } }),
getBranding(),
]);
const connected = !!connection && !connection.revokedAt;
const configured = !!process.env.GOOGLE_CALENDAR_CLIENT_ID && !!process.env.GOOGLE_CALENDAR_CLIENT_SECRET;
return (
<div className="space-y-6 px-6 pt-12 pb-12">
<TopNav title="Google Calendar" onBack sticky className="bg-stone" />
<p className="text-muted text-sm">
One-way sync: {branding.name} stays the source of truth, and your confirmed appointments are pushed to
Google Calendar. Nothing is ever imported back from Google.
</p>
<CalendarConnectionPanel
configured={configured}
initial={
connected
? {
connected: true,
accountEmail: connection.accountEmail,
selectedCalendarName: connection.selectedCalendarName,
lastSyncAt: connection.lastSyncAt?.toISOString(),
lastSyncError: connection.lastSyncError,
}
: { connected: false }
}
/>
</div>
);
}
@@ -0,0 +1,42 @@
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 { 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 session = await auth();
if (!session?.user?.id) redirect("/sign-in?callbackUrl=/staff/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.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 space-y-6 px-6 py-6">
<NotificationPreferencesForm initial={cells} action={saveNotificationPreferencesAction} />
<div className="rounded-md border-line border bg-white p-5">
<h2 className="mb-3 text-sm font-bold">Push Notifications</h2>
<PushSubscribeToggle />
</div>
</main>
</div>
);
}
+116
View File
@@ -0,0 +1,116 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Bell, CalendarClock, CalendarOff, CalendarSync, Clock, type LucideIcon } from "lucide-react";
import { auth } from "@/auth";
import { ProfileCard } from "@/components/ui/profile-card";
import { TopNav } from "@/components/navigation/top-nav";
import { SignOutButton } from "@/features/auth/components/sign-out-button";
import { getNavRole } from "@/lib/auth/permissions";
import { getBranding } from "@/lib/business/branding";
import { InstallAppButton } from "@/features/pwa/components/install-app-button";
import { InlineNotificationBell } from "@/features/notifications/components/notification-bell";
import { prisma } from "@/lib/db/prisma";
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
const ACCOUNT_LINKS = [{ href: "/staff/settings/notifications", label: "Notifications", icon: Bell }] as const;
const WORKING_PATTERN_LINKS = [
{ href: "/staff/settings/working-hours", label: "Working Hours", icon: Clock },
{ href: "/staff/settings/breaks", label: "Breaks", icon: CalendarClock },
{ href: "/staff/settings/time-off", label: "Time Off", icon: CalendarOff },
] as const;
const INTEGRATION_LINKS = [{ href: "/staff/settings/calendar", label: "Google Calendar", icon: CalendarSync }] as const;
function SettingsSection({
title,
links,
}: {
title: string;
links: ReadonlyArray<{ href: string; label: string; icon: LucideIcon }>;
}) {
return (
<div>
<p className="text-muted mb-2 px-1 text-[10px] font-bold tracking-widest uppercase">{title}</p>
<ul className="divide-y divide-line rounded-md border border-line bg-white">
{links.map(({ href, label, icon: Icon }) => (
<li key={href}>
<Link href={href} className="flex items-center gap-3 px-5 py-4 text-sm font-bold hover:bg-stone">
<Icon className="h-5 w-5 text-muted" aria-hidden="true" />
{label}
</Link>
</li>
))}
</ul>
</div>
);
}
export const metadata: Metadata = { title: "Settings" };
/**
* The one personal Settings hub shared by every signed-in staff member,
* owner and Super Administrator — there is no separate owner settings hub
* or "Switch to Owner Admin" link here anymore (business management now
* lives behind the "Manage" nav item, see (owner)/owner/page.tsx). Working
* Pattern and Integrations only apply to a real personal barber schedule,
* so a Super Administrator (who deliberately has no StaffProfile — see
* isSuperAdmin) sees only Notifications and Sign Out. Profile editing is
* the ProfileCard's own edit-pencil (matching the customer settings hub)
* rather than a menu link — withheld for a Super Administrator since the
* profile-edit page below is staff-bio-specific and they have no
* StaffProfile to edit.
*/
export default async function StaffSettingsPage() {
const session = await auth();
if (!session?.user) return null;
const [role, branding, unreadNotificationCount] = await Promise.all([
getNavRole(session.user.id),
getBranding(),
getUnreadNotificationCount(prisma, session.user.id),
]);
const isSuperAdmin = role === "superadmin";
return (
<div className="pb-12">
<TopNav
title="Settings"
sticky
className="bg-stone"
leftAction={<InlineNotificationBell href="/staff/notifications" unreadCount={unreadNotificationCount} />}
/>
<div className="space-y-6 px-6 pt-6">
<ProfileCard
name={session.user.name ?? "Account"}
meta={session.user.email}
avatarUrl={session.user.image ?? undefined}
editHref={isSuperAdmin ? undefined : "/staff/settings/profile"}
/>
<div className="space-y-5">
<SettingsSection title="Account" links={ACCOUNT_LINKS} />
{!isSuperAdmin && (
<>
<SettingsSection title="Working Pattern" links={WORKING_PATTERN_LINKS} />
<SettingsSection title="Integrations" links={INTEGRATION_LINKS} />
</>
)}
</div>
<div>
<p className="text-muted mb-2 px-1 text-[10px] font-bold tracking-widest uppercase">App</p>
<div className="space-y-3">
<div className="rounded-md border border-line bg-white p-5">
<p className="mb-3 text-sm font-bold">Get the app</p>
<InstallAppButton businessName={branding.name} />
</div>
<SignOutButton />
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,57 @@
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 { getStaffProfileByUserId } from "@/services/staff/staff.service";
import { ProfileForm } from "@/features/staff/components/profile-form";
export const metadata: Metadata = { title: "Profile" };
export const dynamic = "force-dynamic";
export default async function StaffProfilePage() {
const staff = await getActingStaff();
if (!staff) redirect("/home");
const profile = await getStaffProfileByUserId(prisma, staff.userId);
if (!profile) redirect("/home");
return (
<div className="pb-12">
<TopNav title="Profile" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl space-y-6 px-6 py-6">
<ProfileForm
initial={{
name: profile.user.name ?? "",
phone: profile.user.phone ?? "",
image: profile.user.image ?? "",
bio: profile.bio ?? "",
isBookableOnline: profile.isBookableOnline,
}}
/>
<div>
<p className="mb-2 text-xs font-bold uppercase tracking-widest text-muted">
Services You Offer
</p>
{profile.services.length === 0 ? (
<p className="text-muted rounded-md border border-line bg-white p-5 text-center text-sm">
No services assigned yet. Ask the owner to add you to a service.
</p>
) : (
<ul className="divide-y divide-line rounded-md border border-line bg-white">
{profile.services.map((s) => (
<li key={s.service.id} className="px-5 py-3 text-sm font-bold">
{s.service.name}
</li>
))}
</ul>
)}
<p className="text-muted mt-2 text-xs">
Only the business owner can change which services you offer.
</p>
</div>
</main>
</div>
);
}
@@ -0,0 +1,40 @@
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 { listTimeOff } from "@/services/staff/staff.service";
import { TimeOffList } from "@/features/staff/components/time-off-list";
export const metadata: Metadata = { title: "Time Off" };
export const dynamic = "force-dynamic";
function toCalendarDate(date: Date): string {
return date.toISOString().slice(0, 10);
}
export default async function StaffTimeOffPage() {
const staff = await getActingStaff();
if (!staff) redirect("/home");
const { upcoming, past } = await listTimeOff(prisma, staff.id);
// Whole-day rows only — partial-day "Block Time" entries are managed from the calendar (see 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="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)} />
</main>
</div>
);
}
@@ -0,0 +1,54 @@
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 { getWorkingHours } from "@/services/staff/staff.service";
import { Weekday } from "@/generated/prisma/enums";
import { WorkingHoursForm } from "@/features/staff/components/working-hours-form";
export const metadata: Metadata = { title: "Working Hours" };
export const dynamic = "force-dynamic";
const DAY_ORDER: Weekday[] = [
Weekday.MONDAY,
Weekday.TUESDAY,
Weekday.WEDNESDAY,
Weekday.THURSDAY,
Weekday.FRIDAY,
Weekday.SATURDAY,
Weekday.SUNDAY,
];
function formatMinutes(m: number): string {
const h = Math.floor(m / 60);
const min = m % 60;
return `${String(h).padStart(2, "0")}:${String(min).padStart(2, "0")}`;
}
export default async function StaffWorkingHoursPage() {
const staff = await getActingStaff();
if (!staff) redirect("/home");
const [workingHours, businessHours] = await Promise.all([
getWorkingHours(prisma, staff.id),
prisma.businessHours.findMany({ where: { businessId: staff.businessId } }),
]);
const businessHoursLabel = Object.fromEntries(
DAY_ORDER.map((day) => {
const hours = businessHours.find((h) => h.dayOfWeek === day);
const label = !hours || hours.isClosed || hours.opensAtMinutes === null || hours.closesAtMinutes === null ? "Closed" : `${formatMinutes(hours.opensAtMinutes)}–${formatMinutes(hours.closesAtMinutes)}`;
return [day, label];
}),
) as Record<Weekday, string>;
return (
<div className="pb-12">
<TopNav title="Working Hours" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl px-6 py-6">
<WorkingHoursForm initialRows={workingHours} businessHoursLabel={businessHoursLabel} />
</main>
</div>
);
}
@@ -0,0 +1,60 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { generateAppointmentIcs } from "@/lib/calendar/ics";
import { prisma } from "@/lib/db/prisma";
import { assertCanAccessAppointment, PermissionDeniedError } from "@/features/booking/authorization";
import { getAppointmentById } from "@/services/scheduling/appointment.service";
/**
* A file download needs real HTTP response headers (Content-Type,
* Content-Disposition) that a Server Action can't produce — the one
* legitimate use of a Route Handler in this phase, per Phase 2's
* architecture ("reserve app/api for... anything that needs a typed HTTP
* response"). Still calls straight into AppointmentService/ics.ts —
* no booking logic lives here.
*/
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) {
return NextResponse.json({ error: "Appointment not found." }, { status: 404 });
}
const session = await auth();
try {
await assertCanAccessAppointment(prisma, appointment, { sessionUserId: session?.user?.id, guestToken: token });
} catch (error) {
if (error instanceof PermissionDeniedError) {
return NextResponse.json({ error: error.message }, { status: 403 });
}
throw error;
}
const business = await prisma.business.findUnique({
where: { id: appointment.businessId },
include: { locations: true },
});
const ics = generateAppointmentIcs({
appointmentId: appointment.id,
businessName: business?.name ?? "the business",
businessAddress: business?.locations[0]
? `${business.locations[0].addressLine1}, ${business.locations[0].city} ${business.locations[0].postalCode}`
: undefined,
staffName: appointment.staff.user.name ?? "Your barber",
serviceNames: appointment.services.map((s) => s.service.name),
startAt: appointment.startAt,
endAt: appointment.endAt,
notes: appointment.notes ?? undefined,
});
return new NextResponse(ics, {
status: 200,
headers: {
"Content-Type": "text/calendar; charset=utf-8",
"Content-Disposition": `attachment; filename="appointment-${appointment.id}.ics"`,
},
});
}
+22
View File
@@ -0,0 +1,22 @@
import { NextRequest } from "next/server";
/**
* Business Auth.js is intentionally unavailable in the standalone Manager
* deployment. Keeping this check before the dynamic import prevents a stale
* SessionProvider or direct request from initialising Prisma there.
*/
function managerAuthDisabled() {
return process.env.MANAGER_MODE === "true";
}
export async function GET(request: NextRequest) {
if (managerAuthDisabled()) return new Response(null, { status: 404 });
const { handlers } = await import("@/auth");
return handlers.GET(request);
}
export async function POST(request: NextRequest) {
if (managerAuthDisabled()) return new Response(null, { status: 404 });
const { handlers } = await import("@/auth");
return handlers.POST(request);
}
@@ -0,0 +1,64 @@
import { NextResponse } from "next/server";
import { secretsMatch } from "@/lib/security/secrets-match";
/**
* 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) {
if (process.env.MANAGER_MODE === "true") return NextResponse.json({ error: "Not available on Manager." }, { status: 404 });
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 });
}
// No business data exists to process before the installer has run — see
// src/lib/installation/installation-state.ts. A 200 with a no-op result
// (not an error) so an external scheduler hitting this on a fixed
// interval before day-one setup doesn't page anyone.
const { isInstalledForRequest } = await import("@/lib/installation/installation-state");
if (!(await isInstalledForRequest())) {
return NextResponse.json({ skipped: "not_installed" });
}
const [{ prisma }, { expireOverdueRewards }, { getEmailProvider, getPushProvider }, { processDueNotificationDeliveries }] = await Promise.all([
import("@/lib/db/prisma"),
import("@/services/loyalty/loyalty.service"),
import("@/services/notifications/providers"),
import("@/services/notifications/reminder-worker"),
]);
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);
}
@@ -0,0 +1,42 @@
import { NextResponse } from "next/server";
import { secretsMatch } from "@/lib/security/secrets-match";
/**
* External trigger for the Google Reviews sync — mirrors
* /api/cron/process-notifications exactly (same CRON_SECRET bearer-token
* auth, same 503-if-unconfigured / 401-if-wrong / skip-if-not-installed
* shape). Recommended frequency: every 24 hours — reviews don't change
* fast enough to justify Google Places API calls any more often than
* that, and the customer app never calls Google directly regardless (see
* src/services/reviews/reviews.service.ts).
*
* Safe to run concurrently with itself: syncGoogleReviewsForBusiness only
* overwrites Business.googleRating/googleRatingCount with whatever Google
* returns, so an overlapping call just re-writes the same two columns,
* never duplicates anything.
*/
export async function POST(request: Request) {
if (process.env.MANAGER_MODE === "true") return NextResponse.json({ error: "Not available on Manager." }, { status: 404 });
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 { isInstalledForRequest } = await import("@/lib/installation/installation-state");
if (!(await isInstalledForRequest())) {
return NextResponse.json({ skipped: "not_installed" });
}
const [{ prisma }, { syncGoogleReviewsForAllBusinesses }] = await Promise.all([
import("@/lib/db/prisma"),
import("@/services/reviews/review-sync.service"),
]);
const results = await syncGoogleReviewsForAllBusinesses(prisma);
return NextResponse.json({ results });
}
@@ -0,0 +1,79 @@
import mariadb from "mariadb";
import { NextResponse } from "next/server";
import { secretsMatch } from "@/lib/security/secrets-match";
/**
* TEMPORARY — diagnose the "pool timeout" seen on the Vercel+Hostinger
* deployment. Prisma's connection pool wraps every failure as the same
* generic "pool timeout" message, hiding the actual socket-level error
* (ETIMEDOUT = firewall silently dropping packets, ECONNREFUSED = port
* closed, ER_ACCESS_DENIED_ERROR = wrong credentials, etc.) that would
* otherwise tell us exactly what's blocking the connection. This makes
* one raw `mariadb.createConnection()` attempt, bypassing the pool
* entirely, and reports that real error back. Delete this route once the
* connectivity issue is resolved — an authenticated diagnostic endpoint
* has no reason to stay in a shipped app.
*
* Auth: reuses CRON_SECRET (already configured for
* /api/cron/process-notifications) rather than adding a new secret only
* this throwaway route would ever use.
*/
export async function GET(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 host = process.env.DATABASE_HOST;
const port = process.env.DATABASE_PORT ? Number(process.env.DATABASE_PORT) : 3306;
const database = process.env.DATABASE_NAME;
const user = process.env.DATABASE_USER;
const password = process.env.DATABASE_PASSWORD;
const ssl = process.env.DATABASE_SSL === "true";
if (!host || !database || !user || password === undefined) {
return NextResponse.json({ error: "DATABASE_* env vars are not fully configured." }, { status: 503 });
}
const startedAt = Date.now();
try {
const conn = await mariadb.createConnection({
host,
port,
user,
password,
database,
connectTimeout: 8000,
ssl: ssl ? { rejectUnauthorized: true } : undefined,
});
await conn.query("SELECT 1");
await conn.end();
return NextResponse.json({
success: true,
durationMs: Date.now() - startedAt,
host,
port,
ssl,
});
} catch (error) {
const err = error as { code?: string; errno?: number; message?: string };
return NextResponse.json(
{
success: false,
durationMs: Date.now() - startedAt,
host,
port,
ssl,
errorCode: err.code ?? null,
errorNo: err.errno ?? null,
errorMessage: err.message ?? String(error),
},
{ status: 502 },
);
}
}
+29
View File
@@ -0,0 +1,29 @@
import { NextResponse } from "next/server";
/**
* Docker/orchestrator health check — deliberately never requires
* installation to have completed: a fresh deployment with a reachable,
* migrated database but no business yet is healthy (it should correctly
* show the installer), not unhealthy. Only a database connectivity
* failure returns a non-200 status. Never returns secrets or a stack
* trace — just enough for a health probe to act on.
*/
export async function GET() {
if (process.env.MANAGER_MODE === "true") {
return NextResponse.json({ status: "healthy", deployment: "manager" });
}
const [{ prisma }, { getInstallationState }] = await Promise.all([
import("@/lib/db/prisma"),
import("@/lib/installation/installation-state"),
]);
try {
await prisma.$queryRaw`SELECT 1`;
} catch {
return NextResponse.json({ status: "unhealthy", database: "unreachable" }, { status: 503 });
}
const { installed } = await getInstallationState().catch(() => ({ installed: false }));
return NextResponse.json({ status: "healthy", database: "reachable", installed });
}
+16
View File
@@ -0,0 +1,16 @@
import { NextResponse } from "next/server";
import { authenticateManager, clearManagerSession, createManagerSession } from "@/services/management/management-auth";
export async function POST(request: Request) {
const body = (await request.json().catch(() => null)) as { email?: string; password?: string } | null;
if (!body?.email || !body.password || !authenticateManager(body.email, body.password)) {
return NextResponse.json({ error: "Incorrect manager credentials." }, { status: 401 });
}
await createManagerSession();
return NextResponse.json({ ok: true });
}
export async function DELETE() {
await clearManagerSession();
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
import { isManagerAuthenticated } from "@/services/management/management-auth";
import { removeBusiness, restartBusiness, startBusiness, stopBusiness, updateBusiness } from "@/services/management/provisioning.service";
const operations = { start: startBusiness, stop: stopBusiness, restart: restartBusiness, update: updateBusiness, remove: removeBusiness } as const;
export async function POST(request: Request, context: { params: Promise<{ slug: string }> }) {
if (!(await isManagerAuthenticated())) return NextResponse.json({ error: "Unauthorized." }, { status: 401 });
const { slug } = await context.params;
const body = (await request.json().catch(() => null)) as { action?: string } | null;
const operation = body?.action && body.action in operations ? operations[body.action as keyof typeof operations] : undefined;
if (!operation) return NextResponse.json({ error: "Unsupported management action." }, { status: 400 });
try {
const result = await operation(slug);
return NextResponse.json(result ?? { ok: true });
} catch (error) {
return NextResponse.json({ error: error instanceof Error ? error.message : "Management action failed." }, { status: 500 });
}
}
@@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import { isManagerAuthenticated } from "@/services/management/management-auth";
import { getBusinessLogs } from "@/services/management/provisioning.service";
export async function GET(_: Request, context: { params: Promise<{ slug: string }> }) {
if (!(await isManagerAuthenticated())) return NextResponse.json({ error: "Unauthorized." }, { status: 401 });
try {
return new NextResponse(await getBusinessLogs((await context.params).slug), { headers: { "Content-Type": "text/plain; charset=utf-8" } });
} catch {
return NextResponse.json({ error: "Unable to read business logs." }, { status: 500 });
}
}
@@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import { isManagerAuthenticated } from "@/services/management/management-auth";
import { getBusiness } from "@/services/management/provisioning.service";
export async function GET(_: Request, context: { params: Promise<{ slug: string }> }) {
if (!(await isManagerAuthenticated())) return NextResponse.json({ error: "Unauthorized." }, { status: 401 });
try {
return NextResponse.json(await getBusiness((await context.params).slug));
} catch {
return NextResponse.json({ error: "Business deployment not found." }, { status: 404 });
}
}
+25
View File
@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { isManagerAuthenticated } from "@/services/management/management-auth";
import { createBusiness, listBusinesses } from "@/services/management/provisioning.service";
export async function GET() {
if (!(await isManagerAuthenticated())) return NextResponse.json({ error: "Unauthorized." }, { status: 401 });
return NextResponse.json(await listBusinesses());
}
export async function POST(request: Request) {
if (!(await isManagerAuthenticated())) return NextResponse.json({ error: "Unauthorized." }, { status: 401 });
const body = (await request.json().catch(() => null)) as Record<string, unknown> | null;
const name = typeof body?.name === "string" ? body.name.trim() : "";
const slug = typeof body?.slug === "string" ? body.slug.trim().toLowerCase() : "";
const domain = typeof body?.domain === "string" ? body.domain.trim().toLowerCase() : "";
const image = typeof body?.image === "string" ? body.image.trim() : "";
if (!name || name.length > 120 || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug) || slug.length > 22 || !/^[a-z0-9.-]+$/.test(domain) || !/^[A-Za-z0-9._/@:-]+$/.test(image)) {
return NextResponse.json({ error: "Enter a valid business name, slug, domain, and image version." }, { status: 400 });
}
try {
return NextResponse.json(await createBusiness({ name, slug, domain, image }), { status: 201 });
} catch (error) {
return NextResponse.json({ error: error instanceof Error ? error.message : "Business deployment failed." }, { status: 500 });
}
}
@@ -0,0 +1,65 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
/** Exercises the actual Route Handler function, not just the storage helper it wraps — real temp-dir I/O, same MEDIA_STORAGE_ROOT override as media-storage.test.ts. */
describe("GET /api/media/[...path]", () => {
let tempRoot: string;
let storage: typeof import("@/lib/media/media-storage");
let route: typeof import("../route");
beforeAll(async () => {
tempRoot = await mkdtemp(path.join(tmpdir(), "nextchair-media-route-test-"));
process.env.MEDIA_STORAGE_ROOT = tempRoot;
storage = await import("@/lib/media/media-storage");
route = await import("../route");
});
afterAll(async () => {
delete process.env.MEDIA_STORAGE_ROOT;
await rm(tempRoot, { recursive: true, force: true });
});
const PNG_BYTES = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"base64",
);
it("returns 200 with the correct content type and bytes for a stored file", async () => {
const stored = await storage.storeMediaFile({ buffer: PNG_BYTES, mimeType: "image/png", category: "branding" });
const segments = stored.url.replace(storage.MEDIA_URL_PREFIX, "").split("/");
const response = await route.GET(new Request(`http://localhost${stored.url}`), { params: Promise.resolve({ path: segments }) });
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe("image/png");
expect(response.headers.get("cache-control")).toContain("immutable");
const body = Buffer.from(await response.arrayBuffer());
expect(body.equals(PNG_BYTES)).toBe(true);
});
it("returns 404 for a file that doesn't exist", async () => {
const response = await route.GET(new Request("http://localhost/api/media/branding/missing.png"), {
params: Promise.resolve({ path: ["branding", "missing.png"] }),
});
expect(response.status).toBe(404);
});
it("returns 404 (not the file) for a path-traversal attempt", async () => {
const response = await route.GET(new Request("http://localhost/api/media/..%2F..%2Fpackage.json"), {
params: Promise.resolve({ path: ["..", "..", "package.json"] }),
});
expect(response.status).toBe(404);
});
it("returns 404 for a deleted file — it no longer resolves", async () => {
const stored = await storage.storeMediaFile({ buffer: PNG_BYTES, mimeType: "image/png", category: "gallery" });
const segments = stored.url.replace(storage.MEDIA_URL_PREFIX, "").split("/");
await storage.deleteMediaFile(stored.url);
const response = await route.GET(new Request(`http://localhost${stored.url}`), { params: Promise.resolve({ path: segments }) });
expect(response.status).toBe(404);
});
});
+41
View File
@@ -0,0 +1,41 @@
import { NextResponse } from "next/server";
import { readStoredMediaFile } from "@/lib/media/media-storage";
/**
* Serves uploaded media files directly — see media-storage.ts's header
* for why this route exists at all (Next's own `public/` static handling
* does not serve files added after the build, verified against a real
* production container). This is the only public entry point for
* uploaded media; nothing else touches the filesystem to serve one.
*
* Deliberately unauthenticated — every image this ever serves (business
* logo, gallery/portfolio, staff/service photos, loyalty card
* background) is already customer-facing public data, same as the
* `public/` assets it replaces. Path safety (not access control) is the
* only thing this route needs to get right — see
* resolveMediaFilePath/readStoredMediaFile for the traversal guard.
*
* `/api/*` is already excluded from src/proxy.ts's matcher, so this
* route is reachable before installation completes and without a
* session — necessary, since branding previews and the installer itself
* may reference these URLs.
*/
export async function GET(_request: Request, context: { params: Promise<{ path: string[] }> }) {
const { path: segments } = await context.params;
const file = await readStoredMediaFile(segments);
if (!file) {
return NextResponse.json({ error: "Not found." }, { status: 404 });
}
return new NextResponse(new Uint8Array(file.buffer), {
status: 200,
headers: {
"Content-Type": file.mimeType,
// Safe to cache indefinitely — each URL's filename is a random
// id assigned once at upload time; a given URL's content never
// changes in place (delete + re-upload always gets a new URL).
"Cache-Control": "public, max-age=31536000, immutable",
"Content-Length": String(file.buffer.byteLength),
},
});
}
@@ -0,0 +1,59 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/db/prisma";
import { isSecureDeployment } from "@/lib/security/cookie-security";
import { createOAuthState } from "@/lib/security/oauth-state";
import { GoogleCalendarProvider } from "@/services/calendar/google-calendar.provider";
function getRedirectUri(): string {
const explicit = process.env.GOOGLE_CALENDAR_REDIRECT_URI;
if (explicit) return explicit;
const appUrl = process.env.NEXT_PUBLIC_APP_URL;
return `${appUrl}/api/staff/calendar/google/callback`;
}
/**
* Starts the Google Calendar connect flow for the signed-in staff
* member's own account — deliberately separate from Google sign-in (see
* docs/phase-8-summary.md): a barber may connect a different Google
* account here than the one they authenticate with. `state` is a signed,
* short-lived, staff-scoped token (see oauth-state.ts) set as an httpOnly
* cookie and re-verified in the callback — CSRF/replay protection without
* server-side session storage.
*/
export async function GET() {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.redirect(new URL("/sign-in", process.env.NEXT_PUBLIC_APP_URL));
}
const staffProfile = await prisma.staffProfile.findUnique({ where: { userId: session.user.id }, select: { id: true } });
if (!staffProfile) {
return NextResponse.json({ error: "Only staff members can connect a calendar." }, { status: 403 });
}
// Fails closed with a friendly redirect, not a 500 — see the brief's
// "show disabled states rather than crashing" for every optional
// integration. The Settings page itself also hides the Connect button
// when unconfigured (see calendar-connection-panel.tsx), so reaching
// this route unconfigured means a stale link or a direct hit.
if (!process.env.GOOGLE_CALENDAR_CLIENT_ID || !process.env.GOOGLE_CALENDAR_CLIENT_SECRET) {
const url = new URL("/staff/settings/calendar", process.env.NEXT_PUBLIC_APP_URL);
url.searchParams.set("error", "1");
url.searchParams.set("detail", "not_configured");
return NextResponse.redirect(url);
}
const state = createOAuthState(staffProfile.id);
const provider = new GoogleCalendarProvider();
const authorizationUrl = provider.buildAuthorizationUrl({ redirectUri: getRedirectUri(), state });
const response = NextResponse.redirect(authorizationUrl);
response.cookies.set("gcal_oauth_state", state, {
httpOnly: true,
secure: isSecureDeployment(),
sameSite: "lax",
path: "/",
maxAge: 600,
});
return response;
}
@@ -0,0 +1,98 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/db/prisma";
import { encryptToken } from "@/lib/security/token-encryption";
import { verifyOAuthState } from "@/lib/security/oauth-state";
import { GoogleCalendarProvider } from "@/services/calendar/google-calendar.provider";
import { CalendarProviderError } from "@/services/calendar/types";
import { runInitialSync } from "@/services/calendar/calendar-sync.service";
function getRedirectUri(): string {
const explicit = process.env.GOOGLE_CALENDAR_REDIRECT_URI;
if (explicit) return explicit;
const appUrl = process.env.NEXT_PUBLIC_APP_URL;
return `${appUrl}/api/staff/calendar/google/callback`;
}
function settingsRedirect(status: "connected" | "error", detail?: string): NextResponse {
const url = new URL("/staff/settings/calendar", process.env.NEXT_PUBLIC_APP_URL);
url.searchParams.set(status, "1");
if (detail) url.searchParams.set("detail", detail);
const response = NextResponse.redirect(url);
response.cookies.delete("gcal_oauth_state");
return response;
}
/**
* Consent denial, an expired/tampered state, or a state issued for a
* different staff member than the one currently signed in are all
* treated the same way: bounce back to Settings with a generic error —
* never revealing which check failed (same principle as the installation
* token check, see installer.service.ts).
*/
export async function GET(request: Request) {
const url = new URL(request.url);
const code = url.searchParams.get("code");
const returnedState = url.searchParams.get("state");
const consentDenied = url.searchParams.get("error");
const session = await auth();
if (!session?.user?.id) {
return NextResponse.redirect(new URL("/sign-in", process.env.NEXT_PUBLIC_APP_URL));
}
const staffProfile = await prisma.staffProfile.findUnique({ where: { userId: session.user.id }, select: { id: true } });
if (!staffProfile) {
return settingsRedirect("error", "not_staff");
}
if (consentDenied) {
return settingsRedirect("error", "consent_denied");
}
const cookieState = request.headers.get("cookie")?.match(/gcal_oauth_state=([^;]+)/)?.[1];
if (!code || !returnedState || !cookieState || returnedState !== cookieState || !verifyOAuthState(cookieState, staffProfile.id)) {
return settingsRedirect("error", "invalid_state");
}
try {
const provider = new GoogleCalendarProvider();
const tokens = await provider.exchangeCodeForTokens({ code, redirectUri: getRedirectUri() });
const calendars = await provider.listWritableCalendars(tokens.accessToken);
const primaryCalendar = calendars.find((c) => c.primary) ?? calendars[0];
if (!primaryCalendar) {
return settingsRedirect("error", "no_writable_calendar");
}
const connection = await prisma.staffCalendarConnection.upsert({
where: { staffId: staffProfile.id },
create: {
staffId: staffProfile.id,
externalAccountId: tokens.externalAccountId,
accountEmail: tokens.accountEmail,
encryptedAccessToken: encryptToken(tokens.accessToken),
encryptedRefreshToken: encryptToken(tokens.refreshToken),
tokenExpiresAt: tokens.expiresAt,
selectedCalendarId: primaryCalendar.id,
selectedCalendarName: primaryCalendar.name,
},
update: {
externalAccountId: tokens.externalAccountId,
accountEmail: tokens.accountEmail,
encryptedAccessToken: encryptToken(tokens.accessToken),
encryptedRefreshToken: encryptToken(tokens.refreshToken),
tokenExpiresAt: tokens.expiresAt,
selectedCalendarId: primaryCalendar.id,
selectedCalendarName: primaryCalendar.name,
revokedAt: null,
lastSyncError: null,
},
});
await runInitialSync(prisma, connection.staffId);
return settingsRedirect("connected");
} catch (error) {
const detail = error instanceof CalendarProviderError ? error.code.toLowerCase() : "unknown";
return settingsRedirect("error", detail);
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

@@ -0,0 +1,17 @@
/**
* Deliberately OUTSIDE the (customer) route group, same reasoning as
* src/app/book/layout.tsx: this page reuses BarberDateTimePicker, which
* renders its own fixed bottom action bar. Nested under (customer)'s
* persistent BottomNav, the two fixed-bottom elements would overlap —
* the nav (z-50) sits above the picker's "Confirm Reschedule" bar (z-40)
* and swallows clicks meant for it. Hiding the bottom nav here avoids
* that collision, matching the booking flow's own "may temporarily hide
* the bottom navigation" allowance.
*/
export default function RescheduleLayout({ children }: { children: React.ReactNode }) {
return (
<div id="main-content" className="min-h-screen bg-stone">
{children}
</div>
);
}
@@ -0,0 +1,84 @@
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { auth } from "@/auth";
import { Card } from "@/components/ui/card";
import { TopNav } from "@/components/navigation/top-nav";
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
import { prisma } from "@/lib/db/prisma";
import { getAppointmentById } from "@/services/scheduling/appointment.service";
import { assertCanAccessAppointment, PermissionDeniedError } from "@/features/booking/authorization";
import { BarberDateTimePicker } from "@/features/booking/components/barber-date-time-picker";
import { rescheduleCustomerAppointmentAction } from "@/features/booking/actions/customer-appointments";
export const metadata: Metadata = { title: "Reschedule Appointment" };
export const dynamic = "force-dynamic";
const NON_RESCHEDULABLE = new Set(["COMPLETED", "CANCELLED", "NO_SHOW", "IN_PROGRESS"]);
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 {
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}${token ? `?token=${token}` : ""}`);
}
const business = await getPrimaryBusiness();
async function handleContinue(selection: { staffId?: string; date: string; startAt: string }) {
"use server";
const result = await rescheduleCustomerAppointmentAction({
appointmentId: id,
staffId: selection.staffId,
startAt: selection.startAt,
guestToken: token,
});
if (result.success) {
redirect(`/appointments/${id}${token ? `?token=${token}` : ""}`);
}
return result;
}
return (
<div className="pb-6">
<TopNav title="Reschedule" onBack={true} sticky className="bg-stone" />
<Card className="mx-6 mt-4 flex items-center gap-3 p-4">
<div className="flex-1">
<p className="text-sm font-bold">{appointment.services.map((s) => s.service.name).join(" + ")} with {appointment.staff.user.name}</p>
<p className="text-xs text-muted">
Currently: {appointment.startAt.toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short", timeZone: business.timezone })} ·{" "}
{appointment.startAt.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", timeZone: business.timezone })}
</p>
</div>
</Card>
<div className="mt-4">
<BarberDateTimePicker
businessId={business.id}
businessTimezone={business.timezone}
serviceIds={appointment.services.map((s) => s.serviceId)}
initialStaffId={appointment.staffId}
onContinue={handleContinue}
continueLabel="Confirm Reschedule"
/>
</div>
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { TopNav } from "@/components/navigation/top-nav";
import { readDraft } from "@/lib/booking-draft/cookie";
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
import { BarberDateTimePicker } from "@/features/booking/components/barber-date-time-picker";
import { saveBarberTimeStepAction } from "@/features/booking/actions/draft";
export const metadata: Metadata = { title: "Barber & Time" };
export const dynamic = "force-dynamic";
export default async function BookBarberTimePage() {
const business = await getPrimaryBusiness();
const draft = await readDraft(business.id);
if (draft.serviceIds.length === 0) {
redirect("/book");
}
async function handleContinue(selection: { staffId?: string; date: string; startAt: string }) {
"use server";
return saveBarberTimeStepAction({ businessId: business.id, ...selection });
}
return (
<div className="pb-6">
<TopNav title="Barber & Time" onBack={true} sticky className="bg-stone" />
<BarberDateTimePicker
businessId={business.id}
businessTimezone={business.timezone}
serviceIds={draft.serviceIds}
initialStaffId={draft.staffId}
initialDate={draft.date}
onContinue={handleContinue}
/>
</div>
);
}
+123
View File
@@ -0,0 +1,123 @@
import type { Metadata } from "next";
import { CalendarPlus, Check } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { StatusPill } from "@/components/ui/status-pill";
import { readLastBookingReference } from "@/lib/booking-draft/cookie";
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
import { getAppointmentById } from "@/services/scheduling/appointment.service";
import { prisma } from "@/lib/db/prisma";
import type { AppointmentStatus } from "@/types/domain";
export const metadata: Metadata = { title: "Booking Confirmed" };
export const dynamic = "force-dynamic";
const STATUS_MAP: Record<string, AppointmentStatus> = {
PENDING: "pending_approval",
CONFIRMED: "confirmed",
CHECKED_IN: "arrived",
IN_PROGRESS: "in_progress",
COMPLETED: "completed",
CANCELLED: "cancelled",
NO_SHOW: "no_show",
};
export default async function BookConfirmationPage() {
const reference = await readLastBookingReference();
if (!reference) redirect("/home");
const appointment = await getAppointmentById(prisma, reference.appointmentId);
if (!appointment) redirect("/home");
const business = await getPrimaryBusiness();
const session = await auth();
const totalPrice = appointment.services.reduce((sum, s) => sum + Number(s.priceAtBooking), 0);
return (
<div className="flex min-h-screen flex-col items-center px-6 pb-16 pt-12">
<div className="mb-8 flex h-20 w-20 items-center justify-center rounded-full bg-accent text-white shadow-lg shadow-accent/20">
<Check className="h-9 w-9" aria-hidden="true" />
</div>
<h1 className="mb-2 text-center text-2xl font-bold">You&apos;re all set!</h1>
<p role="status" className="mb-8 text-center text-sm text-muted">
We&apos;ve confirmed your booking with {business.name}.
</p>
<Card className="w-full max-w-md space-y-6 p-6">
<div className="flex items-center justify-between">
<StatusPill status={STATUS_MAP[appointment.status] ?? "confirmed"} />
<span className="text-[10px] font-bold uppercase tracking-widest text-muted">Ref: {appointment.id.slice(-8)}</span>
</div>
<div className="space-y-1 border-t border-line pt-4">
<p className="text-sm font-bold">{appointment.services.map((s) => s.service.name).join(" + ")}</p>
<p className="text-xs text-muted">with {appointment.staff.user.name}</p>
</div>
<div className="grid grid-cols-2 gap-4 border-t border-line pt-4 text-sm">
<div>
<span className="block text-[10px] font-bold uppercase tracking-widest text-muted">Date</span>
{appointment.startAt.toLocaleDateString("en-GB", {
weekday: "short",
day: "numeric",
month: "short",
timeZone: business.timezone,
})}
</div>
<div>
<span className="block text-[10px] font-bold uppercase tracking-widest text-muted">Time</span>
{appointment.startAt.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", timeZone: business.timezone })}
</div>
<div>
<span className="block text-[10px] font-bold uppercase tracking-widest text-muted">Duration</span>
{appointment.services.reduce((sum, s) => sum + s.durationAtBookingMinutes, 0)} min
</div>
<div>
<span className="block text-[10px] font-bold uppercase tracking-widest text-muted">Total</span>£{totalPrice.toFixed(2)}
</div>
</div>
<a
href={`/api/appointments/${appointment.id}/calendar.ics${reference.guestAccessToken ? `?token=${reference.guestAccessToken}` : ""}`}
className="flex w-full items-center justify-center gap-2 rounded-md border border-line bg-white py-4 text-sm font-bold"
>
<CalendarPlus className="h-4 w-4 text-accent" aria-hidden="true" /> Add to Calendar
</a>
</Card>
<div className="mt-8 w-full max-w-md space-y-3">
<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">
Create an account to view your appointment history, save your details, and track loyalty rewards.
</p>
<Button asChild variant="secondary" className="w-full">
<Link href="/register">Create an Account</Link>
</Button>
</Card>
)}
<Button asChild variant="secondary" className="w-full">
<Link href="/book">Book Another Appointment</Link>
</Button>
<Button asChild className="w-full">
<Link href="/home">Return Home</Link>
</Button>
</div>
</div>
);
}
+81
View File
@@ -0,0 +1,81 @@
import type { Metadata } from "next";
import { LogIn } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/feedback/empty-state";
import { TopNav } from "@/components/navigation/top-nav";
import { readDraft } from "@/lib/booking-draft/cookie";
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
import { prisma } from "@/lib/db/prisma";
import { DetailsForm } from "@/features/booking/components/details-form";
export const metadata: Metadata = { title: "Your Details" };
export const dynamic = "force-dynamic";
const CALLBACK_URL = "/book/details";
export default async function BookDetailsPage() {
const business = await getPrimaryBusiness();
const draft = await readDraft(business.id);
if (draft.serviceIds.length === 0) redirect("/book");
if (!draft.startAt) redirect("/book/barber-time");
const session = await auth();
// Booking progress up to this point (services, barber, time) is stored
// in the signed draft cookie, which survives navigation to /sign-in or
// /register and back — see src/lib/booking-draft/cookie.ts. Only the
// *details* step itself (and everything after it) requires an account
// when guest booking is disabled; browsing and slot selection never do.
const guestBookingEnabled = business.bookingRule?.allowGuestBooking ?? false;
if (!session?.user && !guestBookingEnabled) {
return (
<div className="pb-6">
<TopNav title="Your Details" onBack={true} sticky className="bg-stone" />
<EmptyState
icon={LogIn}
title="Sign in to complete your booking"
description="Create an account or sign in to confirm your appointment, manage your bookings and keep track of your loyalty rewards."
action={
<div className="flex w-full gap-3">
<Button asChild variant="secondary" className="flex-1">
<Link href={`/sign-in?callbackUrl=${encodeURIComponent(CALLBACK_URL)}`}>Sign In</Link>
</Button>
<Button asChild className="flex-1">
<Link href={`/register?callbackUrl=${encodeURIComponent(CALLBACK_URL)}`}>Create Account</Link>
</Button>
</div>
}
/>
</div>
);
}
let prefill: { firstName: string; lastName: string; email: string; phone: string } | undefined;
if (session?.user?.id) {
const profile = await prisma.customerProfile.findUnique({ where: { userId: session.user.id } });
prefill = {
firstName: profile?.firstName ?? session.user.name?.split(" ")[0] ?? "",
lastName: profile?.lastName ?? session.user.name?.split(" ").slice(1).join(" ") ?? "",
email: profile?.email ?? session.user.email ?? "",
phone: profile?.phone ?? "",
};
}
return (
<div className="pb-6">
<TopNav title="Your Details" onBack={true} sticky className="bg-stone" />
<DetailsForm
businessId={business.id}
cancellationPolicy={business.settings?.cancellationPolicy ?? undefined}
isAuthenticated={!!session?.user}
prefill={prefill}
initialNotes={draft.notes}
/>
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
/**
* Deliberately OUTSIDE the (customer) route group — the booking flow must
* work for guests, and (customer)/layout.tsx's session check doesn't
* apply here. The bottom nav is hidden throughout, matching the focused,
* single-purpose flow in the booking templates (18, 9, 17, 26, 27) — see
* docs/phase-4-summary.md for why that's a deliberate, brief-sanctioned
* choice ("may temporarily hide the bottom navigation where this
* improves focus").
*/
export default function BookLayout({ children }: { children: React.ReactNode }) {
return (
<div id="main-content" className="min-h-screen bg-stone">
{children}
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import type { Metadata } from "next";
import { readDraft } from "@/lib/booking-draft/cookie";
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
import { prisma } from "@/lib/db/prisma";
import { getAvailableServices } from "@/services/scheduling/availability.service";
import { ServiceSelector } from "@/features/booking/components/service-selector";
import { saveServicesStepAction } from "@/features/booking/actions/draft";
export const metadata: Metadata = { title: "Select Services" };
export const dynamic = "force-dynamic";
export default async function BookServicesPage() {
const business = await getPrimaryBusiness();
const [services, categories, draft] = await Promise.all([
getAvailableServices(prisma, { businessId: business.id }),
prisma.serviceCategory.findMany({
where: { businessId: business.id, isActive: true },
orderBy: { displayOrder: "asc" },
}),
readDraft(business.id),
]);
const categoryNames = new Map(categories.map((c) => [c.id, c.name]));
const orderedCategoryIds = categories.map((c) => c.id);
async function handleContinue(serviceIds: string[]) {
"use server";
return saveServicesStepAction({ businessId: business.id, serviceIds });
}
return (
<ServiceSelector
services={services}
categoryNames={Object.fromEntries(categoryNames)}
categoryOrder={orderedCategoryIds}
initialSelectedServiceIds={draft.serviceIds}
rebooking={!!draft.rebookFromAppointmentId}
onContinue={handleContinue}
/>
);
}
+200
View File
@@ -0,0 +1,200 @@
import type { Metadata } from "next";
import Link from "next/link";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { TopNav } from "@/components/navigation/top-nav";
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" };
export const dynamic = "force-dynamic";
export default async function BookReviewPage() {
const business = await getPrimaryBusiness();
const draft = await readDraft(business.id);
if (draft.serviceIds.length === 0) redirect("/book");
if (!draft.startAt) redirect("/book/barber-time");
const session = await auth();
if (!session?.user && !draft.guest) redirect("/book/details");
if (!draft.policyAcknowledged) redirect("/book/details");
const [services, staff, customerProfile] = await Promise.all([
prisma.service.findMany({ where: { id: { in: draft.serviceIds } } }),
draft.staffId
? prisma.staffProfile.findUnique({
where: { id: draft.staffId },
include: { user: { select: { name: true } } },
})
: null,
session?.user?.id
? prisma.customerProfile.findUnique({ where: { userId: session.user.id } })
: null,
]);
const totalDuration = services.reduce((sum, s) => sum + s.durationMinutes, 0);
const totalPrice = services.reduce((sum, s) => sum + Number(s.price), 0);
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();
const customerEmail = draft.guest?.email ?? customerProfile?.email ?? session?.user?.email ?? "";
const customerPhone = draft.guest?.phone ?? customerProfile?.phone ?? "";
return (
<div className="pb-12">
<TopNav title="Review Booking" onBack={true} sticky className="bg-stone" />
<main className="mx-auto max-w-2xl space-y-4 px-6 py-6">
<Card className="space-y-6 p-6">
<div className="hidden flex items-start justify-between">
<span className="text-muted text-[10px] font-bold tracking-widest uppercase">
Business
</span>
<Link
href="/home"
className="text-ink text-[10px] font-bold tracking-widest uppercase underline"
>
{business.name}
</Link>
</div>
<div className="space-y-4 pt-2">
<div className="flex items-center justify-between">
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Services
</span>
<ul className="mt-1 text-sm font-bold">
{services.map((s) => (
<li key={s.id}>{s.name}</li>
))}
</ul>
</div>
<Link
href="/book"
className="text-ink text-[10px] font-bold tracking-widest uppercase underline"
>
Edit
</Link>
</div>
<div className="flex items-center justify-between">
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Barber
</span>
<p className="text-sm font-bold">{staff?.user.name ?? "Any available barber"}</p>
</div>
<Link
href="/book/barber-time"
className="text-ink text-[10px] font-bold tracking-widest uppercase underline"
>
Edit
</Link>
</div>
<div className="flex items-center justify-between">
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Date &amp; Time
</span>
<p className="text-sm font-bold">
{startAt.toLocaleDateString("en-GB", {
weekday: "short",
day: "numeric",
month: "short",
timeZone: business.timezone,
})}
{" · "}
{startAt.toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
timeZone: business.timezone,
})}
{"–"}
{endAt.toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
timeZone: business.timezone,
})}
</p>
</div>
<Link
href="/book/barber-time"
className="text-ink text-[10px] font-bold tracking-widest uppercase underline"
>
Edit
</Link>
</div>
</div>
<div className="border-line grid grid-cols-2 gap-6 border-t pt-6">
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Duration
</span>
<p className="text-sm font-bold">{totalDuration} min</p>
</div>
<div className="text-right">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Total
</span>
<p className="text-sm font-bold">£{totalPrice.toFixed(2)}</p>
</div>
</div>
<div className="border-line border-t pt-6">
<div className="flex items-center justify-between">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Your Details
</span>
<Link
href="/book/details"
className="text-ink text-[10px] font-bold tracking-widest uppercase underline"
>
Edit
</Link>
</div>
<p className="mt-1 text-sm font-bold">{customerName}</p>
<p className="text-muted text-xs">
{customerEmail}
{customerPhone ? ` · ${customerPhone}` : ""}
</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">
Notes
</span>
<p className="text-muted mt-1 text-sm italic">{draft.notes}</p>
</div>
)}
</Card>
<ConfirmBookingButton businessId={business.id} />
</main>
</div>
);
}
+60
View File
@@ -0,0 +1,60 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { prisma } from "@/lib/db/prisma";
import { SchedulingTestClient } from "./scheduling-test-client";
export const metadata: Metadata = { title: "Scheduling Engine — Dev Test" };
// Always reflect live DB state — this is a manual test harness, not a page that should serve a build-time snapshot.
export const dynamic = "force-dynamic";
/**
* Internal-only harness for manually exercising the booking engine
* (Phase 3) — not a customer-facing screen, not linked from any nav. Uses
* the seeded demo business/staff/customer from prisma/seed.ts. See
* docs/phase-3-summary.md.
*/
export default async function SchedulingTestPage() {
const business = await prisma.business.findUnique({
where: { slug: "blake-heritage-barbershop" },
select: { id: true, name: true, timezone: true },
});
if (!business) {
notFound();
}
const [services, staff, customer] = await Promise.all([
prisma.service.findMany({
where: { businessId: business.id, isActive: true },
orderBy: { displayOrder: "asc" },
}),
prisma.staffProfile.findMany({
where: { businessId: business.id },
include: { user: { select: { name: true } } },
}),
prisma.customerProfile.findFirst({
where: { email: "alex.chen@example.com" },
}),
]);
if (!customer) {
return (
<div className="text-danger p-8 text-sm">
No seeded customer found — run <code>npm run prisma:seed</code> first.
</div>
);
}
return (
<SchedulingTestClient
business={business}
services={services.map((s) => ({
id: s.id,
name: s.name,
durationMinutes: s.durationMinutes,
price: s.price.toString(),
}))}
staff={staff.map((s) => ({ id: s.id, name: s.user.name ?? "Staff member" }))}
customerId={customer.id}
/>
);
}
@@ -0,0 +1,300 @@
"use client";
import { useEffect, useState, useTransition } from "react";
import {
getAvailableDatesAction,
getAvailableTimeSlotsAction,
} from "@/features/booking/actions/availability";
import {
cancelBookingAction,
createBookingAction,
rescheduleBookingAction,
} from "@/features/booking/actions/booking";
import {
checkInAction,
completeAppointmentAction,
listAppointmentsAction,
markNoShowAction,
startAppointmentAction,
} from "@/features/booking/actions/appointment";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { MonthCalendar } from "@/components/ui/month-calendar";
import { Select } from "@/components/ui/select";
import { StatusPill } from "@/components/ui/status-pill";
import type { AppointmentStatus } from "@/types/domain";
interface Service {
id: string;
name: string;
durationMinutes: number;
price: string;
}
interface Staff {
id: string;
name: string;
}
interface TimeSlot {
startMinutes: number;
startAt: string;
endAt: string;
staffOptions: string[];
}
/**
* Local calendar-date components, not `.toISOString().slice(0, 10)` — that
* converts to UTC first, which silently shifts the date back a day for
* anyone in a timezone ahead of UTC (e.g. the business's own Europe/London
* during BST). `date` here is always local midnight from MonthCalendar's
* own `new Date(year, month, day)`, so reading the local parts back out is
* the correct inverse.
*/
function toYMD(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
const EMPTY_SET: Set<string> = new Set();
const EMPTY_SLOTS: TimeSlot[] = [];
const STATUS_MAP: Record<string, AppointmentStatus> = {
PENDING: "pending_approval",
CONFIRMED: "confirmed",
CHECKED_IN: "arrived",
IN_PROGRESS: "in_progress",
COMPLETED: "completed",
CANCELLED: "cancelled",
NO_SHOW: "no_show",
};
export function SchedulingTestClient(props: {
business: { id: string; name: string; timezone: string };
services: Service[];
staff: Staff[];
customerId: string;
}) {
const { business, services, staff, customerId } = props;
const [isPending, startTransition] = useTransition();
const [selectedServiceIds, setSelectedServiceIds] = useState<string[]>(services[0] ? [services[0].id] : []);
const [selectedStaffId, setSelectedStaffId] = useState<string>("any");
const [month, setMonth] = useState(() => new Date());
const [availableDates, setAvailableDates] = useState<Set<string>>(new Set());
const [selectedDate, setSelectedDate] = useState<Date | undefined>();
const [slots, setSlots] = useState<TimeSlot[]>([]);
const [notes, setNotes] = useState("");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [appointments, setAppointments] = useState<any[]>([]);
const [message, setMessage] = useState<string | null>(null);
const refreshAppointments = () => {
listAppointmentsAction({ businessId: business.id, businessTimezone: business.timezone }).then(setAppointments);
};
useEffect(() => {
refreshAppointments();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Available dates for the visible month. When nothing is selected there's
// nothing to fetch — `effectiveAvailableDates` below derives the empty
// case at render time rather than this effect resetting state
// synchronously (which cascades an extra render for no reason).
useEffect(() => {
if (selectedServiceIds.length === 0) return;
const from = new Date(month.getFullYear(), month.getMonth(), 1);
const to = new Date(month.getFullYear(), month.getMonth() + 1, 0);
getAvailableDatesAction({
businessId: business.id,
serviceIds: selectedServiceIds,
staffId: selectedStaffId === "any" ? undefined : selectedStaffId,
fromDate: toYMD(from),
toDate: toYMD(to),
}).then((res) => setAvailableDates(new Set(res.dates)));
}, [business.id, month, selectedServiceIds, selectedStaffId]);
const effectiveAvailableDates = selectedServiceIds.length === 0 ? EMPTY_SET : availableDates;
// Time slots for the selected date — same derive-at-render-time pattern.
useEffect(() => {
if (!selectedDate || selectedServiceIds.length === 0) return;
getAvailableTimeSlotsAction({
businessId: business.id,
serviceIds: selectedServiceIds,
staffId: selectedStaffId === "any" ? undefined : selectedStaffId,
date: toYMD(selectedDate),
}).then((res) => setSlots(res.slots));
}, [business.id, selectedDate, selectedServiceIds, selectedStaffId]);
const effectiveSlots = !selectedDate || selectedServiceIds.length === 0 ? EMPTY_SLOTS : slots;
const toggleService = (id: string) => {
setSelectedServiceIds((prev) => (prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id]));
};
const book = (slot: TimeSlot) => {
startTransition(async () => {
const result = await createBookingAction({
businessId: business.id,
customerId,
serviceIds: selectedServiceIds,
staffId: selectedStaffId === "any" ? undefined : selectedStaffId,
startAt: slot.startAt,
notes: notes || undefined,
});
if (result.success) {
setMessage(`Booked: ${result.appointment.id}`);
setNotes("");
setSelectedDate(undefined);
refreshAppointments();
} else {
setMessage(`Failed: ${result.validation.issues.map((i) => `${i.code} — ${i.message}`).join("; ")}`);
}
});
};
const cancel = (appointmentId: string) => {
startTransition(async () => {
const result = await cancelBookingAction({ appointmentId, cancelledByUserId: customerId, reason: "Dev test" });
setMessage(result.success ? "Cancelled" : `Failed: ${result.validation.issues.map((i) => i.message).join("; ")}`);
refreshAppointments();
});
};
const reschedule = (appointmentId: string) => {
const input = window.prompt("New start time (ISO, e.g. 2026-08-20T10:00:00.000Z):");
if (!input) return;
startTransition(async () => {
const result = await rescheduleBookingAction({ appointmentId, startAt: input });
setMessage(result.success ? "Rescheduled" : `Failed: ${result.validation.issues.map((i) => i.message).join("; ")}`);
refreshAppointments();
});
};
const runTransition = (fn: (id: string) => Promise<{ success: boolean }>, appointmentId: string) => {
startTransition(async () => {
const result = await fn(appointmentId);
setMessage(result.success ? "Status updated" : "Transition rejected");
refreshAppointments();
});
};
return (
<div className="mx-auto max-w-2xl space-y-8 px-6 py-10">
<header>
<h1 className="text-2xl font-bold">Scheduling Engine — Dev Test</h1>
<p className="mt-1 text-sm text-muted">
{business.name} · {business.timezone} · not a real feature page, see docs/phase-3-summary.md
</p>
</header>
{message && (
<div className="rounded-xl border border-line bg-white p-3 text-sm" role="status">
{message}
</div>
)}
<Card className="space-y-4 p-6">
<h2 className="text-xs font-bold uppercase tracking-widest text-muted">1. Services</h2>
<div className="space-y-2">
{services.map((s) => (
<Checkbox
key={s.id}
label={`${s.name} (${s.durationMinutes}m, £${s.price})`}
checked={selectedServiceIds.includes(s.id)}
onChange={() => toggleService(s.id)}
/>
))}
</div>
<h2 className="pt-2 text-xs font-bold uppercase tracking-widest text-muted">2. Barber</h2>
<Select value={selectedStaffId} onChange={(e) => setSelectedStaffId(e.target.value)}>
<option value="any">Anyone available</option>
{staff.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</Select>
</Card>
<Card className="p-6">
<h2 className="mb-4 text-xs font-bold uppercase tracking-widest text-muted">3. Date</h2>
<MonthCalendar
month={month}
onMonthChange={setMonth}
selectedDate={selectedDate}
onSelectDate={setSelectedDate}
isDateDisabled={(date) => !effectiveAvailableDates.has(toYMD(date))}
/>
<p className="mt-3 text-xs text-muted">Selectable dates are ones the engine reports as having openings.</p>
</Card>
{selectedDate && (
<Card className="space-y-4 p-6">
<h2 className="text-xs font-bold uppercase tracking-widest text-muted">4. Time</h2>
{effectiveSlots.length === 0 ? (
<p className="text-sm text-muted">No available slots this day for this selection.</p>
) : (
<div className="flex flex-wrap gap-2">
{effectiveSlots.map((slot) => (
<button
key={slot.startAt}
onClick={() => book(slot)}
disabled={isPending}
className="rounded-lg border border-line px-4 py-2 text-sm font-medium hover:border-ink disabled:opacity-50"
>
{new Date(slot.startAt).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", timeZone: business.timezone })}
<span className="ml-1 text-[10px] text-muted">({slot.staffOptions.length} avail.)</span>
</button>
))}
</div>
)}
<input
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Notes (optional)"
className="w-full rounded-lg border border-line px-3 py-2 text-sm"
/>
</Card>
)}
<Card className="space-y-3 p-6">
<h2 className="text-xs font-bold uppercase tracking-widest text-muted">Appointments</h2>
{appointments.length === 0 && <p className="text-sm text-muted">None yet.</p>}
{appointments.map((a) => (
<div key={a.id} className="rounded-xl border border-line p-4 text-sm">
<div className="mb-2 flex items-center justify-between">
<StatusPill status={STATUS_MAP[a.status] ?? "confirmed"} />
<span className="text-xs text-muted">{new Date(a.startAt).toLocaleString("en-GB", { timeZone: business.timezone })}</span>
</div>
<p className="mb-3 text-xs text-muted">
{a.staff.user.name} · {a.services.map((s: { service: { name: string } }) => s.service.name).join(" + ")}
</p>
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="secondary" onClick={() => runTransition((id) => checkInAction(id), a.id)} disabled={isPending}>
Check in
</Button>
<Button size="sm" variant="secondary" onClick={() => runTransition((id) => startAppointmentAction(id), a.id)} disabled={isPending}>
Start
</Button>
<Button size="sm" variant="secondary" onClick={() => runTransition((id) => completeAppointmentAction(id), a.id)} disabled={isPending}>
Complete
</Button>
<Button size="sm" variant="secondary" onClick={() => runTransition((id) => markNoShowAction(id), a.id)} disabled={isPending}>
No-show
</Button>
<Button size="sm" variant="secondary" onClick={() => reschedule(a.id)} disabled={isPending}>
Reschedule
</Button>
<Button size="sm" variant="destructive" onClick={() => cancel(a.id)} disabled={isPending}>
Cancel
</Button>
</div>
</div>
))}
</Card>
</div>
);
}
+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>
);
}
+93
View File
@@ -0,0 +1,93 @@
@import "tailwindcss";
/*
* Design tokens ported from /templates. The majority of the 22 UX Pilot
* exports converge on this palette (ink/stone/surface/muted/line/accent) —
* see docs/implementation-plan.md §8. Two competing palettes found in the
* templates (a salon-red variant on the staff calendar, a blue variant on
* the appointments list) were NOT carried forward; that section explains
* why. `--radius-jumbo`/`--radius-tile` use the larger of the two radius
* scales found in the templates, per the same recommendation.
*/
@theme {
--color-ink: #111b15;
--color-stone: #f4f6f4;
--color-surface: #ffffff;
--color-pure-cloud: #f5f6f8;
/* 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
status-* tokens below even though they share a hue today: an
appointment being CANCELLED and a form field being INVALID are
different concepts that a future theme may want to diverge on. */
--color-danger: #ef4444;
--color-warning: #f59e0b;
--color-warning-foreground: #92400e;
/* Semantic status colors — replaces the ad hoc per-screen status colors
found in the templates (see docs/implementation-plan.md §8). */
--color-status-confirmed: #10b981;
--color-status-pending: #f59e0b;
--color-status-progress: #2563eb;
--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;
--font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
}
@layer base {
html {
-webkit-text-size-adjust: 100%;
}
body {
@apply bg-stone text-ink antialiased;
}
/* Visible focus ring for keyboard users everywhere — the templates had
no explicit focus state beyond the browser default on form inputs
(see docs/implementation-plan.md §8, Accessibility risks). */
:focus-visible {
@apply ring-ink ring-offset-stone ring-2 ring-offset-2 outline-none;
}
}
@layer utilities {
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+18
View File
@@ -0,0 +1,18 @@
/**
* Deliberately not the (auth)/(customer)/(staff)/(owner) shells — this
* route renders before any business exists, so there is nothing to brand
* with yet (see the brief's "Offline and Error Branding" section: use
* neutral installation wording before install, business branding after).
* A plain neutral mark, not the app's own logo/name, keeps that boundary
* honest rather than showing product branding on the one screen where it
* explicitly shouldn't appear.
*/
export default function InstallLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen flex-col bg-stone p-6">
<div id="main-content" className="mx-auto w-full max-w-lg flex-1 py-10">
{children}
</div>
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { redirect } from "next/navigation";
import { getInstallationState } from "@/lib/installation/installation-state";
import { InstallWizard } from "@/features/installation/components/install-wizard";
export const metadata = {
title: "Set Up Your Business",
};
// Must never be statically prerendered — the installation-state check on
// every request is the entire point of this page (see the brief's "do
// not rely on the form being hidden"). Without this, Next could bake a
// single build-time snapshot of `installed` into static HTML and serve
// that forever, regardless of the database's real state afterward.
export const dynamic = "force-dynamic";
export default async function InstallPage() {
const { installed } = await getInstallationState();
// Direct navigation after a completed install — src/proxy.ts already
// redirects here for most cases, but this page enforces the same rule
// itself rather than trusting the proxy alone (see the brief's "do not
// rely on the form being hidden" requirement).
if (installed) {
redirect("/");
}
const tokenConfigured = !!process.env.INSTALLATION_TOKEN;
if (!tokenConfigured) {
return (
<div className="space-y-4">
<h1 className="text-2xl font-bold">Installation is not configured</h1>
<p className="text-muted text-sm">
This deployment has no <code className="rounded bg-white px-1.5 py-0.5 text-xs">INSTALLATION_TOKEN</code>{" "}
environment variable set, so setup can&apos;t proceed. An administrator needs to set one and restart the
application before installation can begin.
</p>
</div>
);
}
return <InstallWizard />;
}
+93
View File
@@ -0,0 +1,93 @@
import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google";
import { DeploymentProviders } from "@/components/deployment-providers";
import "./globals.css";
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
display: "swap",
});
/**
* Dynamic, not a static `export const metadata` — every page's title,
* description, and app-name metadata reflect the installed business, not
* the internal product name (see src/lib/business/branding.ts). Before
* installation completes, `getBranding()` returns neutral wording rather
* than "NextChair" (see that file's own fallback-order comment).
*/
export async function generateMetadata(): Promise<Metadata> {
if (process.env.MANAGER_MODE === "true") {
return {
title: { default: "NextChair Manager", template: "%s | NextChair Manager" },
description: "Manage NextChair business deployments.",
};
}
const { getBranding } = await import("@/lib/business/branding");
const branding = await getBranding();
return {
title: { default: branding.name, template: `%s | ${branding.name}` },
description: branding.description || "Book your next appointment in seconds.",
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: branding.shortName,
// One exact per-device `apple-touch-startup-image` entry per array
// item — imported wholesale from a generator's ZIP bundle (see
// importAppIconZipAction), not hand-picked. Empty array renders no
// splash screens at all, which is the correct "unconfigured" state.
startupImage: branding.splashScreens.length > 0 ? branding.splashScreens : undefined,
},
icons:
branding.faviconUrl || branding.appIconUrl
? { icon: branding.faviconUrl || undefined, apple: branding.appIconUrl || undefined }
: undefined,
};
}
export async function generateViewport(): Promise<Viewport> {
if (process.env.MANAGER_MODE === "true") {
return {
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: false,
viewportFit: "cover",
themeColor: "#111b15",
};
}
const { getBranding } = await import("@/lib/business/branding");
const branding = await getBranding();
return {
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: false,
viewportFit: "cover",
themeColor: branding.themeColor,
};
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
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>
<DeploymentProviders managerMode={process.env.MANAGER_MODE === "true"}>{children}</DeploymentProviders>
</body>
</html>
);
}
+8
View File
@@ -0,0 +1,8 @@
import { redirect } from "next/navigation";
import { ManagerAddBusiness } from "@/features/management/components/manager-add-business";
import { isManagerAuthenticated } from "@/services/management/management-auth";
export default async function ManagerAddPage() {
if (!(await isManagerAuthenticated())) redirect("/manager/sign-in");
return <ManagerAddBusiness />;
}
@@ -0,0 +1,14 @@
import { notFound, redirect } from "next/navigation";
import { ManagerBusinessDetail } from "@/features/management/components/manager-business-detail";
import { isManagerAuthenticated } from "@/services/management/management-auth";
import { getManagerConfig } from "@/services/management/manager-config";
import { getBusiness } from "@/services/management/provisioning.service";
export default async function ManagerBusinessPage({ params }: { params: Promise<{ slug: string }> }) {
if (!(await isManagerAuthenticated())) redirect("/manager/sign-in");
try {
return <ManagerBusinessDetail business={await getBusiness((await params).slug)} dockgeUrl={getManagerConfig().dockgeUrl} />;
} catch {
notFound();
}
}
+5
View File
@@ -0,0 +1,5 @@
import type { ReactNode } from "react";
export default function ManagerLayout({ children }: { children: ReactNode }) {
return <div className="min-h-screen bg-stone">{children}</div>;
}
+9
View File
@@ -0,0 +1,9 @@
import { redirect } from "next/navigation";
import { ManagerDashboard } from "@/features/management/components/manager-dashboard";
import { isManagerAuthenticated } from "@/services/management/management-auth";
import { listBusinesses } from "@/services/management/provisioning.service";
export default async function ManagerPage() {
if (!(await isManagerAuthenticated())) redirect("/manager/sign-in");
return <ManagerDashboard initialBusinesses={await listBusinesses()} />;
}
+19
View File
@@ -0,0 +1,19 @@
import type { Metadata } from "next";
import { ManagerSignInForm } from "@/features/management/components/manager-sign-in-form";
export const metadata: Metadata = { title: "NextChair Manager" };
export default function ManagerSignInPage() {
return (
<main className="flex min-h-screen items-center justify-center px-6 py-12">
<div className="w-full max-w-md">
<div className="mb-10">
<p className="mb-4 text-[10px] font-bold tracking-[0.3em] text-accent-foreground uppercase">NextChair Manager</p>
<h1 className="text-4xl font-bold tracking-tight">Platform operations</h1>
<p className="mt-3 text-sm leading-relaxed text-muted">Manage business deployments without opening up Docker administration.</p>
</div>
<ManagerSignInForm />
</div>
</main>
);
}
+81
View File
@@ -0,0 +1,81 @@
import type { MetadataRoute } from "next";
/**
* Next.js's manifest file convention — auto-served at /manifest.webmanifest
* with the <link rel="manifest"> tag injected automatically. Dynamic as of
* Phase 8: name/description/colors reflect the installed business (see
* src/lib/business/branding.ts) rather than the internal product name.
*
* `force-dynamic` is required, not optional — without it, Next has no
* signal that this route depends on request-time data (a plain DB read
* doesn't count as one of Next's dynamic-usage triggers) and statically
* renders it once at build time. Since the Docker image is built before
* the installation wizard ever runs, that bakes in the pre-install
* neutral fallback ("Book an Appointment"/"Booking") permanently — the
* real business name/icons would never appear no matter how the Business
* row changes afterward.
*
* Each of the four manifest icons falls back independently to the app's
* own pre-generated, correctly-sized/maskable PWA icon when a business
* hasn't uploaded its own — a business can set some without all four (see
* the "App Icon & Home Screen" super-admin settings screen). This is
* fallback rule #2 ("business name + a neutral generated icon") applied
* per-field, rather than risking a broken/non-square install icon.
*/
export const dynamic = "force-dynamic";
export default async function manifest(): Promise<MetadataRoute.Manifest> {
if (process.env.MANAGER_MODE === "true") {
return {
name: "NextChair Manager",
short_name: "Manager",
description: "Manage NextChair business deployments.",
start_url: "/manager",
scope: "/",
display: "standalone",
background_color: "#f4f6f4",
theme_color: "#111b15",
orientation: "portrait-primary",
icons: [{ src: "/icons/icon-192.png", sizes: "192x192", type: "image/png" }],
};
}
const { getBranding } = await import("@/lib/business/branding");
const branding = await getBranding();
return {
name: branding.name,
short_name: branding.shortName,
description: branding.description || "Book your next appointment in seconds.",
start_url: "/home",
scope: "/",
display: "standalone",
background_color: branding.backgroundColor,
theme_color: branding.themeColor,
orientation: "portrait-primary",
icons: [
{
src: branding.icon192Url || "/icons/icon-192.png",
sizes: "192x192",
type: "image/png",
purpose: "any",
},
{
src: branding.icon512Url || "/icons/icon-512.png",
sizes: "512x512",
type: "image/png",
purpose: "any",
},
{
src: branding.maskableIcon192Url || "/icons/icon-maskable-192.png",
sizes: "192x192",
type: "image/png",
purpose: "maskable",
},
{
src: branding.maskableIcon512Url || "/icons/icon-maskable-512.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
],
};
}
+38
View File
@@ -0,0 +1,38 @@
import type { Metadata } from "next";
import { WifiOff } from "lucide-react";
import { getBranding } from "@/lib/business/branding";
export const metadata: Metadata = { title: "You're offline" };
// Forced dynamic so every time the service worker actually (re-)fetches
// this page (at install/activate, while online — see this file's own
// comment below), it gets a fresh render reflecting current branding.
// Without this, a production build could statically bake in whatever
// branding existed at *build* time and serve that forever, even after
// the business's branding changes.
export const dynamic = "force-dynamic";
/**
* The service worker's navigation fallback (see public/sw.js) — served
* from cache when a page request fails with no network. No auth, no
* per-request data fetching once it's *serving* offline (the whole point
* is that it still renders with zero connectivity) — but it's still a
* normal server-rendered page whenever the service worker actually
* (re-)precaches it, which only ever happens while online (install/
* activate, see public/sw.js). So it reads business branding like any
* other page; a device that goes offline simply keeps showing whatever
* branding was current the last time it had a connection.
*/
export default async function OfflinePage() {
const branding = await getBranding();
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">
{branding.name} needs a connection for booking, schedules, and account details. Reconnect and try again.
</p>
</div>
);
}
@@ -0,0 +1,14 @@
/**
* Deliberately outside the (owner) route group, same reasoning as
* src/app/staff/appointments/[id]/reschedule/layout.tsx: this page reuses
* BarberDateTimePicker, whose own fixed bottom action bar would otherwise
* sit under the persistent owner sidebar/bottom-nav and swallow clicks
* meant for "Confirm Reschedule".
*/
export default function OwnerRescheduleLayout({ children }: { children: React.ReactNode }) {
return (
<div id="main-content" className="bg-stone min-h-screen">
{children}
</div>
);
}
@@ -0,0 +1,111 @@
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { auth } from "@/auth";
import { Card } from "@/components/ui/card";
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 { getAppointmentById } from "@/services/scheduling/appointment.service";
import { assertAppointmentInBusiness, StaffPermissionDeniedError } from "@/features/staff/authorization";
import { ownerRescheduleAppointmentAction } from "@/features/owner/actions/appointments";
import { BarberDateTimePicker } from "@/features/booking/components/barber-date-time-picker";
export const metadata: Metadata = { title: "Reschedule / Reassign" };
export const dynamic = "force-dynamic";
const NON_RESCHEDULABLE = new Set(["COMPLETED", "CANCELLED", "NO_SHOW", "IN_PROGRESS"]);
/**
* Also the owner's appointment-reassignment surface — BarberDateTimePicker
* already lets the caller choose a different eligible barber as part of
* picking a new slot, so "reassign to another barber" and "reschedule to a
* new time" are the same underlying operation here, not two separate
* flows. See docs/phase-6-summary.md, "Appointment reassignment approach".
*/
export default async function OwnerRescheduleAppointmentPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const session = await auth();
if (!session?.user) redirect("/sign-in");
const canManage = await hasPermission(session.user.id, Permission.MANAGE_ALL_APPOINTMENTS);
if (!canManage) redirect("/staff");
const owner = await prisma.staffProfile.findUnique({
where: { userId: session.user.id },
include: { business: { select: { id: true, timezone: true } } },
});
if (!owner) redirect("/staff");
const { id } = await params;
const appointment = await getAppointmentById(prisma, id);
if (!appointment) notFound();
try {
assertAppointmentInBusiness(appointment, { id: owner.id, businessId: owner.businessId });
} catch (error) {
if (error instanceof StaffPermissionDeniedError) notFound();
throw error;
}
if (NON_RESCHEDULABLE.has(appointment.status)) {
redirect(`/owner/appointments/${id}`);
}
async function handleContinue(selection: { staffId?: string; date: string; startAt: string }) {
"use server";
const result = await ownerRescheduleAppointmentAction({
appointmentId: id,
staffId: selection.staffId,
startAt: selection.startAt,
});
if (result.success) {
redirect(`/owner/appointments/${id}`);
}
return result;
}
return (
<div className="pb-6">
<TopNav title="Reschedule / Reassign" onBack sticky className="bg-stone" />
<Card className="mx-6 mt-4 flex items-center gap-3 p-4">
<div className="flex-1">
<p className="text-sm font-bold">
{appointment.services.map((s) => s.service.name).join(" + ")} for {appointment.customer.firstName}{" "}
{appointment.customer.lastName}
</p>
<p className="text-muted text-xs">
Currently with {appointment.staff.user.name} —{" "}
{appointment.startAt.toLocaleDateString("en-GB", {
weekday: "short",
day: "numeric",
month: "short",
timeZone: owner.business.timezone,
})}{" "}
·{" "}
{appointment.startAt.toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
timeZone: owner.business.timezone,
})}
</p>
</div>
</Card>
<div className="mt-4">
<BarberDateTimePicker
businessId={owner.businessId}
businessTimezone={owner.business.timezone}
serviceIds={appointment.services.map((s) => s.serviceId)}
initialStaffId={appointment.staffId}
onContinue={handleContinue}
continueLabel="Confirm"
/>
</div>
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { redirect } from "next/navigation";
/** Deployment-mode entry point. Keep this module database-free: Next can
* evaluate the root route module before running the page function. */
export default async function RootPage() {
if (process.env.MANAGER_MODE === "true") redirect("/manager");
const { default: BusinessWelcomePage } = await import("@/features/welcome/pages/business-welcome-page");
return <BusinessWelcomePage />;
}
@@ -0,0 +1,14 @@
/**
* Deliberately outside the (staff) route group, same reasoning as
* src/app/appointments/[id]/reschedule/layout.tsx on the customer side:
* this page reuses BarberDateTimePicker, whose own fixed bottom action bar
* would otherwise sit under the persistent staff bottom nav and swallow
* clicks meant for "Confirm Reschedule".
*/
export default function StaffRescheduleLayout({ children }: { children: React.ReactNode }) {
return (
<div id="main-content" className="bg-stone min-h-screen">
{children}
</div>
);
}
@@ -0,0 +1,93 @@
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { Card } from "@/components/ui/card";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { getAppointmentById } from "@/services/scheduling/appointment.service";
import { assertOwnAppointment, StaffPermissionDeniedError } from "@/features/staff/authorization";
import { rescheduleStaffAppointmentAction } from "@/features/staff/actions/appointment-actions";
import { BarberDateTimePicker } from "@/features/booking/components/barber-date-time-picker";
export const metadata: Metadata = { title: "Reschedule Appointment" };
export const dynamic = "force-dynamic";
const NON_RESCHEDULABLE = new Set(["COMPLETED", "CANCELLED", "NO_SHOW", "IN_PROGRESS"]);
export default async function StaffRescheduleAppointmentPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const staff = await getActingStaff();
if (!staff) redirect("/home");
const { id } = await params;
const appointment = await getAppointmentById(prisma, id);
if (!appointment) notFound();
try {
assertOwnAppointment(appointment, staff);
} catch (error) {
if (error instanceof StaffPermissionDeniedError) notFound();
throw error;
}
if (NON_RESCHEDULABLE.has(appointment.status)) {
redirect(`/staff/appointments/${id}`);
}
async function handleContinue(selection: { staffId?: string; date: string; startAt: string }) {
"use server";
const result = await rescheduleStaffAppointmentAction({
appointmentId: id,
staffId: selection.staffId,
startAt: selection.startAt,
});
if (result.success) {
redirect(`/staff/appointments/${id}`);
}
return result;
}
return (
<div className="pb-6">
<TopNav title="Reschedule" onBack sticky className="bg-stone" />
<Card className="mx-6 mt-4 flex items-center gap-3 p-4">
<div className="flex-1">
<p className="text-sm font-bold">
{appointment.services.map((s) => s.service.name).join(" + ")} for{" "}
{appointment.customer.firstName} {appointment.customer.lastName}
</p>
<p className="text-muted text-xs">
Currently:{" "}
{appointment.startAt.toLocaleDateString("en-GB", {
weekday: "short",
day: "numeric",
month: "short",
timeZone: staff.business.timezone,
})}{" "}
·{" "}
{appointment.startAt.toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
timeZone: staff.business.timezone,
})}
</p>
</div>
</Card>
<div className="mt-4">
<BarberDateTimePicker
businessId={staff.businessId}
businessTimezone={staff.business.timezone}
serviceIds={appointment.services.map((s) => s.serviceId)}
initialStaffId={appointment.staffId}
onContinue={handleContinue}
continueLabel="Confirm Reschedule"
/>
</div>
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { TopNav } from "@/components/navigation/top-nav";
import { readStaffDraft } from "@/lib/booking-draft/cookie";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { BarberDateTimePicker } from "@/features/booking/components/barber-date-time-picker";
import { saveStaffBarberTimeStepAction } from "@/features/staff/actions/staff-booking";
export const metadata: Metadata = { title: "Barber & Time" };
export const dynamic = "force-dynamic";
export default async function StaffBookBarberTimePage() {
const staff = await getActingStaff();
if (!staff) redirect("/home");
const draft = await readStaffDraft(staff.businessId);
if (draft.serviceIds.length === 0) redirect("/staff/book/services");
async function handleContinue(selection: { staffId?: string; date: string; startAt: string }) {
"use server";
return saveStaffBarberTimeStepAction(selection);
}
return (
<div className="pb-6">
<TopNav title="Barber & Time" onBack sticky className="bg-stone" />
<BarberDateTimePicker
businessId={staff.businessId}
businessTimezone={staff.business.timezone}
serviceIds={draft.serviceIds}
initialStaffId={draft.staffId}
initialDate={draft.date}
onContinue={handleContinue}
/>
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
/**
* Deliberately outside the (staff) route group — same reasoning as
* src/app/book/layout.tsx (customer) and src/app/staff/appointments/[id]/reschedule:
* this flow reuses ServiceSelector and BarberDateTimePicker, both of which
* render their own fixed bottom action bar that would otherwise sit under
* the persistent staff bottom nav.
*/
export default function StaffBookLayout({ children }: { children: React.ReactNode }) {
return (
<div id="main-content" className="bg-stone min-h-screen">
{children}
</div>
);
}
+130
View File
@@ -0,0 +1,130 @@
import type { Metadata } from "next";
import { Search, UserPlus } from "lucide-react";
import { redirect } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { TopNav } from "@/components/navigation/top-nav";
import { prisma } from "@/lib/db/prisma";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { matchesCustomerSearch } from "@/services/customer/customer.service";
import { selectExistingCustomerAction } from "@/features/staff/actions/staff-booking";
import { NewGuestCustomerForm } from "@/features/staff/components/new-guest-customer-form";
export const metadata: Metadata = { title: "Book Appointment" };
export const dynamic = "force-dynamic";
export default async function StaffBookCustomerPage({
searchParams,
}: {
searchParams: Promise<{ customerId?: string; q?: string }>;
}) {
const staff = await getActingStaff();
if (!staff) redirect("/home");
const { customerId, q } = await searchParams;
if (customerId) {
const customer = await prisma.customerProfile.findUnique({ where: { id: customerId } });
if (customer) {
const selectAction = selectExistingCustomerAction.bind(null, customer.id);
return (
<div className="pb-6">
<TopNav title="Book Appointment" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl space-y-4 px-6 py-6">
<Card className="p-6">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Booking For
</span>
<p className="mt-1 text-lg font-bold">
{customer.firstName} {customer.lastName}
</p>
<p className="text-muted text-sm">{customer.phone ?? customer.email}</p>
</Card>
<form action={selectAction}>
<Button type="submit" className="w-full">
Continue
</Button>
</form>
</main>
</div>
);
}
}
const candidates = q
? await prisma.customerProfile.findMany({
where: { appointments: { some: { businessId: staff.businessId } } },
})
: [];
const results = candidates.filter((c) => matchesCustomerSearch(c, q ?? "")).slice(0, 20);
return (
<div className="pb-6">
<TopNav title="Book Appointment" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl space-y-6 px-6 py-6">
<section>
<h2 className="mb-3 text-sm font-bold">Find an Existing Customer</h2>
<form action="/staff/book" method="GET" className="relative mb-3">
<Search
className="text-muted absolute top-1/2 left-4 h-4 w-4 -translate-y-1/2"
aria-hidden="true"
/>
<label htmlFor="customer-search" className="sr-only">
Search customers
</label>
<input
id="customer-search"
type="search"
name="q"
defaultValue={q}
placeholder="Search by name, email, or phone"
className="border-line focus:ring-ink w-full rounded-xl border bg-white py-3 pr-4 pl-11 text-sm outline-none focus:ring-2"
/>
</form>
{q && (
<div className="divide-line rounded-md border-line divide-y border bg-white">
{results.length === 0 ? (
<p className="text-muted px-5 py-4 text-sm">
No customers match &ldquo;{q}&rdquo;.
</p>
) : (
results.map((customer) => {
const action = selectExistingCustomerAction.bind(null, customer.id);
return (
<form key={customer.id} action={action}>
<button
type="submit"
className="hover:bg-stone flex w-full items-center gap-3 px-5 py-4 text-left"
>
<div className="bg-stone text-muted flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-xs font-bold">
{customer.firstName.charAt(0)}
{customer.lastName.charAt(0)}
</div>
<div>
<p className="text-sm font-bold">
{customer.firstName} {customer.lastName}
</p>
<p className="text-muted text-xs">{customer.phone ?? customer.email}</p>
</div>
</button>
</form>
);
})
)}
</div>
)}
</section>
<section>
<h2 className="mb-3 flex items-center gap-2 text-sm font-bold">
<UserPlus className="text-accent h-4 w-4" aria-hidden="true" /> Or Add a New Customer
</h2>
<Card className="p-5">
<NewGuestCustomerForm />
</Card>
</section>
</main>
</div>
);
}
+162
View File
@@ -0,0 +1,162 @@
import type { Metadata } from "next";
import Link from "next/link";
import { redirect } from "next/navigation";
import { Card } from "@/components/ui/card";
import { TopNav } from "@/components/navigation/top-nav";
import { readStaffDraft } from "@/lib/booking-draft/cookie";
import { prisma } from "@/lib/db/prisma";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { ConfirmStaffBooking } from "@/features/staff/components/confirm-staff-booking";
export const metadata: Metadata = { title: "Review Booking" };
export const dynamic = "force-dynamic";
export default async function StaffBookReviewPage() {
const staff = await getActingStaff();
if (!staff) redirect("/home");
const draft = await readStaffDraft(staff.businessId);
if (draft.serviceIds.length === 0) redirect("/staff/book/services");
if (!draft.startAt) redirect("/staff/book/barber-time");
if (!draft.customerId && !draft.guest) redirect("/staff/book");
const [services, staffMember, customerProfile] = await Promise.all([
prisma.service.findMany({ where: { id: { in: draft.serviceIds } } }),
draft.staffId
? prisma.staffProfile.findUnique({
where: { id: draft.staffId },
include: { user: { select: { name: true } } },
})
: null,
draft.customerId
? prisma.customerProfile.findUnique({ where: { id: draft.customerId } })
: null,
]);
const totalDuration = services.reduce((sum, s) => sum + s.durationMinutes, 0);
const totalPrice = services.reduce((sum, s) => sum + Number(s.price), 0);
const startAt = new Date(draft.startAt);
const endAt = new Date(startAt.getTime() + totalDuration * 60_000);
const tz = staff.business.timezone;
const customerName = draft.guest
? `${draft.guest.firstName} ${draft.guest.lastName}`
: `${customerProfile?.firstName ?? ""} ${customerProfile?.lastName ?? ""}`.trim();
const customerContact = draft.guest
? `${draft.guest.email} · ${draft.guest.phone}`
: (customerProfile?.phone ?? customerProfile?.email ?? "");
return (
<div className="pb-12">
<TopNav title="Review Booking" onBack sticky className="bg-stone" />
<main className="mx-auto max-w-2xl space-y-4 px-6 py-6">
<Card className="space-y-6 p-6">
<div className="flex items-start justify-between">
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Customer
</span>
<p className="text-sm font-bold">{customerName}</p>
<p className="text-muted text-xs">{customerContact}</p>
</div>
<Link
href="/staff/book"
className="text-ink text-[10px] font-bold tracking-widest uppercase underline"
>
Change
</Link>
</div>
<div className="border-line space-y-4 border-t pt-6">
<div className="flex items-center justify-between">
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Services
</span>
<ul className="mt-1 text-sm font-bold">
{services.map((s) => (
<li key={s.id}>{s.name}</li>
))}
</ul>
</div>
<Link
href="/staff/book/services"
className="text-ink text-[10px] font-bold tracking-widest uppercase underline"
>
Edit
</Link>
</div>
<div className="flex items-center justify-between">
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Barber
</span>
<p className="text-sm font-bold">
{staffMember?.user.name ?? "Any available barber"}
</p>
</div>
<Link
href="/staff/book/barber-time"
className="text-ink text-[10px] font-bold tracking-widest uppercase underline"
>
Edit
</Link>
</div>
<div className="flex items-center justify-between">
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Date &amp; Time
</span>
<p className="text-sm font-bold">
{startAt.toLocaleDateString("en-GB", {
weekday: "short",
day: "numeric",
month: "short",
timeZone: tz,
})}
{" · "}
{startAt.toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
timeZone: tz,
})}
–
{endAt.toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
timeZone: tz,
})}
</p>
</div>
<Link
href="/staff/book/barber-time"
className="text-ink text-[10px] font-bold tracking-widest uppercase underline"
>
Edit
</Link>
</div>
</div>
<div className="border-line grid grid-cols-2 gap-6 border-t pt-6">
<div>
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Duration
</span>
<p className="text-sm font-bold">{totalDuration} min</p>
</div>
<div className="text-right">
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
Total
</span>
<p className="text-sm font-bold">£{totalPrice.toFixed(2)}</p>
</div>
</div>
</Card>
<ConfirmStaffBooking />
</main>
</div>
);
}
Loaded 100 of 690 files, more files were not shown because too many files have changed in this diff. Show more