diff --git a/e2e/owner-journeys.spec.ts b/e2e/owner-journeys.spec.ts index 0fae67e..4b86fc5 100644 --- a/e2e/owner-journeys.spec.ts +++ b/e2e/owner-journeys.spec.ts @@ -55,21 +55,26 @@ test.describe.serial("owner administration journeys", () => { await page.goto("/owner/calendar"); await expect(page.getByRole("heading", { name: "Calendar" })).toBeVisible(); - await expect(page.getByRole("link", { name: "Marcus Reed", exact: true })).toBeVisible(); - await expect(page.getByRole("link", { name: "Leo Vance", exact: true })).toBeVisible(); + // The staff filter pills are avatar+name+hours cards now, so their + // accessible name is "Marcus Reed 10:00 AM – 7:00 PM" etc — a + // substring match (Playwright's default, no `exact`) still uniquely + // identifies each pill's link. + await expect(page.getByRole("link", { name: "Marcus Reed" })).toBeVisible(); + await expect(page.getByRole("link", { name: "Leo Vance" })).toBeVisible(); // Filtering to one staff member narrows the day view to a single column. - await page.getByRole("link", { name: "Leo Vance", exact: true }).click(); + await page.getByRole("link", { name: "Leo Vance" }).click(); await expect(page).toHaveURL(/staffId=/); // The single remaining day-view column is headed by a
with the staff member's exact name - // (the filter pill link has the same text, so scope to the paragraph specifically). - await expect(page.locator("p").getByText("Leo Vance", { exact: true })).toBeVisible(); + // — the staff filter pill above it is *also* an avatar+name+hours card with its own
of the + // same text now, so scope to the grid itself (data-testid="calendar-grid") to disambiguate. + await expect(page.getByTestId("calendar-grid").locator("p").getByText("Leo Vance", { exact: true })).toBeVisible(); await page.getByRole("link", { name: "Agenda", exact: true }).click(); await expect(page).toHaveURL(/view=agenda/); await expect(page.getByRole("link", { name: "Day", exact: true })).toBeVisible(); - await page.getByRole("link", { name: "All Staff", exact: true }).click(); + await page.getByRole("link", { name: "All Staff" }).click(); await expect(page).not.toHaveURL(/staffId=/); }); diff --git a/src/app/(customer)/appointments/page.tsx b/src/app/(customer)/appointments/page.tsx index a6bcd3d..eb50b4c 100644 --- a/src/app/(customer)/appointments/page.tsx +++ b/src/app/(customer)/appointments/page.tsx @@ -6,11 +6,14 @@ import { AppointmentCard } from "@/components/ui/appointment-card"; import { Button } from "@/components/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { EmptyState } from "@/components/feedback/empty-state"; +import { TopNav } from "@/components/navigation/top-nav"; import { getPrimaryBusiness } from "@/lib/business/get-primary-business"; import { prisma } from "@/lib/db/prisma"; import { listAppointments } from "@/services/scheduling/appointment.service"; +import { getUnreadNotificationCount } from "@/services/notifications/notification.service"; import { BookAgainButton } from "@/features/booking/components/book-again-button"; import { CancelAppointmentDialog } from "@/features/booking/components/cancel-appointment-dialog"; +import { InlineNotificationBell } from "@/features/notifications/components/notification-bell"; import type { AppointmentStatus } from "@/types/domain"; export const metadata: Metadata = { title: "Appointments" }; @@ -36,41 +39,49 @@ export default async function AppointmentsPage() { if (!session?.user) { return ( -
Get the app
-Get the app
+{context.businessName} — today's overview
-{w.message}
-No one is scheduled to work today.
- ) : ( -Completed visits, last 30 days.
- {summary.popularServices.length === 0 ? ( -No completed visits in the last 30 days yet.
- ) : ( -No loyalty activity yet.
- ) : ( -{w.message}
+No one is scheduled to work today.
+ ) : ( +Completed visits, last 30 days.
+ {summary.popularServices.length === 0 ? ( +No completed visits in the last 30 days yet.
+ ) : ( +No loyalty activity yet.
+ ) : ( +- {customer.firstName} {customer.lastName} -
-- {customer.phone ?? customer.email} - {lastVisit && - ` · Last visit ${lastVisit.startAt.toLocaleDateString("en-GB", { day: "numeric", month: "short" })}`} -
-- {completedVisits} visit{completedVisits === 1 ? "" : "s"} - {upcoming && · Upcoming} - {loyalty && ( - - {" "} - · {loyalty.currentStamps}/{loyalty.rewardThreshold} stamps - - )} -
-+ {customer.firstName} {customer.lastName} +
++ {customer.phone ?? customer.email} + {lastVisit && + ` · Last visit ${lastVisit.startAt.toLocaleDateString("en-GB", { day: "numeric", month: "short" })}`} +
++ {completedVisits} visit{completedVisits === 1 ? "" : "s"} + {upcoming && · Upcoming} + {loyalty && ( + + {" "} + · {loyalty.currentStamps}/{loyalty.rewardThreshold} stamps + + )} +
+- {current.customer.firstName} {current.customer.lastName} -
-- {current.customer.isGuest ? "Guest customer" : "Registered customer"} -
++ {current.customer.firstName} {current.customer.lastName} +
++ {current.customer.isGuest ? "Guest customer" : "Registered customer"} +
+{current.services.map((s) => s.service.name).join(" + ")}
-- {current.startAt.toLocaleTimeString("en-GB", { - hour: "2-digit", - minute: "2-digit", - timeZone: tz, - })}{" "} - –{" "} - {current.endAt.toLocaleTimeString("en-GB", { - hour: "2-digit", - minute: "2-digit", - timeZone: tz, - })} -
-{totalDuration} min
-£{totalPrice.toFixed(2)}
-{current.notes}
-- {n.author.user.name ?? "Staff"}: {n.body} -
- ))} +{current.services.map((s) => s.service.name).join(" + ")}
++ {current.startAt.toLocaleTimeString("en-GB", { + hour: "2-digit", + minute: "2-digit", + timeZone: tz, + })}{" "} + –{" "} + {current.endAt.toLocaleTimeString("en-GB", { + hour: "2-digit", + minute: "2-digit", + timeZone: tz, + })} +
+{totalDuration} min
+£{totalPrice.toFixed(2)}
- {loyalty.currentStamps}/{resolveRewardThreshold(loyalty)} stamps - {loyalty.availableReward && ( - · Reward available - )} -
-{current.notes}
+- {lastCompleted.services.map((s) => s.service.name).join(" + ")} ·{" "} - {lastCompleted.startAt.toLocaleDateString("en-GB", { - day: "numeric", - month: "short", - year: "numeric", - timeZone: tz, - })} -
-+ {n.author.user.name ?? "Staff"}: {n.body} +
+ ))} ++ {loyalty.currentStamps}/{resolveRewardThreshold(loyalty)} stamps + {loyalty.availableReward && ( + · Reward available + )} +
++ {lastCompleted.services.map((s) => s.service.name).join(" + ")} ·{" "} + {lastCompleted.startAt.toLocaleDateString("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + timeZone: tz, + })} +
+App
-Get the app
-App
+Get the app
+All Staff
+Everyone
+ + + + {options.map((option) => ( + + {option.avatarUrl ? ( +{option.name}
+{option.hoursLabel ?? "Off today"}
+ + + ))} +{selectedDate === today ? "Today" : label}
{selectedDate === today &&{label}
} {selectedDate !== today && ( @@ -65,7 +65,7 @@ export function DateStrip({ diff --git a/src/features/staff/components/day-calendar.tsx b/src/features/staff/components/day-calendar.tsx index bd2c5fa..8baa6d1 100644 --- a/src/features/staff/components/day-calendar.tsx +++ b/src/features/staff/components/day-calendar.tsx @@ -29,6 +29,14 @@ function formatCompactTime(minutes: number): string { const displayHour = h % 12 === 0 ? 12 : h % 12; return `${displayHour}:${String(m).padStart(2, "0")}`; } +/** "2:47 pm" — the current-time indicator's own label, read out next to its dot on the axis. */ +function formatCurrentTimeLabel(minutes: number): string { + const h = Math.floor(minutes / MINUTES_PER_HOUR); + const m = minutes % MINUTES_PER_HOUR; + const period = h >= 12 ? "pm" : "am"; + const displayHour = h % 12 === 0 ? 12 : h % 12; + return `${displayHour}:${String(m).padStart(2, "0")} ${period}`; +} export interface CalendarAppointment { id: string; @@ -104,7 +112,7 @@ export function DayCalendar({ currentTimeMinutes !== null && currentTimeMinutes >= RANGE_START_MINUTES && currentTimeMinutes <= RANGE_END_MINUTES; return ( -