diff --git a/e2e/customer-journeys.spec.ts b/e2e/customer-journeys.spec.ts index 15f341e..e76acb1 100644 --- a/e2e/customer-journeys.spec.ts +++ b/e2e/customer-journeys.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from "@playwright/test"; +import { IN_APP_NOTIFICATIONS_VISIBLE } from "../src/services/notifications/in-app"; import { SEEDED, completeBookingFlow, completeRescheduleFlow, findAppointmentCard, signIn, uniqueEmail } from "./helpers"; /** @@ -166,6 +167,8 @@ test.describe("customer booking journeys", () => { }); test("7. booking confirmation creates an in-app notification the customer can read and mark read", async ({ page }) => { + test.skip(!IN_APP_NOTIFICATIONS_VISIBLE, "In-app notifications are hidden — see src/features/notifications/in-app.ts"); + await signIn(page, SEEDED.registeredCustomer.email); await page.goto("/book"); diff --git a/e2e/staff-journeys.spec.ts b/e2e/staff-journeys.spec.ts index ca06cf5..439186a 100644 --- a/e2e/staff-journeys.spec.ts +++ b/e2e/staff-journeys.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from "@playwright/test"; import { createPool } from "mariadb"; import { buildMysqlPoolConfig } from "../src/lib/db/mysql-pool-config"; +import { IN_APP_NOTIFICATIONS_VISIBLE } from "../src/services/notifications/in-app"; import { SEEDED, completeStaffBookingFlow, signIn } from "./helpers"; /** @@ -240,6 +241,8 @@ test.describe.serial("barber operations journeys", () => { }); test("10. barber receives a new-booking notification", async ({ page }) => { + test.skip(!IN_APP_NOTIFICATIONS_VISIBLE, "In-app notifications are hidden — see src/features/notifications/in-app.ts"); + // Deliberately books its own fresh appointment rather than relying on journey 3's — every // other independent journey in this file follows that same "book a fresh appointment first" // rule (see this file's header comment), and this one was the exception, which made it fail diff --git a/public/sw.js b/public/sw.js index 05f5371..1ae84d1 100644 --- a/public/sw.js +++ b/public/sw.js @@ -5,8 +5,8 @@ // balances) — caching that would risk serving stale or, worse, another // user's data. So this file does NOT try to make the booking flow work // offline. What it does do: -// - Push notifications (`push`/`notificationclick` — unchanged from the -// original Phase 7 push-notifications work). +// - Push notifications (`push`/`notificationclick`), plus an app-icon +// badge number counting the ones still unread (see updateAppBadge). // - Precache + serve a small static app shell (offline fallback page, // manifest, icons) so a lost connection shows a real "you're offline" // screen instead of the browser's own dinosaur/error page. @@ -92,6 +92,34 @@ self.addEventListener("message", (event) => { if (event.data?.type === "SKIP_WAITING") self.skipWaiting(); }); +/** + * App-icon badge = how many of this app's push notifications are still + * sitting in the notification shelf. Counting what the OS is actually + * showing (instead of a stored counter) means nothing needs persisting — + * a service worker can be stopped at any moment, losing in-memory state. + * The page clears the badge and the shelf when the app is opened (see + * service-worker-manager.tsx). + * + * Needs the Badging API: installed PWAs on Android/desktop Chrome and + * Home Screen web apps on iOS 16.4+. Everywhere else this is a no-op. + */ +async function updateAppBadge() { + if (!("setAppBadge" in self.navigator)) return; + try { + const count = (await self.registration.getNotifications()).length; + if (count > 0) await self.navigator.setAppBadge(count); + else await self.navigator.clearAppBadge(); + } catch { + // Badging is cosmetic — never let it break showing the notification. + } +} + +/** True when the app is open and in front of the person — a badge on the icon is pointless (and stale the moment they leave) then. */ +async function appIsInForeground() { + const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true }); + return windows.some((client) => client.visibilityState === "visible"); +} + self.addEventListener("push", (event) => { if (!event.data) return; @@ -114,7 +142,13 @@ self.addEventListener("push", (event) => { badge: "/icons/icon-192.png", }; - event.waitUntil(self.registration.showNotification(title, options)); + event.waitUntil( + self.registration + .showNotification(title, options) + .then(async () => { + if (!(await appIsInForeground())) await updateAppBadge(); + }), + ); }); self.addEventListener("notificationclick", (event) => { @@ -122,11 +156,14 @@ self.addEventListener("notificationclick", (event) => { const url = event.notification.data?.url || "/"; event.waitUntil( - self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clientList) => { - for (const client of clientList) { - if (client.url.includes(url) && "focus" in client) return client.focus(); - } - if (self.clients.openWindow) return self.clients.openWindow(url); - }), + Promise.all([ + updateAppBadge(), + self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clientList) => { + for (const client of clientList) { + if (client.url.includes(url) && "focus" in client) return client.focus(); + } + if (self.clients.openWindow) return self.clients.openWindow(url); + }), + ]), ); }); diff --git a/src/app/(customer)/notifications/page.tsx b/src/app/(customer)/notifications/page.tsx index 1d82a43..ea23c7c 100644 --- a/src/app/(customer)/notifications/page.tsx +++ b/src/app/(customer)/notifications/page.tsx @@ -1,16 +1,20 @@ import type { Metadata } from "next"; import Link from "next/link"; +import { notFound } from "next/navigation"; 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 { IN_APP_NOTIFICATIONS_VISIBLE } from "@/services/notifications/in-app"; import { NotificationList } from "@/features/notifications/components/notification-list"; export const metadata: Metadata = { title: "Notifications" }; export const dynamic = "force-dynamic"; export default async function CustomerNotificationsPage() { + if (!IN_APP_NOTIFICATIONS_VISIBLE) notFound(); + const session = await auth(); if (!session?.user) { diff --git a/src/app/(owner)/owner/notifications/page.tsx b/src/app/(owner)/owner/notifications/page.tsx index 131bc97..310fa2f 100644 --- a/src/app/(owner)/owner/notifications/page.tsx +++ b/src/app/(owner)/owner/notifications/page.tsx @@ -1,9 +1,10 @@ import type { Metadata } from "next"; -import { redirect } from "next/navigation"; +import { notFound, redirect } from "next/navigation"; import { TopNav } from "@/components/navigation/top-nav"; import { prisma } from "@/lib/db/prisma"; import { getActingBusinessContext } from "@/lib/business/get-acting-business-context"; import { listInAppNotifications } from "@/services/notifications/notification.service"; +import { IN_APP_NOTIFICATIONS_VISIBLE } from "@/services/notifications/in-app"; import { NotificationList } from "@/features/notifications/components/notification-list"; export const metadata: Metadata = { title: "Notifications" }; @@ -11,6 +12,8 @@ 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() { + if (!IN_APP_NOTIFICATIONS_VISIBLE) notFound(); + const context = await getActingBusinessContext(); if (!context) redirect("/home"); diff --git a/src/app/(owner)/owner/settings/business/page.tsx b/src/app/(owner)/owner/settings/business/page.tsx index 3fb0d3f..e19d4b2 100644 --- a/src/app/(owner)/owner/settings/business/page.tsx +++ b/src/app/(owner)/owner/settings/business/page.tsx @@ -53,9 +53,6 @@ export default async function OwnerBusinessDetailsPage() { postalCode: location?.postalCode ?? "", country: location?.country ?? "GB", cancellationPolicy: business.settings?.cancellationPolicy ?? "", - lateArrivalPolicy: business.settings?.lateArrivalPolicy ?? "", - bookingTerms: business.settings?.bookingTerms ?? "", - privacyPolicyUrl: business.settings?.privacyPolicyUrl ?? "", legalNameDisplay: business.settings?.legalNameDisplay ?? "NEXTCHAIR", legalContactEmail: business.settings?.legalContactEmail ?? "NEXTCHAIR", }} diff --git a/src/app/(staff)/staff/notifications/page.tsx b/src/app/(staff)/staff/notifications/page.tsx index c38b145..a18b16c 100644 --- a/src/app/(staff)/staff/notifications/page.tsx +++ b/src/app/(staff)/staff/notifications/page.tsx @@ -1,15 +1,18 @@ import type { Metadata } from "next"; -import { redirect } from "next/navigation"; +import { notFound, 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 { IN_APP_NOTIFICATIONS_VISIBLE } from "@/services/notifications/in-app"; import { NotificationList } from "@/features/notifications/components/notification-list"; export const metadata: Metadata = { title: "Notifications" }; export const dynamic = "force-dynamic"; export default async function StaffNotificationsPage() { + if (!IN_APP_NOTIFICATIONS_VISIBLE) notFound(); + const staff = await getActingStaff(); if (!staff) redirect("/home"); diff --git a/src/features/notifications/components/notification-bell.tsx b/src/features/notifications/components/notification-bell.tsx index ff2a40f..96f74b9 100644 --- a/src/features/notifications/components/notification-bell.tsx +++ b/src/features/notifications/components/notification-bell.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { Bell } from "lucide-react"; import { usePathname } from "next/navigation"; +import { IN_APP_NOTIFICATIONS_VISIBLE } from "@/services/notifications/in-app"; /** Pages that render their own inline bell (see InlineNotificationBell) as part of a proper header — the shell's own floating bell steps aside there instead of sitting on top of it. */ const PAGES_WITH_INLINE_BELL = [ @@ -41,7 +42,7 @@ function badge(unreadCount: number) { */ export function NotificationBell({ href, unreadCount }: { href: string; unreadCount: number }) { const pathname = usePathname(); - if (PAGES_WITH_INLINE_BELL.includes(pathname)) return null; + if (!IN_APP_NOTIFICATIONS_VISIBLE || PAGES_WITH_INLINE_BELL.includes(pathname)) return null; return ( set("cancellationPolicy", e.target.value)} /> -