From f1fe45fd6a31968d6c25ecb29e4954a6444a7179 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Mon, 8 Jun 2026 23:28:36 +0000 Subject: [PATCH] fix(GRO-2236): map portal service basePriceCents/durationMinutes so Book New cards show price + duration The Book New 'Select Services' cards read svc.price (dollars) and svc.duration, but GET /api/portal/services projects the canonical DB columns basePriceCents/durationMinutes. The mismatch rendered every card as $undefined price and an empty 'min'. Normalize the services payload at fetch time (basePriceCents -> price in dollars, durationMinutes -> duration), tolerating an already-normalized shape. Render price/duration via a formatServicePrice helper that returns null when absent, so the card gracefully hides the field instead of printing $undefined. Adds unit tests for normalizeService and formatServicePrice. Co-Authored-By: Claude Opus 4.8 --- src/__tests__/Appointments.test.tsx | 64 +++++++++++++++++++++++- src/portal/sections/Appointments.tsx | 73 ++++++++++++++++++++++++---- 2 files changed, 126 insertions(+), 11 deletions(-) diff --git a/src/__tests__/Appointments.test.tsx b/src/__tests__/Appointments.test.tsx index 72c4678..c878b46 100644 --- a/src/__tests__/Appointments.test.tsx +++ b/src/__tests__/Appointments.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; -import { parseTimeTo24Hour, isUpcoming, normalizeAppointment, CustomerNotesSection, ConfirmationSection, StatusBadge, formatSlotLabel, slotToTime, BookingFlow } from "../portal/sections/Appointments.tsx"; +import { parseTimeTo24Hour, isUpcoming, normalizeAppointment, normalizeService, formatServicePrice, CustomerNotesSection, ConfirmationSection, StatusBadge, formatSlotLabel, slotToTime, BookingFlow } from "../portal/sections/Appointments.tsx"; const UPCOMING_APPT = { id: "appt-1", @@ -873,3 +873,65 @@ describe("BookingFlow Book New funnel (GRO-2213)", () => { expect(screen.queryByText(/Failed to book appointment/i)).not.toBeInTheDocument(); }); }); + +describe("normalizeService", () => { + it("maps API basePriceCents/durationMinutes to price (dollars)/duration", () => { + const svc = normalizeService({ + id: "svc-1", + name: "Full Groom", + basePriceCents: 4500, + durationMinutes: 60, + }); + expect(svc.price).toBe(45); + expect(svc.duration).toBe(60); + }); + + it("preserves an already-normalized payload (price/duration)", () => { + const svc = normalizeService({ + id: "svc-2", + name: "Bath", + price: 30, + duration: 30, + }); + expect(svc.price).toBe(30); + expect(svc.duration).toBe(30); + }); + + it("leaves price/duration undefined when both source shapes are absent", () => { + const svc = normalizeService({ id: "svc-3", name: "Mystery" }); + expect(svc.price).toBeUndefined(); + expect(svc.duration).toBeUndefined(); + }); + + it("coerces null fields to undefined", () => { + const svc = normalizeService({ + id: "svc-4", + name: "Nail Trim", + basePriceCents: null, + durationMinutes: null, + description: null, + }); + expect(svc.price).toBeUndefined(); + expect(svc.duration).toBeUndefined(); + expect(svc.description).toBeUndefined(); + }); +}); + +describe("formatServicePrice", () => { + it("prefers an explicit priceRange string", () => { + expect(formatServicePrice({ priceRange: "$40–$60", price: 45 })).toBe("$40–$60"); + }); + + it("formats integer dollars without trailing zeros", () => { + expect(formatServicePrice({ price: 45 })).toBe("$45"); + }); + + it("formats fractional dollars to cents", () => { + expect(formatServicePrice({ price: 45.5 })).toBe("$45.50"); + }); + + it("returns null when no price is available (never '$undefined')", () => { + expect(formatServicePrice({})).toBeNull(); + expect(formatServicePrice({ price: undefined })).toBeNull(); + }); +}); diff --git a/src/portal/sections/Appointments.tsx b/src/portal/sections/Appointments.tsx index fd1b4fc..bf55d90 100644 --- a/src/portal/sections/Appointments.tsx +++ b/src/portal/sections/Appointments.tsx @@ -89,8 +89,8 @@ interface Service { id: string; name: string; description?: string; - duration: number; - price: number; + duration?: number; + price?: number; priceRange?: string; isAddOn?: boolean; } @@ -249,6 +249,52 @@ export function normalizeAppointment(raw: RawApiAppointment): Appointment { }; } +// Raw service shape from `GET /api/portal/services`, which projects the +// canonical DB columns (`basePriceCents`, `durationMinutes`). Also tolerates an +// already-normalized payload so either shape renders correctly. +interface RawApiService { + id: string; + name: string; + description?: string | null; + basePriceCents?: number | null; + durationMinutes?: number | null; + price?: number | null; + duration?: number | null; + priceRange?: string | null; + isAddOn?: boolean | null; +} + +// Normalizes a raw API service into the flat `Service` shape the cards render: +// price as dollars (from `basePriceCents`) and duration in minutes (from +// `durationMinutes`). Leaves fields undefined when genuinely absent so the card +// can hide them rather than print `$undefined` / empty `min`. +export function normalizeService(raw: RawApiService): Service { + const price = + raw.price ?? (typeof raw.basePriceCents === 'number' ? raw.basePriceCents / 100 : undefined); + const duration = raw.duration ?? raw.durationMinutes ?? undefined; + return { + id: raw.id, + name: raw.name, + description: raw.description ?? undefined, + duration: duration ?? undefined, + price: price ?? undefined, + priceRange: raw.priceRange ?? undefined, + isAddOn: raw.isAddOn ?? undefined, + }; +} + +// Renders a service price for display, preferring an explicit `priceRange` +// string, then a numeric dollar `price` (integers without trailing zeros, e.g. +// `$45`; fractional values to cents, e.g. `$45.50`). Returns null when neither +// is available so the caller can omit the price line entirely. +export function formatServicePrice(svc: Pick): string | null { + if (svc.priceRange) return svc.priceRange; + if (typeof svc.price === 'number' && Number.isFinite(svc.price)) { + return `$${Number.isInteger(svc.price) ? svc.price : svc.price.toFixed(2)}`; + } + return null; +} + const STATUS_COLORS: Record = { confirmed: 'bg-green-100 text-green-700', pending: 'bg-amber-100 text-amber-600', @@ -994,7 +1040,8 @@ export function BookingFlow({ onClose, sessionId }: BookingFlowProps) { if (servicesRes.ok) { const servicesData = await servicesRes.json(); - setServices(servicesData.services || servicesData || []); + const rawServices: RawApiService[] = servicesData.services || servicesData || []; + setServices(rawServices.map(normalizeService)); } } catch { setError('Failed to load data. Please try again.'); @@ -1190,10 +1237,14 @@ export function BookingFlow({ onClose, sessionId }: BookingFlowProps) { )}
-

- {svc.priceRange || `$${svc.price}`} -

-

{svc.duration} min

+ {formatServicePrice(svc) && ( +

+ {formatServicePrice(svc)} +

+ )} + {typeof svc.duration === 'number' && ( +

{svc.duration} min

+ )}
))} @@ -1226,9 +1277,11 @@ export function BookingFlow({ onClose, sessionId }: BookingFlowProps) {

{svc.description}

)} - - {svc.priceRange || `$${svc.price}`} - + {formatServicePrice(svc) && ( + + {formatServicePrice(svc)} + + )} ))} -- 2.52.0