Staf Booking

fixed staff ability to book a customer in
This commit is contained in:
Stefan Lazic committed 2026-08-06 14:49:46 +01:00
1 parent 973f18f64c
commit 90ec663e6e
8 files changed
+378 -174

No files matched your search

+17 -27
View File
@@ -21,7 +21,7 @@ import { getAvailableRewardFlags } from "@/services/loyalty/loyalty.service";
import { listAppointments } from "@/services/scheduling/appointment.service"; import { listAppointments } from "@/services/scheduling/appointment.service";
import { DateStrip } from "@/features/staff/components/date-strip"; import { DateStrip } from "@/features/staff/components/date-strip";
import { DayCalendar } from "@/features/staff/components/day-calendar"; import { DayCalendar } from "@/features/staff/components/day-calendar";
import type { CalendarAppointment, CalendarBlock } from "@/features/staff/components/day-calendar"; import type { CalendarAppointment, CalendarBlock, CalendarColumn } from "@/features/staff/components/day-calendar";
import { OwnerBlockTimeButton } from "@/features/owner/components/owner-block-time-button"; import { OwnerBlockTimeButton } from "@/features/owner/components/owner-block-time-button";
import { cn } from "@/lib/utils/cn"; import { cn } from "@/lib/utils/cn";
import type { AppointmentStatus } from "@/types/domain"; import type { AppointmentStatus } from "@/types/domain";
@@ -74,7 +74,7 @@ export default async function OwnerCalendarPage({
const allStaff = await prisma.staffProfile.findMany({ const allStaff = await prisma.staffProfile.findMany({
where: { businessId: context.businessId, status: "ACTIVE" }, where: { businessId: context.businessId, status: "ACTIVE" },
include: { user: { select: { id: true, name: true } } }, include: { user: { select: { id: true, name: true, image: true } } },
orderBy: { createdAt: "asc" }, orderBy: { createdAt: "asc" },
}); });
const selectedStaff = staffIdParam ? allStaff.find((s) => s.id === staffIdParam) : undefined; const selectedStaff = staffIdParam ? allStaff.find((s) => s.id === staffIdParam) : undefined;
@@ -159,6 +159,20 @@ export default async function OwnerCalendarPage({
return { workingWindow, blocks, wholeDayOff, staffAppointments }; 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,
};
});
return ( return (
<div className="pb-6"> <div className="pb-6">
<div className="flex items-center justify-between px-6 pt-12"> <div className="flex items-center justify-between px-6 pt-12">
@@ -237,31 +251,7 @@ export default async function OwnerCalendarPage({
<EmptyState icon={CalendarX2} title="No active staff" description="Add a staff member from Team." /> <EmptyState icon={CalendarX2} title="No active staff" description="Add a staff member from Team." />
</div> </div>
) : ( ) : (
<div className="flex gap-4 overflow-x-auto px-6 pb-4 lg:px-0"> <DayCalendar columns={columns} currentTimeMinutes={currentTimeMinutes} appointmentHrefBase="/owner/appointments" />
{staffToRender.map((s) => {
const column = buildColumnFor(s.id);
return (
<div key={s.id} className="w-[320px] shrink-0 lg:w-auto lg:flex-1">
<p className="text-muted mb-2 px-6 text-xs font-bold tracking-widest uppercase lg:px-0">
{s.user.name}
</p>
{column.wholeDayOff ? (
<div className="px-6 lg:px-0">
<EmptyState icon={CalendarX2} title="Off this day" />
</div>
) : (
<DayCalendar
appointments={column.staffAppointments}
blocks={column.blocks}
workingWindow={column.workingWindow}
currentTimeMinutes={currentTimeMinutes}
appointmentHrefBase="/owner/appointments"
/>
)}
</div>
);
})}
</div>
) )
) : appointments.length === 0 ? ( ) : appointments.length === 0 ? (
<div className="px-6"> <div className="px-6">
+15 -7
View File
@@ -18,7 +18,7 @@ import { getAvailableRewardFlags } from "@/services/loyalty/loyalty.service";
import { listAppointments, selectCurrentAppointment } from "@/services/scheduling/appointment.service"; import { listAppointments, selectCurrentAppointment } from "@/services/scheduling/appointment.service";
import { DateStrip } from "@/features/staff/components/date-strip"; import { DateStrip } from "@/features/staff/components/date-strip";
import { DayCalendar } from "@/features/staff/components/day-calendar"; import { DayCalendar } from "@/features/staff/components/day-calendar";
import type { CalendarAppointment, CalendarBlock } from "@/features/staff/components/day-calendar"; import type { CalendarAppointment, CalendarBlock, CalendarColumn } from "@/features/staff/components/day-calendar";
import { BlockTimeButton } from "@/features/staff/components/block-time-button"; import { BlockTimeButton } from "@/features/staff/components/block-time-button";
import { cn } from "@/lib/utils/cn"; import { cn } from "@/lib/utils/cn";
import type { AppointmentStatus } from "@/types/domain"; import type { AppointmentStatus } from "@/types/domain";
@@ -124,6 +124,19 @@ export default async function StaffCalendarPage({
const currentTimeMinutes = const currentTimeMinutes =
date === today ? minutesFromMidnight(new Date(), staff.business.timezone) : null; 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 // 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, // does more than center the mobile layout (see docs/phase-5-summary.md,
// "Responsive Design"). Only meaningful when viewing today. // "Responsive Design"). Only meaningful when viewing today.
@@ -181,12 +194,7 @@ export default async function StaffCalendarPage({
description={wholeDayOff.reason ?? "Approved time off."} description={wholeDayOff.reason ?? "Approved time off."}
/> />
) : view === "day" ? ( ) : view === "day" ? (
<DayCalendar <DayCalendar columns={columns} currentTimeMinutes={currentTimeMinutes} />
appointments={calendarAppointments}
blocks={blocks}
workingWindow={workingWindow}
currentTimeMinutes={currentTimeMinutes}
/>
) : calendarAppointments.length === 0 ? ( ) : calendarAppointments.length === 0 ? (
<div className="px-6 lg:px-0"> <div className="px-6 lg:px-0">
<EmptyState <EmptyState
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { prisma } from "@/lib/db/prisma"; import { prisma } from "@/lib/db/prisma";
import { isSecureDeployment } from "@/lib/security/cookie-security";
import { createOAuthState } from "@/lib/security/oauth-state"; import { createOAuthState } from "@/lib/security/oauth-state";
import { GoogleCalendarProvider } from "@/services/calendar/google-calendar.provider"; import { GoogleCalendarProvider } from "@/services/calendar/google-calendar.provider";
@@ -49,7 +50,7 @@ export async function GET() {
const response = NextResponse.redirect(authorizationUrl); const response = NextResponse.redirect(authorizationUrl);
response.cookies.set("gcal_oauth_state", state, { response.cookies.set("gcal_oauth_state", state, {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === "production", secure: isSecureDeployment(),
sameSite: "lax", sameSite: "lax",
path: "/", path: "/",
maxAge: 600, maxAge: 600,
+8 -13
View File
@@ -81,22 +81,17 @@ export function DateStrip({
key={date} key={date}
href={hrefFor(date)} href={hrefFor(date)}
aria-current={isSelected ? "date" : undefined} aria-current={isSelected ? "date" : undefined}
className={cn( className="flex flex-1 flex-col items-center gap-1 py-2 text-xs"
"flex flex-1 flex-col items-center gap-1 rounded-xl border py-2 text-xs",
isSelected
? "border-ink bg-ink text-white"
: "border-line text-ink hover:bg-stone bg-white",
)}
> >
<span <span className="text-muted text-[9px] font-bold tracking-widest uppercase">
className={cn(
"text-[9px] font-bold tracking-widest uppercase",
isSelected ? "text-white/70" : "text-muted",
)}
>
{d.toLocaleDateString("en-GB", { weekday: "short" })} {d.toLocaleDateString("en-GB", { weekday: "short" })}
</span> </span>
<span className={cn("font-bold", isToday && !isSelected && "text-accent")}> <span
className={cn(
"flex h-7 w-7 items-center justify-center rounded-full font-bold",
isSelected ? "bg-ink text-white" : isToday ? "text-accent" : "text-ink",
)}
>
{d.getDate()} {d.getDate()}
</span> </span>
</Link> </Link>
+276 -123
View File
@@ -1,23 +1,33 @@
import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { Gift, NotebookText } from "lucide-react"; import { Ban, CheckCheck, CircleDot, Clock, Gift, LogIn, NotebookText, UserX } from "lucide-react";
import { StatusPill } from "@/components/ui/status-pill"; import type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils/cn"; import { cn } from "@/lib/utils/cn";
import type { AppointmentStatus } from "@/types/domain"; import type { AppointmentStatus } from "@/types/domain";
const START_HOUR = 7; const START_HOUR = 7;
const END_HOUR = 21; const END_HOUR = 21;
const PX_PER_HOUR = 64; const PX_PER_HOUR = 112;
const RANGE_START_MINUTES = START_HOUR * 60; const MINUTES_PER_HOUR = 60;
const RANGE_END_MINUTES = END_HOUR * 60; const RANGE_START_MINUTES = START_HOUR * MINUTES_PER_HOUR;
const RANGE_END_MINUTES = END_HOUR * MINUTES_PER_HOUR;
const HEADER_HEIGHT = 56;
function topFor(minutes: number): number { function topFor(minutes: number): number {
return ((clampToRange(minutes) - RANGE_START_MINUTES) / 60) * PX_PER_HOUR; return ((clampToRange(minutes) - RANGE_START_MINUTES) / MINUTES_PER_HOUR) * PX_PER_HOUR;
} }
function clampToRange(minutes: number): number { function clampToRange(minutes: number): number {
return Math.min(RANGE_END_MINUTES, Math.max(RANGE_START_MINUTES, minutes)); return Math.min(RANGE_END_MINUTES, Math.max(RANGE_START_MINUTES, minutes));
} }
function heightFor(startMinutes: number, endMinutes: number): number { function heightFor(startMinutes: number, endMinutes: number): number {
return Math.max(20, topFor(endMinutes) - topFor(startMinutes)); return Math.max(24, topFor(endMinutes) - topFor(startMinutes));
}
/** Compact "9:30" / "1:15" clock text — no AM/PM, matching the tight in-block time-range label. */
function formatCompactTime(minutes: number): string {
const h = Math.floor(minutes / MINUTES_PER_HOUR);
const m = minutes % MINUTES_PER_HOUR;
const displayHour = h % 12 === 0 ? 12 : h % 12;
return `${displayHour}:${String(m).padStart(2, "0")}`;
} }
export interface CalendarAppointment { export interface CalendarAppointment {
@@ -39,25 +49,50 @@ export interface CalendarBlock {
kind: "break" | "timeOff"; kind: "break" | "timeOff";
} }
export interface CalendarColumn {
id: string;
name: string;
avatarUrl?: string | null;
/** Formatted working-hours range for the day (e.g. "10:00 AM – 7:00 PM"), or null if off entirely. */
hoursLabel: string | null;
wholeDayOff: boolean;
appointments: CalendarAppointment[];
blocks: CalendarBlock[];
/** The column's working window that day, for shading outside-hours space — null if off entirely. */
workingWindow: { startMinutes: number; endMinutes: number } | null;
}
const STATUS_BADGE_ICON: Record<AppointmentStatus, LucideIcon> = {
pending_approval: Clock,
confirmed: CheckCheck,
arrived: LogIn,
in_progress: CircleDot,
completed: CheckCheck,
cancelled: Ban,
no_show: UserX,
};
/** Column width on small screens — under 100% so the next column peeks in at the edge, inviting a swipe (per the reference design). Desktop ignores this and flexes to fill instead. */
const COLUMN_WIDTH_CLASS = "w-[86%] shrink-0 snap-start sm:w-[300px] lg:w-auto lg:flex-1";
/** /**
* A single day's grid for one staff member. Deliberately no drag-and-drop — * A shared-axis, multi-column, swipeable day grid — one time axis pinned to
* every interactive element is a plain Link (open details / start a * the left edge (via `sticky`, so it never scrolls out of view), one column
* reschedule from there), per the brief's preference for a reliable * per staff member snapping into place as the row is swiped horizontally
* tap-to-act flow over an unreliable drag interaction. Pure presentation: * (CSS scroll-snap — no touch/gesture JS needed, matching this app's
* every minute value here is already computed by the page from real * "reliable tap/scroll over fragile custom gestures" preference). A single
* WorkingHours/Break/TimeOff/Appointment rows, nothing is derived here. * staff member's own view (staff/page.tsx) is just the one-column case of
* the same component. Deliberately no drag-and-drop — every interactive
* element is a plain Link (open details / start a reschedule from there).
* Pure presentation: every minute value here is already computed by the
* page from real WorkingHours/Break/TimeOff/Appointment rows.
*/ */
export function DayCalendar({ export function DayCalendar({
appointments, columns,
blocks,
workingWindow,
currentTimeMinutes, currentTimeMinutes,
appointmentHrefBase = "/staff/appointments", appointmentHrefBase = "/staff/appointments",
}: { }: {
appointments: CalendarAppointment[]; columns: CalendarColumn[];
blocks: CalendarBlock[];
/** The staff member's working window that day, for shading outside-hours space — null if they're off entirely. */
workingWindow: { startMinutes: number; endMinutes: number } | null;
/** Minutes-from-midnight right now, only passed when the viewed day is today. */ /** Minutes-from-midnight right now, only passed when the viewed day is today. */
currentTimeMinutes: number | null; currentTimeMinutes: number | null;
/** Owner's all-staff calendar reuses this component unchanged but links into /owner/appointments/[id] instead — see docs/phase-6-summary.md, "All-staff calendar approach". */ /** Owner's all-staff calendar reuses this component unchanged but links into /owner/appointments/[id] instead — see docs/phase-6-summary.md, "All-staff calendar approach". */
@@ -65,124 +100,242 @@ export function DayCalendar({
}) { }) {
const hourMarks = Array.from({ length: END_HOUR - START_HOUR + 1 }, (_, i) => START_HOUR + i); const hourMarks = Array.from({ length: END_HOUR - START_HOUR + 1 }, (_, i) => START_HOUR + i);
const gridHeight = (END_HOUR - START_HOUR) * PX_PER_HOUR; const gridHeight = (END_HOUR - START_HOUR) * PX_PER_HOUR;
const showCurrentTime =
currentTimeMinutes !== null && currentTimeMinutes >= RANGE_START_MINUTES && currentTimeMinutes <= RANGE_END_MINUTES;
return ( return (
<div className="overflow-x-auto px-6 pb-8"> <div className="flex gap-2 px-6 pb-8">
<div className="relative ml-14" style={{ height: gridHeight }}> {/* Time axis — a plain sibling of the scrolling columns strip below, not part of its horizontal scroll at all, so it's immune to any scroll-snap/sticky edge cases: it simply never moves. */}
{/* Hour gridlines + labels */} <div className="w-11 shrink-0" style={{ height: HEADER_HEIGHT + gridHeight }}>
{hourMarks.map((hour) => ( <div style={{ height: HEADER_HEIGHT }} aria-hidden="true" />
<div <div className="relative" style={{ height: gridHeight }}>
key={hour} {hourMarks.map((hour) =>
className="border-line absolute inset-x-0 border-t" hour === END_HOUR ? null : (
style={{ top: (hour - START_HOUR) * PX_PER_HOUR }} <div key={hour} className="absolute inset-x-0" style={{ top: (hour - START_HOUR) * PX_PER_HOUR }}>
> <span className="text-muted absolute -top-2 right-0 text-[10px] font-bold">{formatHour(hour)}</span>
<span className="text-muted absolute -top-2.5 -left-14 w-12 text-right text-[10px] font-medium"> {[15, 30, 45].map((minute) => (
{hour === 12 ? "12pm" : hour > 12 ? `${hour - 12}pm` : `${hour}am`} <span
</span> key={minute}
</div> className="text-muted/70 absolute right-0 text-[9px]"
))} style={{ top: (minute / MINUTES_PER_HOUR) * PX_PER_HOUR - 6 }}
>
{/* Outside-working-hours shading */} {minute}
{workingWindow ? ( </span>
<> ))}
{workingWindow.startMinutes > RANGE_START_MINUTES && ( </div>
<div ),
className="bg-line/20 absolute inset-x-0"
style={{ top: 0, height: topFor(workingWindow.startMinutes) }}
aria-hidden="true"
/>
)}
{workingWindow.endMinutes < RANGE_END_MINUTES && (
<div
className="bg-line/20 absolute inset-x-0"
style={{ top: topFor(workingWindow.endMinutes), bottom: 0 }}
aria-hidden="true"
/>
)}
</>
) : (
<div className="bg-line/20 absolute inset-0" aria-hidden="true" />
)}
{/* Breaks / time off */}
{blocks.map((block) => (
<div
key={block.id}
className="border-warning/40 bg-warning/10 absolute inset-x-1 z-10 overflow-hidden rounded-lg border bg-[repeating-linear-gradient(135deg,transparent,transparent_6px,rgba(0,0,0,0.04)_6px,rgba(0,0,0,0.04)_12px)] px-2 py-1"
style={{
top: topFor(block.startMinutes),
height: heightFor(block.startMinutes, block.endMinutes),
}}
>
<p className="text-warning-foreground truncate text-[10px] font-bold tracking-widest uppercase">
{block.label}
</p>
</div>
))}
{/* Appointments */}
{appointments.map((appt) => (
<Link
key={appt.id}
href={`${appointmentHrefBase}/${appt.id}`}
className={cn(
"absolute inset-x-1 z-20 overflow-hidden rounded-lg border-l-4 bg-white p-2 shadow-sm transition-shadow hover:shadow-md",
"focus-visible:ring-ink focus-visible:ring-2 focus-visible:ring-offset-1",
statusBorderClass(appt.status),
)}
style={{
top: topFor(appt.startMinutes),
height: heightFor(appt.startMinutes, appt.endMinutes),
}}
>
<div className="flex items-center gap-1">
<StatusPill status={appt.status} iconOnly className="px-1.5 py-0.5" />
{appt.hasNotes && (
<NotebookText className="text-muted h-3 w-3 shrink-0" aria-label="Has notes" />
)}
{appt.hasAvailableReward && (
<Gift
className="text-accent-loyalty h-3 w-3 shrink-0"
aria-label="Reward available"
/>
)}
</div>
<p className="truncate text-xs font-bold">{appt.customerName}</p>
<p className="text-muted truncate text-[10px]">{appt.serviceNames}</p>
</Link>
))}
{/* Current-time indicator */}
{currentTimeMinutes !== null &&
currentTimeMinutes >= RANGE_START_MINUTES &&
currentTimeMinutes <= RANGE_END_MINUTES && (
<div
className="border-danger absolute inset-x-0 z-30 border-t-2"
style={{ top: topFor(currentTimeMinutes) }}
aria-hidden="true"
>
<span className="bg-danger absolute -top-1.5 -left-1.5 h-3 w-3 rounded-full" />
</div>
)} )}
{showCurrentTime && (
<span
className="bg-danger absolute right-0 top-0 h-2 w-2 -translate-y-1/2 rounded-full"
style={{ top: topFor(currentTimeMinutes!) }}
aria-hidden="true"
/>
)}
</div>
</div>
{/* Staff columns — a separately-scrolling strip, so swiping through staff never drags the axis along with it. Each column is a full swipeable "page": its own header, then its own grid body. */}
<div className="snap-x snap-mandatory flex flex-1 gap-3 overflow-x-auto">
{columns.map((column) => (
<div key={column.id} className={COLUMN_WIDTH_CLASS}>
<div className="flex items-center gap-2 pb-3" style={{ height: HEADER_HEIGHT }}>
{column.avatarUrl ? (
<Image
src={column.avatarUrl}
alt=""
width={36}
height={36}
className="h-9 w-9 shrink-0 rounded-full border border-line object-cover"
/>
) : (
<div className="bg-white text-muted flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-line text-sm font-bold">
{column.name.charAt(0)}
</div>
)}
<div className="min-w-0">
<p className="truncate text-sm font-bold">{column.name}</p>
<p className="text-muted truncate text-[10px]">{column.hoursLabel ?? "Off today"}</p>
</div>
</div>
<div className="border-line relative border-l" style={{ height: gridHeight }}>
{/* Hour gridlines */}
{hourMarks.map((hour) => (
<div
key={hour}
className="border-line absolute inset-x-0 border-t"
style={{ top: (hour - START_HOUR) * PX_PER_HOUR }}
/>
))}
{column.wholeDayOff ? (
<div className="bg-line/10 absolute inset-0 flex items-center justify-center">
<span className="text-muted text-xs font-bold tracking-widest uppercase">Off today</span>
</div>
) : (
<>
{/* Outside-working-hours shading — diagonal hatch, same texture as the break/time-off blocks below. */}
{column.workingWindow ? (
<>
{column.workingWindow.startMinutes > RANGE_START_MINUTES && (
<div
className={HATCH_CLASS}
style={{ top: 0, height: topFor(column.workingWindow.startMinutes) }}
aria-hidden="true"
/>
)}
{column.workingWindow.endMinutes < RANGE_END_MINUTES && (
<div
className={HATCH_CLASS}
style={{ top: topFor(column.workingWindow.endMinutes), bottom: 0 }}
aria-hidden="true"
/>
)}
</>
) : (
<div className={cn(HATCH_CLASS, "inset-0")} aria-hidden="true" />
)}
{/* Breaks / time off */}
{column.blocks.map((block) => (
<div
key={block.id}
className="border-warning/40 bg-warning/10 absolute inset-x-1 z-10 overflow-hidden rounded-lg border bg-[repeating-linear-gradient(135deg,transparent,transparent_6px,rgba(0,0,0,0.04)_6px,rgba(0,0,0,0.04)_12px)] px-2 py-1"
style={{
top: topFor(block.startMinutes),
height: heightFor(block.startMinutes, block.endMinutes),
}}
>
<p className="text-warning-foreground truncate text-[10px] font-bold tracking-widest uppercase">
{block.label}
</p>
</div>
))}
{/* Appointments */}
{column.appointments.map((appt) => {
const blockHeight = heightFor(appt.startMinutes, appt.endMinutes);
const BadgeIcon = STATUS_BADGE_ICON[appt.status];
return (
<Link
key={appt.id}
href={`${appointmentHrefBase}/${appt.id}`}
className={cn(
"absolute inset-x-1 z-20 overflow-hidden rounded-lg border-l-4 p-2 shadow-sm transition-shadow hover:shadow-md",
"focus-visible:ring-ink focus-visible:ring-2 focus-visible:ring-offset-1",
statusBlockClass(appt.status),
)}
style={{
top: topFor(appt.startMinutes),
height: blockHeight,
}}
>
<span
className={cn(
"absolute top-1 right-1 flex h-4 w-4 items-center justify-center rounded-full",
statusBadgeClass(appt.status),
)}
>
<BadgeIcon className="h-2.5 w-2.5" aria-hidden="true" />
<span className="sr-only">{statusLabel(appt.status)}</span>
</span>
<div className="flex items-center gap-1 pr-4">
<p className="truncate text-[11px] font-bold">
{formatCompactTime(appt.startMinutes)} – {formatCompactTime(appt.endMinutes)}
</p>
{appt.hasNotes && <NotebookText className="text-muted h-2.5 w-2.5 shrink-0" aria-label="Has notes" />}
{appt.hasAvailableReward && (
<Gift className="text-accent-loyalty h-2.5 w-2.5 shrink-0" aria-label="Reward available" />
)}
</div>
{blockHeight >= 44 && (
<p className="truncate text-[11px] opacity-80">
{appt.customerName} • {appt.serviceNames}
</p>
)}
</Link>
);
})}
{/* Current-time indicator */}
{showCurrentTime && (
<div
className="border-danger pointer-events-none absolute inset-x-0 z-30 border-t-2"
style={{ top: topFor(currentTimeMinutes!) }}
aria-hidden="true"
/>
)}
</>
)}
</div>
</div>
))}
</div> </div>
</div> </div>
); );
} }
function statusBorderClass(status: AppointmentStatus): string { const HATCH_CLASS =
"bg-line/15 absolute inset-x-0 bg-[repeating-linear-gradient(135deg,transparent,transparent_6px,rgba(0,0,0,0.05)_6px,rgba(0,0,0,0.05)_12px)]";
function formatHour(hour: number): string {
if (hour === 0 || hour === 24) return "12:00";
if (hour === 12) return "12:00";
return `${hour > 12 ? hour - 12 : hour}:00`;
}
function statusLabel(status: AppointmentStatus): string {
switch (status) {
case "pending_approval":
return "Pending approval";
case "confirmed":
return "Confirmed";
case "arrived":
return "Arrived";
case "in_progress":
return "In progress";
case "completed":
return "Completed";
case "cancelled":
return "Cancelled";
case "no_show":
return "No-show";
}
}
function statusBlockClass(status: AppointmentStatus): string {
switch (status) { switch (status) {
case "cancelled": case "cancelled":
case "no_show": case "no_show":
return "border-status-cancelled opacity-60"; return "bg-status-cancelled/10 border-status-cancelled text-status-cancelled-foreground opacity-70";
case "completed": case "completed":
return "border-line opacity-70"; return "bg-line/40 border-line text-muted";
case "in_progress": case "in_progress":
return "border-status-progress"; return "bg-status-progress/15 border-status-progress text-ink";
case "arrived": case "arrived":
return "border-status-confirmed"; return "bg-status-confirmed/15 border-status-confirmed text-ink";
case "pending_approval": case "pending_approval":
return "border-status-pending"; return "bg-status-pending/15 border-status-pending text-ink";
default: default:
return "border-status-confirmed"; return "bg-status-confirmed/15 border-status-confirmed text-ink";
}
}
function statusBadgeClass(status: AppointmentStatus): string {
switch (status) {
case "cancelled":
case "no_show":
return "bg-status-cancelled text-white";
case "completed":
return "bg-muted text-white";
case "in_progress":
return "bg-status-progress text-white";
case "arrived":
return "bg-status-confirmed text-white";
case "pending_approval":
return "bg-status-pending text-white";
default:
return "bg-status-confirmed text-white";
} }
} }
+4 -3
View File
@@ -1,5 +1,6 @@
import "server-only"; import "server-only";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { isSecureDeployment } from "@/lib/security/cookie-security";
import { bookingDraftSchema, emptyDraft, emptyStaffDraft, staffBookingDraftSchema } from "./schema"; import { bookingDraftSchema, emptyDraft, emptyStaffDraft, staffBookingDraftSchema } from "./schema";
import type { BookingDraft, StaffBookingDraft } from "./schema"; import type { BookingDraft, StaffBookingDraft } from "./schema";
import { sign, verify } from "./sign"; import { sign, verify } from "./sign";
@@ -35,7 +36,7 @@ export async function writeDraft(draft: BookingDraft): Promise<void> {
const token = sign(JSON.stringify(draft)); const token = sign(JSON.stringify(draft));
store.set(COOKIE_NAME, token, { store.set(COOKIE_NAME, token, {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === "production", secure: isSecureDeployment(),
sameSite: "lax", sameSite: "lax",
path: "/", path: "/",
maxAge: MAX_AGE_SECONDS, maxAge: MAX_AGE_SECONDS,
@@ -66,7 +67,7 @@ export async function writeLastBookingReference(reference: LastBookingReference)
const token = sign(JSON.stringify(reference)); const token = sign(JSON.stringify(reference));
store.set(CONFIRMATION_COOKIE_NAME, token, { store.set(CONFIRMATION_COOKIE_NAME, token, {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === "production", secure: isSecureDeployment(),
sameSite: "lax", sameSite: "lax",
path: "/", path: "/",
maxAge: CONFIRMATION_MAX_AGE_SECONDS, maxAge: CONFIRMATION_MAX_AGE_SECONDS,
@@ -120,7 +121,7 @@ export async function writeStaffDraft(draft: StaffBookingDraft): Promise<void> {
const token = sign(JSON.stringify(draft)); const token = sign(JSON.stringify(draft));
store.set(STAFF_COOKIE_NAME, token, { store.set(STAFF_COOKIE_NAME, token, {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === "production", secure: isSecureDeployment(),
sameSite: "lax", sameSite: "lax",
path: "/", path: "/",
maxAge: MAX_AGE_SECONDS, maxAge: MAX_AGE_SECONDS,
@@ -0,0 +1,31 @@
import { afterEach, describe, expect, it } from "vitest";
import { isSecureDeployment } from "../cookie-security";
describe("isSecureDeployment", () => {
const originalAppUrl = process.env.NEXT_PUBLIC_APP_URL;
afterEach(() => {
if (originalAppUrl === undefined) delete process.env.NEXT_PUBLIC_APP_URL;
else process.env.NEXT_PUBLIC_APP_URL = originalAppUrl;
});
it("is true when the deployment's own URL is https", () => {
process.env.NEXT_PUBLIC_APP_URL = "https://barbershop.example.com";
expect(isSecureDeployment()).toBe(true);
});
it("is false for a plain-http deployment — e.g. the documented default self-hosted setup", () => {
process.env.NEXT_PUBLIC_APP_URL = "http://localhost:3000";
expect(isSecureDeployment()).toBe(false);
});
it("is false for a plain-http LAN address, regardless of NODE_ENV", () => {
process.env.NEXT_PUBLIC_APP_URL = "http://192.168.1.50:3000";
expect(isSecureDeployment()).toBe(false);
});
it("is false when unset", () => {
delete process.env.NEXT_PUBLIC_APP_URL;
expect(isSecureDeployment()).toBe(false);
});
});
+25
View File
@@ -0,0 +1,25 @@
/**
* Whether this deployment is actually served over HTTPS — the correct
* signal for a cookie's `Secure` attribute, which every browser enforces
* by silently refusing to store if the response wasn't delivered over
* HTTPS (see MDN/RFC 6265bis). `NODE_ENV === "production"` is NOT that
* signal: this app's own Dockerfile hard-sets `NODE_ENV=production`
* regardless of whether the operator has put TLS in front of it, and
* docker-compose.local.yaml ships with no TLS termination at all — a
* self-hosted instance reached at "http://<lan-ip>:3000" (the default,
* documented setup) is exactly `NODE_ENV=production` *and* plain HTTP.
* A cookie marked `Secure` there is silently dropped by the browser on
* write, with no error surfaced anywhere — this was the root cause of a
* real bug where the staff "book for customer" flow's draft cookie
* vanished between steps, bouncing the user back to customer search with
* no explanation. NextAuth's own session cookie doesn't hit this because
* it adapts via `AUTH_TRUST_HOST`/its own request-aware cookie config;
* this app's hand-rolled cookies (booking draft, staff booking draft,
* last-booking reference, Google Calendar OAuth state) need the same
* request-aware treatment, derived here from the deployer-declared
* `NEXT_PUBLIC_APP_URL` (see .env.local.example — defaults to
* "http://localhost:3000") rather than blindly trusting NODE_ENV.
*/
export function isSecureDeployment(): boolean {
return (process.env.NEXT_PUBLIC_APP_URL ?? "").startsWith("https://");
}