Initial PHP rebuild of the app
This commit is contained in:
690 files changed
+33944
-12046
No files matched your search
@@ -1,51 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
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 & loyalty.</p>
|
||||
{/* useSearchParams in RegisterForm requires a Suspense boundary. */}
|
||||
<Suspense>
|
||||
<RegisterForm />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
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">
|
||||
“{appointment.cancellationReason}”
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
"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} />;
|
||||
}
|
||||
@@ -1,296 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
"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} />;
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,270 +0,0 @@
|
||||
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">“{appointment.cancellationReason}”</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>
|
||||
);
|
||||
}
|
||||
@@ -1,291 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,256 +0,0 @@
|
||||
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'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>
|
||||
);
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
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");
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
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 “{q}”.
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
"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} />;
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,296 +0,0 @@
|
||||
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">
|
||||
“{appointment.cancellationReason}”
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { TopNav } from "@/components/navigation/top-nav";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { listInAppNotifications } from "@/services/notifications/notification.service";
|
||||
import { NotificationList } from "@/features/notifications/components/notification-list";
|
||||
|
||||
export const metadata: Metadata = { title: "Notifications" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function StaffNotificationsPage() {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) redirect("/home");
|
||||
|
||||
const notifications = await listInAppNotifications(prisma, { userId: staff.userId });
|
||||
|
||||
return (
|
||||
<div className="pb-12">
|
||||
<TopNav title="Notifications" onBack sticky className="bg-stone" />
|
||||
<main className="mx-auto max-w-2xl px-6 py-6">
|
||||
<NotificationList notifications={notifications} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
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)}`;
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
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"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
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 });
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
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 });
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
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 });
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
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),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
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.
|
Before Width: | Height: | Size: 2.2 KiB |
@@ -1,17 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
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're all set!</h1>
|
||||
<p role="status" className="mb-8 text-center text-sm text-muted">
|
||||
We'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>
|
||||
);
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,300 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 16 KiB |
@@ -1,18 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
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't proceed. An administrator needs to set one and restart the
|
||||
application before installation can begin.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <InstallWizard />;
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
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 />;
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export default function ManagerLayout({ children }: { children: ReactNode }) {
|
||||
return <div className="min-h-screen bg-stone">{children}</div>;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
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()} />;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
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",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
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'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>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
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 />;
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
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 “{q}”.
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
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 & 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
Reference in new issue
Block a user