Merge pull request 'Promote dev → uat: GRO-2236 portal Book New service cards price + duration' (#58) from flea/dev-to-uat-gro-2236 into uat
CI / Test (push) Successful in 24s
CI / Lint & Typecheck (push) Successful in 30s
CI / Build & Push Docker Image (push) Successful in 48s

This commit was merged in pull request #58.
This commit is contained in:
2026-06-09 02:13:08 +00:00
2 changed files with 126 additions and 11 deletions
+63 -1
View File
@@ -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();
});
});
+63 -10
View File
@@ -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>