Compare commits
2 Commits
main
...
bfe3ccf3b2
| Author | SHA1 | Date | |
|---|---|---|---|
| bfe3ccf3b2 | |||
| b52b8e10ad |
+21
-1
@@ -217,6 +217,25 @@ export const { signIn, signOut, useSession, changePassword } = authClient;
|
|||||||
| TC-WEB-5.12.16 | Badge status from data | Compare badge label to appointment.status field | Badge label matches the API appointment status exactly |
|
| TC-WEB-5.12.16 | Badge status from data | Compare badge label to appointment.status field | Badge label matches the API appointment status exactly |
|
||||||
| TC-WEB-5.12.17 | Unknown status fallback | Render badge with unknown status value | Badge renders with the raw status string as label and fallback CSS class |
|
| TC-WEB-5.12.17 | Unknown status fallback | Render badge with unknown status value | Badge renders with the raw status string as label and fallback CSS class |
|
||||||
|
|
||||||
|
#### 5.12f Live StatusBadge palette — no-show / pending / waitlisted (GRO-2319)
|
||||||
|
|
||||||
|
These cases exercise the full StatusBadge palette as it is now produced live by
|
||||||
|
the seeded UAT customer (`uat-customer@groombook.dev`), not just unit-rendered.
|
||||||
|
|
||||||
|
| # | Scenario | Steps | Expected |
|
||||||
|
|---|----------|-------|----------|
|
||||||
|
| TC-WEB-5.12.26 | No-show badge (item 1) | Sign in as `uat-customer@groombook.dev`, open `Appointments` → **Past** tab, find the seeded `no_show` appointment | A styled yellow **"No-show"** badge renders (`bg-yellow-100 text-yellow-700`) — **not** a raw gray `no_show` label. The DB `no_show` (underscore) status is normalized to the `no-show` palette key. |
|
||||||
|
| TC-WEB-5.12.27 | Pending derivation (item 2) | On the **Upcoming** tab, find the seeded upcoming appointment whose `confirmationStatus` is `pending` (groomer-unconfirmed) | The card's top-row badge reads amber **"Pending"** (derived from `confirmationStatus`), even though the raw appointment status is `scheduled`. |
|
||||||
|
| TC-WEB-5.12.28 | Confirmed not overridden | On the **Upcoming** tab, find the seeded confirmed appointment (`confirmationStatus = confirmed`) | Badge still reads green **"Confirmed"** — the pending derivation does not override a confirmed appointment. |
|
||||||
|
| TC-WEB-5.12.29 | Waitlisted card (item 2) | On the **Upcoming** tab, find the seeded waitlist entry for the customer | A card renders with a blue **"Waitlisted"** badge, a **dashed muted border**, and the subtext _"You're on the waitlist — we'll let you know if a spot opens."_ The Confirm / Reschedule / Cancel / Notes actions are **not** shown for this entry (it is not a booked appointment). |
|
||||||
|
|
||||||
|
> **GRO-2319 note:** the DB `appointment_status` enum cannot represent `pending`
|
||||||
|
> or `waitlisted`, so those badges are derived in the portal: `pending` from an
|
||||||
|
> upcoming appointment's `confirmationStatus`, and `waitlisted` from active
|
||||||
|
> `waitlist_entries` surfaced by `GET /api/portal/appointments` as synthetic
|
||||||
|
> cards. The `no_show` → `no-show` key normalization fixes the cosmetic badge
|
||||||
|
> mismatch (item 1).
|
||||||
|
|
||||||
#### 5.12d Appointment API Shape Normalization (GRO-2180)
|
#### 5.12d Appointment API Shape Normalization (GRO-2180)
|
||||||
|
|
||||||
| # | Scenario | Steps | Expected |
|
| # | Scenario | Steps | Expected |
|
||||||
@@ -428,7 +447,8 @@ These cases cover the `CustomerPortal` initialisation path that bridges an Authe
|
|||||||
| TC-WEB-5.25.3 | Subsequent portal calls use the bridged session ID | After TC-WEB-5.25.1 succeeds, navigate to **Appointments**, **My Pets**, **Billing**, **Settings**. Inspect any `/api/portal/*` request in DevTools → Network. | Each portal API call carries an `X-Impersonation-Session-Id` header whose value equals the `sessionId` returned by `session-from-auth` (not a URL-param value). Each call returns 200 (or 404 for genuinely empty collections), never 401. |
|
| TC-WEB-5.25.3 | Subsequent portal calls use the bridged session ID | After TC-WEB-5.25.1 succeeds, navigate to **Appointments**, **My Pets**, **Billing**, **Settings**. Inspect any `/api/portal/*` request in DevTools → Network. | Each portal API call carries an `X-Impersonation-Session-Id` header whose value equals the `sessionId` returned by `session-from-auth` (not a URL-param value). Each call returns 200 (or 404 for genuinely empty collections), never 401. |
|
||||||
| TC-WEB-5.25.4 | No impersonation chrome for the customer's own session | After TC-WEB-5.25.1, scan the portal UI. | No amber border around the page. No "STAFF VIEW" watermark. No "End Impersonation" button in the sidebar. The customer is themselves; only impersonation sessions started via `?sessionId=` show the banner. |
|
| TC-WEB-5.25.4 | No impersonation chrome for the customer's own session | After TC-WEB-5.25.1, scan the portal UI. | No amber border around the page. No "STAFF VIEW" watermark. No "End Impersonation" button in the sidebar. The customer is themselves; only impersonation sessions started via `?sessionId=` show the banner. |
|
||||||
| TC-WEB-5.25.5 | 404 fallback for authenticated user with no client record | 1. Sign in via SSO with an Authentik account whose email is **not** present in `clients`. 2. Land on `/`. | `POST /api/portal/session-from-auth` returns 404. The portal renders a centred card titled **"Portal access not configured"** with the message about contacting the groomer and a **Sign out** button. No redirect loop, no portal chrome. |
|
| TC-WEB-5.25.5 | 404 fallback for authenticated user with no client record | 1. Sign in via SSO with an Authentik account whose email is **not** present in `clients`. 2. Land on `/`. | `POST /api/portal/session-from-auth` returns 404. The portal renders a centred card titled **"Portal access not configured"** with the message about contacting the groomer and a **Sign out** button. No redirect loop, no portal chrome. |
|
||||||
| TC-WEB-5.25.6 | 404 fallback Sign-out escape hatch | From TC-WEB-5.25.5 click **Sign out**. | `POST /api/auth/sign-out` fires; browser navigates to `/login`; the Authentik session cookie is cleared. Reloading `/` no longer hits 404 (will show the login page). |
|
| TC-WEB-5.25.6 | 404 fallback Sign-out escape hatch (GRO-2358) | From TC-WEB-5.25.5 click **Sign out**. | The shared `signOut()` from `lib/auth-client` fires (same handler as `AdminLayout`); browser navigates to `/login`; the Authentik session cookie is cleared. Reloading `/` no longer hits 404 (will show the login page). The handler always navigates to `/login` — even if the network call to `/api/auth/sign-out` fails — so a transient auth-server hiccup never leaves the user trapped on an authenticated screen. |
|
||||||
|
| TC-WEB-5.25.6b | 404 fallback Sign-out on deep-link (GRO-2358) | From TC-WEB-5.25.5, instead of staying on `/`, navigate directly to a portal sub-route (e.g. `/appointments`, `/pets`, `/billing`). The no-access card renders. Click **Sign out**. | The same shared `signOut()` handler fires and the browser navigates to `/login`. The no-access screen must surface an escape hatch on every authenticated route — not just `/` — so a stale or deep link into a portal the user has no access to can never trap them. |
|
||||||
| TC-WEB-5.25.7 | Bridge precedence — impersonation URL wins | 1. Sign in via SSO as a customer. 2. Open a new tab to `https://uat.groombook.dev/?sessionId=<a-valid-staff-impersonation-session-id>`. | The impersonation path runs; the amber banner appears for the impersonated client. The Better Auth bridge is **not** called on this load (`session-from-auth` absent in Network). |
|
| TC-WEB-5.25.7 | Bridge precedence — impersonation URL wins | 1. Sign in via SSO as a customer. 2. Open a new tab to `https://uat.groombook.dev/?sessionId=<a-valid-staff-impersonation-session-id>`. | The impersonation path runs; the amber banner appears for the impersonated client. The Better Auth bridge is **not** called on this load (`session-from-auth` absent in Network). |
|
||||||
| TC-WEB-5.25.8 | Bridge precedence — dev user wins | In dev mode (e.g. local) with `localStorage["dev-user"]` set to a client persona, navigate to `/`. | The dev-session path runs (`POST /api/portal/dev-session`). The Better Auth bridge is **not** called (`session-from-auth` absent in Network). Staff dev users still redirect to `/admin`. |
|
| TC-WEB-5.25.8 | Bridge precedence — dev user wins | In dev mode (e.g. local) with `localStorage["dev-user"]` set to a client persona, navigate to `/`. | The dev-session path runs (`POST /api/portal/dev-session`). The Better Auth bridge is **not** called (`session-from-auth` absent in Network). Staff dev users still redirect to `/admin`. |
|
||||||
| TC-WEB-5.25.9 | Staff Better Auth session does not run the customer bridge | Sign in via SSO with a staff identity. Navigate to `/`. | `App.tsx` routing redirects to `/admin`. `POST /api/portal/session-from-auth` is **not** called. |
|
| TC-WEB-5.25.9 | Staff Better Auth session does not run the customer bridge | Sign in via SSO with a staff identity. Navigate to `/`. | `App.tsx` routing redirects to `/admin`. `POST /api/portal/session-from-auth` is **not** called. |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
import { parseTimeTo24Hour, isUpcoming, normalizeAppointment, normalizeService, formatServicePrice, CustomerNotesSection, ConfirmationSection, StatusBadge, formatSlotLabel, slotToTime, BookingFlow } from "../portal/sections/Appointments.tsx";
|
import { parseTimeTo24Hour, isUpcoming, normalizeAppointment, normalizeService, formatServicePrice, CustomerNotesSection, ConfirmationSection, StatusBadge, normalizeStatusKey, deriveDisplayStatus, formatSlotLabel, slotToTime, BookingFlow } from "../portal/sections/Appointments.tsx";
|
||||||
|
|
||||||
const UPCOMING_APPT = {
|
const UPCOMING_APPT = {
|
||||||
id: "appt-1",
|
id: "appt-1",
|
||||||
@@ -517,6 +517,52 @@ describe("StatusBadge", () => {
|
|||||||
expect(badge?.className).toContain("bg-stone-100");
|
expect(badge?.className).toContain("bg-stone-100");
|
||||||
expect(badge?.className).toContain("text-stone-600");
|
expect(badge?.className).toContain("text-stone-600");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GRO-2319 item 1: DB stores `no_show` (underscore) but the palette key is
|
||||||
|
// `no-show` (hyphen) — without normalization it rendered raw gray text.
|
||||||
|
it("renders the styled No-show badge for DB `no_show` status", () => {
|
||||||
|
render(<StatusBadge status="no_show" />);
|
||||||
|
const badge = screen.getByText("No-show").closest('span');
|
||||||
|
expect(badge?.className).toContain("bg-yellow-100");
|
||||||
|
expect(badge?.className).toContain("text-yellow-700");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("normalizeStatusKey (GRO-2319 item 1)", () => {
|
||||||
|
it("maps underscore status keys to the hyphen palette key", () => {
|
||||||
|
expect(normalizeStatusKey("no_show")).toBe("no-show");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves already-hyphenated / single-word keys unchanged", () => {
|
||||||
|
expect(normalizeStatusKey("no-show")).toBe("no-show");
|
||||||
|
expect(normalizeStatusKey("confirmed")).toBe("confirmed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deriveDisplayStatus (GRO-2319 item 2)", () => {
|
||||||
|
it("derives Pending for an upcoming, unconfirmed appointment", () => {
|
||||||
|
expect(
|
||||||
|
deriveDisplayStatus({ ...UPCOMING_APPT, status: "scheduled", confirmationStatus: "pending" }),
|
||||||
|
).toBe("pending");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the raw status when the appointment is confirmed", () => {
|
||||||
|
expect(
|
||||||
|
deriveDisplayStatus({ ...UPCOMING_APPT, status: "confirmed", confirmationStatus: "confirmed" }),
|
||||||
|
).toBe("confirmed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not derive Pending for a past appointment", () => {
|
||||||
|
expect(
|
||||||
|
deriveDisplayStatus({ ...PAST_APPT, status: "completed", confirmationStatus: "pending" }),
|
||||||
|
).toBe("completed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("always shows Waitlisted for a waitlist-backed entry", () => {
|
||||||
|
expect(
|
||||||
|
deriveDisplayStatus({ ...UPCOMING_APPT, status: "waitlisted", confirmationStatus: undefined }),
|
||||||
|
).toBe("waitlisted");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("RescheduleFlow dynamic time slots", () => {
|
describe("RescheduleFlow dynamic time slots", () => {
|
||||||
|
|||||||
@@ -21,6 +21,18 @@ vi.mock("../portal/sections/Appointments.js", async () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Spy on the canonical `signOut()` from the shared auth-client so we can
|
||||||
|
// assert the no-access screen's logout button uses the SAME handler as
|
||||||
|
// `AdminLayout`. We mock at the module boundary — the no-access screen is
|
||||||
|
// the one authenticated surface that renders without the portal chrome, so
|
||||||
|
// a regression here would trap the user. We do NOT use `importActual`
|
||||||
|
// because the real `createAuthClient()` requires a runtime `baseURL`
|
||||||
|
// (Better Auth) that the JSDOM test environment can't supply.
|
||||||
|
const signOutSpy = vi.hoisted(() => vi.fn(async () => undefined));
|
||||||
|
vi.mock("../lib/auth-client.js", () => ({
|
||||||
|
signOut: signOutSpy,
|
||||||
|
}));
|
||||||
|
|
||||||
const SESSION: ImpersonationSession = {
|
const SESSION: ImpersonationSession = {
|
||||||
id: "sess-1",
|
id: "sess-1",
|
||||||
staffId: "staff-1",
|
staffId: "staff-1",
|
||||||
@@ -336,6 +348,8 @@ describe("CustomerPortal SSO bridge", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Make sure no dev-user leaks across tests
|
// Make sure no dev-user leaks across tests
|
||||||
window.localStorage.clear();
|
window.localStorage.clear();
|
||||||
|
// Reset shared signOut() spy so per-test counts are deterministic
|
||||||
|
signOutSpy.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
const brandingResponse = {
|
const brandingResponse = {
|
||||||
@@ -429,6 +443,132 @@ describe("CustomerPortal SSO bridge", () => {
|
|||||||
expect(screen.getByRole("button", { name: /Sign out/i })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: /Sign out/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("calls the shared signOut() handler and navigates to /login from the no-access screen (GRO-2358)", async () => {
|
||||||
|
// Reset the spy so previous tests don't leak into this assertion.
|
||||||
|
signOutSpy.mockClear();
|
||||||
|
|
||||||
|
// JSDOM throws on window.location.href assignment by default; swap in a
|
||||||
|
// writable stub so the navigation is observable, then restore after.
|
||||||
|
const originalLocation = window.location;
|
||||||
|
Object.defineProperty(window, "location", {
|
||||||
|
value: { href: "" },
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
global.fetch = vi.fn((input: RequestInfo) => {
|
||||||
|
const url = typeof input === "string" ? input : input.toString();
|
||||||
|
if (url === "/api/branding") return Promise.resolve(brandingResponse);
|
||||||
|
if (url === "/api/auth/get-session") {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ user: { email: "stranger@example.com", role: "customer" } }),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
if (url === "/api/portal/session-from-auth") {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
json: async () => ({ error: "No client record found for this user" }),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={["/"]}>
|
||||||
|
<CustomerPortal />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/Portal access not configured/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pre-condition: the shared signOut() must NOT have been called yet — the
|
||||||
|
// no-access screen is mounted because the bridge failed, not because the
|
||||||
|
// user clicked anything.
|
||||||
|
expect(signOutSpy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Drive the click. The handler is the SAME `signOut()` exported from
|
||||||
|
// auth-client that AdminLayout uses, so verifying this call is enough to
|
||||||
|
// prove the no-access screen reaches the canonical sign-out surface.
|
||||||
|
const signOutButton = screen.getByRole("button", { name: /Sign out/i });
|
||||||
|
fireEvent.click(signOutButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(signOutSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The handler always navigates to /login — even if the network call to
|
||||||
|
// /api/auth/sign-out fails — so a transient auth-server hiccup never
|
||||||
|
// leaves the user trapped on an authenticated screen.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(window.location.href).toBe("/login");
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(window, "location", { value: originalLocation, configurable: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reaches the same shared signOut() on a deep-link no-access screen (GRO-2358)", async () => {
|
||||||
|
// AC requires verifying the SAME logout handler is reachable from at
|
||||||
|
// least one other authenticated surface — here a deep link to a portal
|
||||||
|
// sub-route (e.g. /appointments) for a user with a Better Auth session
|
||||||
|
// but no client record. The no-access screen is the only authenticated
|
||||||
|
// surface without a route guard, so the handler must fire identically.
|
||||||
|
signOutSpy.mockClear();
|
||||||
|
|
||||||
|
const originalLocation = window.location;
|
||||||
|
Object.defineProperty(window, "location", {
|
||||||
|
value: { href: "" },
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
global.fetch = vi.fn((input: RequestInfo) => {
|
||||||
|
const url = typeof input === "string" ? input : input.toString();
|
||||||
|
if (url === "/api/branding") return Promise.resolve(brandingResponse);
|
||||||
|
if (url === "/api/auth/get-session") {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ user: { email: "stranger@example.com", role: "customer" } }),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
if (url === "/api/portal/session-from-auth") {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
json: async () => ({ error: "No client record found for this user" }),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={["/appointments"]}>
|
||||||
|
<CustomerPortal />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/Portal access not configured/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const signOutButton = screen.getByRole("button", { name: /Sign out/i });
|
||||||
|
fireEvent.click(signOutButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(signOutSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(window.location.href).toBe("/login");
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(window, "location", { value: originalLocation, configurable: true });
|
||||||
|
});
|
||||||
|
|
||||||
it("does not call session-from-auth when there is no Better Auth session", async () => {
|
it("does not call session-from-auth when there is no Better Auth session", async () => {
|
||||||
global.fetch = vi.fn((input: RequestInfo) => {
|
global.fetch = vi.fn((input: RequestInfo) => {
|
||||||
const url = typeof input === "string" ? input : input.toString();
|
const url = typeof input === "string" ? input : input.toString();
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { ImpersonationBanner } from "./ImpersonationBanner.js";
|
|||||||
import { AuditLogViewer } from "./AuditLogViewer.js";
|
import { AuditLogViewer } from "./AuditLogViewer.js";
|
||||||
import { useBranding } from "../BrandingContext.js";
|
import { useBranding } from "../BrandingContext.js";
|
||||||
import { getDevUser } from "../pages/DevLoginSelector.js";
|
import { getDevUser } from "../pages/DevLoginSelector.js";
|
||||||
|
import { signOut } from "../lib/auth-client.js";
|
||||||
import type { ImpersonationSession } from "@groombook/types";
|
import type { ImpersonationSession } from "@groombook/types";
|
||||||
import type { Appointment as PortalAppointment } from "./sections/Appointments.js";
|
import type { Appointment as PortalAppointment } from "./sections/Appointments.js";
|
||||||
|
|
||||||
@@ -193,6 +194,19 @@ export function CustomerPortal() {
|
|||||||
}
|
}
|
||||||
}, [session]);
|
}, [session]);
|
||||||
|
|
||||||
|
// Shared sign-out handler — wires the canonical Better Auth `signOut()` so
|
||||||
|
// every authenticated surface (no-access screen, portal chrome, etc.) uses
|
||||||
|
// the same implementation as `AdminLayout`. Failure to reach the server
|
||||||
|
// still leaves the SPA free to navigate to /login.
|
||||||
|
const handleSignOut = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await signOut();
|
||||||
|
} catch {
|
||||||
|
// Best-effort; navigate to /login regardless so the user is never trapped.
|
||||||
|
}
|
||||||
|
window.location.href = "/login";
|
||||||
|
}, []);
|
||||||
|
|
||||||
const logPageView = useCallback((page: string) => {
|
const logPageView = useCallback((page: string) => {
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
void fetch(`/api/impersonation/sessions/${session.id}/log`, {
|
void fetch(`/api/impersonation/sessions/${session.id}/log`, {
|
||||||
@@ -281,14 +295,7 @@ export function CustomerPortal() {
|
|||||||
<h1 className="text-lg font-semibold text-stone-800 mb-2">Portal access not configured</h1>
|
<h1 className="text-lg font-semibold text-stone-800 mb-2">Portal access not configured</h1>
|
||||||
<p className="text-sm text-stone-600 mb-6">{authError}</p>
|
<p className="text-sm text-stone-600 mb-6">{authError}</p>
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={() => { void handleSignOut(); }}
|
||||||
try {
|
|
||||||
await fetch("/api/auth/sign-out", { method: "POST", credentials: "include" });
|
|
||||||
} catch {
|
|
||||||
// Best-effort sign-out; redirect to /login regardless.
|
|
||||||
}
|
|
||||||
window.location.href = "/login";
|
|
||||||
}}
|
|
||||||
className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-stone-700 bg-stone-100 hover:bg-stone-200 transition-colors"
|
className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-stone-700 bg-stone-100 hover:bg-stone-200 transition-colors"
|
||||||
>
|
>
|
||||||
<LogOut size={14} />
|
<LogOut size={14} />
|
||||||
|
|||||||
@@ -315,9 +315,19 @@ const STATUS_LABELS: Record<string, string> = {
|
|||||||
scheduled: 'Scheduled',
|
scheduled: 'Scheduled',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The DB `appointment_status` enum stores `no_show` (underscore), but the badge
|
||||||
|
// palette is keyed on `no-show` (hyphen). Without normalization a no-show
|
||||||
|
// appointment renders as a raw gray `no_show` label instead of the styled
|
||||||
|
// "No-show" badge (GRO-2319 item 1). Map underscore status keys to the hyphen
|
||||||
|
// palette key so DB-sourced statuses resolve to their intended badge style.
|
||||||
|
export function normalizeStatusKey(status: string): string {
|
||||||
|
return status.replace(/_/g, '-');
|
||||||
|
}
|
||||||
|
|
||||||
export function StatusBadge({ status }: { status: string }) {
|
export function StatusBadge({ status }: { status: string }) {
|
||||||
const label = STATUS_LABELS[status] ?? status;
|
const key = normalizeStatusKey(status);
|
||||||
const colorClass = STATUS_COLORS[status] ?? 'bg-stone-100 text-stone-600';
|
const label = STATUS_LABELS[key] ?? status;
|
||||||
|
const colorClass = STATUS_COLORS[key] ?? 'bg-stone-100 text-stone-600';
|
||||||
return (
|
return (
|
||||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${colorClass}`}>
|
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${colorClass}`}>
|
||||||
{label}
|
{label}
|
||||||
@@ -325,6 +335,19 @@ export function StatusBadge({ status }: { status: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Derives the badge state shown on an Upcoming/Past card from the appointment's
|
||||||
|
// raw status plus its confirmationStatus (GRO-2319 item 2, CMPO-approved):
|
||||||
|
// - a synthetic waitlist entry (status `waitlisted`) always shows Waitlisted
|
||||||
|
// - an upcoming appointment the groomer has not yet confirmed
|
||||||
|
// (`confirmationStatus === 'pending'`) shows Pending — semantically honest
|
||||||
|
// and reduces anxiety-driven follow-up messages
|
||||||
|
// - otherwise the raw status drives the badge
|
||||||
|
export function deriveDisplayStatus(appt: Appointment): string {
|
||||||
|
if (appt.status === 'waitlisted') return 'waitlisted';
|
||||||
|
if (isUpcoming(appt) && appt.confirmationStatus === 'pending') return 'pending';
|
||||||
|
return appt.status;
|
||||||
|
}
|
||||||
|
|
||||||
const CONFIRMATION_STATUS_COLORS: Record<string, string> = {
|
const CONFIRMATION_STATUS_COLORS: Record<string, string> = {
|
||||||
confirmed: 'bg-green-100 text-green-700',
|
confirmed: 'bg-green-100 text-green-700',
|
||||||
pending: 'bg-amber-100 text-amber-700',
|
pending: 'bg-amber-100 text-amber-700',
|
||||||
@@ -508,11 +531,24 @@ function AppointmentCard({
|
|||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
onReschedule: (appt: Appointment) => void;
|
onReschedule: (appt: Appointment) => void;
|
||||||
}) {
|
}) {
|
||||||
|
// A waitlist-backed entry (GRO-2319 item 2, CMPO UX spec GRO-2328) is not a
|
||||||
|
// confirmed appointment: it gets a muted, dashed-border card and a subtext
|
||||||
|
// line so the customer can tell it apart from booked appointments, and the
|
||||||
|
// appointment-only actions (confirm / notes / reschedule / cancel) are hidden.
|
||||||
|
const isWaitlist = appt.status === 'waitlisted';
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-xl border border-stone-200 shadow-sm overflow-hidden">
|
<div
|
||||||
|
className={
|
||||||
|
isWaitlist
|
||||||
|
? 'bg-stone-50/60 rounded-xl border border-dashed border-stone-300 shadow-sm overflow-hidden'
|
||||||
|
: 'bg-white rounded-xl border border-stone-200 shadow-sm overflow-hidden'
|
||||||
|
}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
className="w-full flex items-center gap-4 p-4 text-left hover:bg-stone-50"
|
className={`w-full flex items-center gap-4 p-4 text-left ${
|
||||||
|
isWaitlist ? 'hover:bg-stone-100/60' : 'hover:bg-stone-50'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<div className="w-10 h-10 rounded-lg bg-blue-100 flex items-center justify-center text-lg shrink-0">
|
<div className="w-10 h-10 rounded-lg bg-blue-100 flex items-center justify-center text-lg shrink-0">
|
||||||
{appt.petName?.charAt(0) || 'P'}
|
{appt.petName?.charAt(0) || 'P'}
|
||||||
@@ -532,8 +568,13 @@ function AppointmentCard({
|
|||||||
</span>
|
</span>
|
||||||
<span>with {appt.groomerName || 'First Available'}</span>
|
<span>with {appt.groomerName || 'First Available'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{isWaitlist && (
|
||||||
|
<p className="text-xs text-stone-400 mt-1">
|
||||||
|
You're on the waitlist — we'll let you know if a spot opens.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge status={appt.status} />
|
<StatusBadge status={deriveDisplayStatus(appt)} />
|
||||||
{expanded ? (
|
{expanded ? (
|
||||||
<ChevronDown size={16} className="text-stone-400" />
|
<ChevronDown size={16} className="text-stone-400" />
|
||||||
) : (
|
) : (
|
||||||
@@ -567,11 +608,14 @@ function AppointmentCard({
|
|||||||
{appt.notes}
|
{appt.notes}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{isUpcoming(appt) && !readOnly && (
|
{!isWaitlist && isUpcoming(appt) && !readOnly && (
|
||||||
<CustomerNotesSection appointment={appt} sessionId={sessionId} />
|
<CustomerNotesSection appointment={appt} sessionId={sessionId} />
|
||||||
)}
|
)}
|
||||||
{isUpcoming(appt) && <ConfirmationSection appointment={appt} sessionId={sessionId} />}
|
{!isWaitlist && isUpcoming(appt) && (
|
||||||
{appt.status !== 'completed' &&
|
<ConfirmationSection appointment={appt} sessionId={sessionId} />
|
||||||
|
)}
|
||||||
|
{!isWaitlist &&
|
||||||
|
appt.status !== 'completed' &&
|
||||||
appt.status !== 'cancelled' &&
|
appt.status !== 'cancelled' &&
|
||||||
!readOnly && (
|
!readOnly && (
|
||||||
<div className="flex gap-2 mt-3">
|
<div className="flex gap-2 mt-3">
|
||||||
|
|||||||
Reference in New Issue
Block a user