Staf Booking
fixed staff ability to book a customer in
This commit is contained in:
8 files changed
+323
-119
No files matched your search
@@ -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">
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
|
||||||
className={cn(
|
|
||||||
"text-[9px] font-bold tracking-widest uppercase",
|
|
||||||
isSelected ? "text-white/70" : "text-muted",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
|
<span className="text-muted text-[9px] font-bold tracking-widest uppercase">
|
||||||
{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>
|
||||||
|
|||||||
@@ -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,47 +100,105 @@ 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 }}>
|
||||||
|
<div style={{ height: HEADER_HEIGHT }} aria-hidden="true" />
|
||||||
|
<div className="relative" style={{ height: gridHeight }}>
|
||||||
|
{hourMarks.map((hour) =>
|
||||||
|
hour === END_HOUR ? null : (
|
||||||
|
<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>
|
||||||
|
{[15, 30, 45].map((minute) => (
|
||||||
|
<span
|
||||||
|
key={minute}
|
||||||
|
className="text-muted/70 absolute right-0 text-[9px]"
|
||||||
|
style={{ top: (minute / MINUTES_PER_HOUR) * PX_PER_HOUR - 6 }}
|
||||||
|
>
|
||||||
|
{minute}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</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) => (
|
{hourMarks.map((hour) => (
|
||||||
<div
|
<div
|
||||||
key={hour}
|
key={hour}
|
||||||
className="border-line absolute inset-x-0 border-t"
|
className="border-line absolute inset-x-0 border-t"
|
||||||
style={{ top: (hour - START_HOUR) * PX_PER_HOUR }}
|
style={{ top: (hour - START_HOUR) * PX_PER_HOUR }}
|
||||||
>
|
/>
|
||||||
<span className="text-muted absolute -top-2.5 -left-14 w-12 text-right text-[10px] font-medium">
|
|
||||||
{hour === 12 ? "12pm" : hour > 12 ? `${hour - 12}pm` : `${hour}am`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Outside-working-hours shading */}
|
{column.wholeDayOff ? (
|
||||||
{workingWindow ? (
|
<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>
|
||||||
|
) : (
|
||||||
<>
|
<>
|
||||||
{workingWindow.startMinutes > RANGE_START_MINUTES && (
|
{/* Outside-working-hours shading — diagonal hatch, same texture as the break/time-off blocks below. */}
|
||||||
|
{column.workingWindow ? (
|
||||||
|
<>
|
||||||
|
{column.workingWindow.startMinutes > RANGE_START_MINUTES && (
|
||||||
<div
|
<div
|
||||||
className="bg-line/20 absolute inset-x-0"
|
className={HATCH_CLASS}
|
||||||
style={{ top: 0, height: topFor(workingWindow.startMinutes) }}
|
style={{ top: 0, height: topFor(column.workingWindow.startMinutes) }}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{workingWindow.endMinutes < RANGE_END_MINUTES && (
|
{column.workingWindow.endMinutes < RANGE_END_MINUTES && (
|
||||||
<div
|
<div
|
||||||
className="bg-line/20 absolute inset-x-0"
|
className={HATCH_CLASS}
|
||||||
style={{ top: topFor(workingWindow.endMinutes), bottom: 0 }}
|
style={{ top: topFor(column.workingWindow.endMinutes), bottom: 0 }}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-line/20 absolute inset-0" aria-hidden="true" />
|
<div className={cn(HATCH_CLASS, "inset-0")} aria-hidden="true" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Breaks / time off */}
|
{/* Breaks / time off */}
|
||||||
{blocks.map((block) => (
|
{column.blocks.map((block) => (
|
||||||
<div
|
<div
|
||||||
key={block.id}
|
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"
|
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"
|
||||||
@@ -121,68 +214,128 @@ export function DayCalendar({
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Appointments */}
|
{/* Appointments */}
|
||||||
{appointments.map((appt) => (
|
{column.appointments.map((appt) => {
|
||||||
|
const blockHeight = heightFor(appt.startMinutes, appt.endMinutes);
|
||||||
|
const BadgeIcon = STATUS_BADGE_ICON[appt.status];
|
||||||
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={appt.id}
|
key={appt.id}
|
||||||
href={`${appointmentHrefBase}/${appt.id}`}
|
href={`${appointmentHrefBase}/${appt.id}`}
|
||||||
className={cn(
|
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",
|
"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",
|
"focus-visible:ring-ink focus-visible:ring-2 focus-visible:ring-offset-1",
|
||||||
statusBorderClass(appt.status),
|
statusBlockClass(appt.status),
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
top: topFor(appt.startMinutes),
|
top: topFor(appt.startMinutes),
|
||||||
height: heightFor(appt.startMinutes, appt.endMinutes),
|
height: blockHeight,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1">
|
<span
|
||||||
<StatusPill status={appt.status} iconOnly className="px-1.5 py-0.5" />
|
className={cn(
|
||||||
{appt.hasNotes && (
|
"absolute top-1 right-1 flex h-4 w-4 items-center justify-center rounded-full",
|
||||||
<NotebookText className="text-muted h-3 w-3 shrink-0" aria-label="Has notes" />
|
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 && (
|
{appt.hasAvailableReward && (
|
||||||
<Gift
|
<Gift className="text-accent-loyalty h-2.5 w-2.5 shrink-0" aria-label="Reward available" />
|
||||||
className="text-accent-loyalty h-3 w-3 shrink-0"
|
|
||||||
aria-label="Reward available"
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="truncate text-xs font-bold">{appt.customerName}</p>
|
{blockHeight >= 44 && (
|
||||||
<p className="text-muted truncate text-[10px]">{appt.serviceNames}</p>
|
<p className="truncate text-[11px] opacity-80">
|
||||||
|
{appt.customerName} • {appt.serviceNames}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{/* Current-time indicator */}
|
{/* Current-time indicator */}
|
||||||
{currentTimeMinutes !== null &&
|
{showCurrentTime && (
|
||||||
currentTimeMinutes >= RANGE_START_MINUTES &&
|
|
||||||
currentTimeMinutes <= RANGE_END_MINUTES && (
|
|
||||||
<div
|
<div
|
||||||
className="border-danger absolute inset-x-0 z-30 border-t-2"
|
className="border-danger pointer-events-none absolute inset-x-0 z-30 border-t-2"
|
||||||
style={{ top: topFor(currentTimeMinutes) }}
|
style={{ top: topFor(currentTimeMinutes!) }}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
/>
|
||||||
<span className="bg-danger absolute -top-1.5 -left-1.5 h-3 w-3 rounded-full" />
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</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";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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://");
|
||||||
|
}
|
||||||
Reference in new issue
Block a user