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