Promote dev → uat: GRO-2236 portal Book New service cards price + duration #58
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<Service, 'price' | 'priceRange'>): 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<string, string> = {
|
||||
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) {
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right shrink-0 ml-3">
|
||||
<p className="text-sm font-medium text-stone-700">
|
||||
{svc.priceRange || `$${svc.price}`}
|
||||
</p>
|
||||
<p className="text-xs text-stone-400">{svc.duration} min</p>
|
||||
{formatServicePrice(svc) && (
|
||||
<p className="text-sm font-medium text-stone-700">
|
||||
{formatServicePrice(svc)}
|
||||
</p>
|
||||
)}
|
||||
{typeof svc.duration === 'number' && (
|
||||
<p className="text-xs text-stone-400">{svc.duration} min</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
@@ -1226,9 +1277,11 @@ export function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
|
||||
<p className="text-xs text-stone-500">{svc.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-stone-600 shrink-0 ml-3">
|
||||
{svc.priceRange || `$${svc.price}`}
|
||||
</span>
|
||||
{formatServicePrice(svc) && (
|
||||
<span className="text-stone-600 shrink-0 ml-3">
|
||||
{formatServicePrice(svc)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user