Elegir "Corte + arreglo de barba" y luego cualquier especialista que no fuera el barbero dejaba el asistente atascado: /slots responde 400 "El especialista no ofrece este servicio" y la UI lo pintaba como "No pudimos cargar los horarios. Intenta otra fecha" — un mensaje que manda a probar días al azar cuando ninguna fecha lo resuelve. La raíz es que el payload público listaba todos los empleados activos sin decir qué servicios ofrece cada uno, así que el cliente no tenía con qué filtrar. - GET /api/public/:slug devuelve ahora `service_ids` por empleado. El JOIN contra employees filtra por negocio: employee_services no tiene business_id propio. - El paso 2 lista solo a los elegibles, y si nadie ofrece el servicio en concreto lo dice en vez de mostrar una lista vacía. - Cambiar de servicio suelta un especialista que ya no aplique, en lugar de arrastrar una combinación que el servidor va a rechazar. - El estado de error muestra el motivo real del servidor. booking-e2e.mjs cubre el contrato: cada empleado publica service_ids, quien ofrece el servicio responde 200 y quien no responde 400. 15/15. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
116 lines
2.7 KiB
TypeScript
116 lines
2.7 KiB
TypeScript
import type { BookResponse } from "../../shared/types";
|
|
export type { BookResponse };
|
|
|
|
const BASE = "/api/public";
|
|
|
|
export interface PublicBusiness {
|
|
id: number;
|
|
name: string;
|
|
industry: string;
|
|
currency_symbol: string;
|
|
phone: string | null;
|
|
address: string | null;
|
|
/** Zona IANA del negocio. Toda hora mostrada al cliente se rinde en ella. */
|
|
timezone: string;
|
|
booking_enabled: boolean;
|
|
cancel_window_hours: number;
|
|
cancel_penalty_pct: number;
|
|
require_deposit: boolean;
|
|
deposit_pct: number;
|
|
auto_assign_specialist: number | boolean;
|
|
}
|
|
|
|
export interface PublicService {
|
|
id: number;
|
|
name: string;
|
|
description: string | null;
|
|
category: string;
|
|
duration_min: number;
|
|
price: number;
|
|
color: string;
|
|
}
|
|
|
|
export interface PublicEmployee {
|
|
id: number;
|
|
name: string;
|
|
role: string;
|
|
color: string;
|
|
/** Servicios que este especialista sí ofrece. El paso 2 filtra por aquí. */
|
|
service_ids: number[];
|
|
}
|
|
|
|
export interface PublicBusinessResponse {
|
|
business: PublicBusiness;
|
|
services: PublicService[];
|
|
employees: PublicEmployee[];
|
|
}
|
|
|
|
export interface PublicSlot {
|
|
time: string;
|
|
iso: string;
|
|
employee_id: number;
|
|
employee_name: string;
|
|
}
|
|
|
|
export interface PublicSlotsResponse {
|
|
slots: PublicSlot[];
|
|
service: Pick<PublicService, "id" | "name" | "price" | "duration_min">;
|
|
}
|
|
|
|
export interface BookClient {
|
|
name: string;
|
|
email?: string;
|
|
phone?: string;
|
|
notes?: string;
|
|
}
|
|
|
|
export interface BookPayload {
|
|
service_id: number;
|
|
employee_id?: number;
|
|
start_at: string;
|
|
client: BookClient;
|
|
}
|
|
|
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|
const res = await fetch(`${BASE}${path}`, {
|
|
...init,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
...(init?.headers as Record<string, string> | undefined),
|
|
},
|
|
});
|
|
if (!res.ok) {
|
|
let message = `Error ${res.status}`;
|
|
try {
|
|
const body = await res.json();
|
|
if (body?.error) message = body.error;
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
throw new Error(message);
|
|
}
|
|
return (await res.json()) as T;
|
|
}
|
|
|
|
export function getBusiness(slug: string): Promise<PublicBusinessResponse> {
|
|
return request<PublicBusinessResponse>(`/${slug}`);
|
|
}
|
|
|
|
export function getSlots(
|
|
slug: string,
|
|
serviceId: number,
|
|
date: string,
|
|
employeeId?: number
|
|
): Promise<PublicSlotsResponse> {
|
|
const q = new URLSearchParams({ service_id: String(serviceId), date });
|
|
if (employeeId) q.set("employee_id", String(employeeId));
|
|
return request<PublicSlotsResponse>(`/${slug}/slots?${q.toString()}`);
|
|
}
|
|
|
|
export function book(slug: string, payload: BookPayload): Promise<BookResponse> {
|
|
return request<BookResponse>(`/${slug}/book`, {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
});
|
|
}
|