fix(GRO-2236): portal Book New service cards show price + duration (#57)
Co-authored-by: The Dogfather <20+gb_dogfather@noreply.git.farh.net> Co-committed-by: The Dogfather <20+gb_dogfather@noreply.git.farh.net>
This commit is contained in:
@@ -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, 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 = {
|
const UPCOMING_APPT = {
|
||||||
id: "appt-1",
|
id: "appt-1",
|
||||||
@@ -873,3 +873,65 @@ describe("BookingFlow Book New funnel (GRO-2213)", () => {
|
|||||||
expect(screen.queryByText(/Failed to book appointment/i)).not.toBeInTheDocument();
|
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;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
duration: number;
|
duration?: number;
|
||||||
price: number;
|
price?: number;
|
||||||
priceRange?: string;
|
priceRange?: string;
|
||||||
isAddOn?: boolean;
|
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> = {
|
const 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-600',
|
pending: 'bg-amber-100 text-amber-600',
|
||||||
@@ -994,7 +1040,8 @@ export function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
|
|||||||
|
|
||||||
if (servicesRes.ok) {
|
if (servicesRes.ok) {
|
||||||
const servicesData = await servicesRes.json();
|
const servicesData = await servicesRes.json();
|
||||||
setServices(servicesData.services || servicesData || []);
|
const rawServices: RawApiService[] = servicesData.services || servicesData || [];
|
||||||
|
setServices(rawServices.map(normalizeService));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setError('Failed to load data. Please try again.');
|
setError('Failed to load data. Please try again.');
|
||||||
@@ -1190,10 +1237,14 @@ export function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right shrink-0 ml-3">
|
<div className="text-right shrink-0 ml-3">
|
||||||
<p className="text-sm font-medium text-stone-700">
|
{formatServicePrice(svc) && (
|
||||||
{svc.priceRange || `$${svc.price}`}
|
<p className="text-sm font-medium text-stone-700">
|
||||||
</p>
|
{formatServicePrice(svc)}
|
||||||
<p className="text-xs text-stone-400">{svc.duration} min</p>
|
</p>
|
||||||
|
)}
|
||||||
|
{typeof svc.duration === 'number' && (
|
||||||
|
<p className="text-xs text-stone-400">{svc.duration} min</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -1226,9 +1277,11 @@ export function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
|
|||||||
<p className="text-xs text-stone-500">{svc.description}</p>
|
<p className="text-xs text-stone-500">{svc.description}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-stone-600 shrink-0 ml-3">
|
{formatServicePrice(svc) && (
|
||||||
{svc.priceRange || `$${svc.price}`}
|
<span className="text-stone-600 shrink-0 ml-3">
|
||||||
</span>
|
{formatServicePrice(svc)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user