Compare commits

..

5 Commits

Author SHA1 Message Date
Flea Flicker 95c688764b Merge pull request 'uat→main (PROD): GRO-2234 portal Book New fix + validated batch' (#59) from flea/uat-to-main-gro-2234-web into main
CI / Test (push) Successful in 21s
CI / Lint & Typecheck (push) Successful in 27s
CI / Build & Push Docker Image (push) Successful in 16s
2026-06-09 00:37:47 +00:00
Flea Flicker 5bb8fbcb7d Merge pull request 'Promote uat → main (atomic): GRO-2105/2094/2099/2089/2180/2213 portal bundle' (#48) from uat into main
CI / Test (push) Successful in 19s
CI / Lint & Typecheck (push) Successful in 27s
CI / Build & Push Docker Image (push) Successful in 40s
2026-06-08 19:29:49 +00:00
Scrubs McBarkley fdff0977ad Merge pull request 'Promote uat → main: GRO-2012 RescheduleFlow portalSessionId fallback' (#40) from uat into main
CI / Test (push) Successful in 23s
CI / Lint & Typecheck (push) Successful in 29s
CI / Build & Push Docker Image (push) Successful in 16s
Promote uat → main: GRO-2012 RescheduleFlow portalSessionId fallback

Gate checks:
- UAT: GRO-2023 done (CTO verified, ec29f719)
- Security: GRO-2032 Barkley PASS
- UAT_PLAYBOOK.md: TC-WEB-5.26 present

Fix: CustomerPortal.tsx:329 sessionId={session?.id ?? portalSessionId}
Fix commit: f29f1828c8

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-01 19:10:08 +00:00
Scrubs McBarkley 2aad7cb6a0 Merge pull request 'promote: uat → main (GRO-1757 SSO auto-provision fix)' (#21) from uat into main
CI / Test (push) Successful in 13s
CI / Lint & Typecheck (push) Successful in 21s
CI / Build & Push Docker Image (push) Successful in 13s
2026-05-26 02:16:28 +00:00
Chris Farhood 0c41640f59 Add .mcp.json
CI / Test (push) Successful in 20s
CI / Lint & Typecheck (push) Successful in 27s
CI / Build & Push Docker Image (push) Successful in 4m1s
2026-05-24 18:15:24 +00:00
2 changed files with 11 additions and 126 deletions
+1 -63
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, normalizeService, formatServicePrice, CustomerNotesSection, ConfirmationSection, StatusBadge, formatSlotLabel, slotToTime, BookingFlow } from "../portal/sections/Appointments.tsx";
import { parseTimeTo24Hour, isUpcoming, normalizeAppointment, CustomerNotesSection, ConfirmationSection, StatusBadge, formatSlotLabel, slotToTime, BookingFlow } from "../portal/sections/Appointments.tsx";
const UPCOMING_APPT = {
id: "appt-1",
@@ -873,65 +873,3 @@ 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();
});
});
+10 -63
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,52 +249,6 @@ 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',
@@ -1040,8 +994,7 @@ export function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
if (servicesRes.ok) {
const servicesData = await servicesRes.json();
const rawServices: RawApiService[] = servicesData.services || servicesData || [];
setServices(rawServices.map(normalizeService));
setServices(servicesData.services || servicesData || []);
}
} catch {
setError('Failed to load data. Please try again.');
@@ -1237,14 +1190,10 @@ export function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
)}
</div>
<div className="text-right shrink-0 ml-3">
{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>
)}
<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>
</div>
</button>
))}
@@ -1277,11 +1226,9 @@ export function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
<p className="text-xs text-stone-500">{svc.description}</p>
)}
</div>
{formatServicePrice(svc) && (
<span className="text-stone-600 shrink-0 ml-3">
{formatServicePrice(svc)}
</span>
)}
<span className="text-stone-600 shrink-0 ml-3">
{svc.priceRange || `$${svc.price}`}
</span>
</button>
))}
</div>