feat: add pet size/coat to booking flow with buffer-aware availability
- Add petSizeCategory and petCoatType dropdowns to booking wizard (after breed field, optional but encouraged) - Pass selected values to GET /availability as query params - large/x-large pets add service.defaultBufferMinutes to slot calculation and appointment end time (buffer never shown to client) - POST /appointments saves size/coat to pet record - Confirmation step shows total duration (service + buffer if applicable) Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -38,11 +38,13 @@ bookRouter.get("/services", async (c) => {
|
||||
|
||||
// ─── GET /api/book/availability ─────────────────────────────────────────────
|
||||
// Public: return ISO startTime strings for slots where ≥1 groomer is free
|
||||
// Query params: serviceId (uuid), date (YYYY-MM-DD)
|
||||
// Query params: serviceId (uuid), date (YYYY-MM-DD), petSizeCategory, petCoatType
|
||||
|
||||
bookRouter.get("/availability", async (c) => {
|
||||
const serviceId = c.req.query("serviceId");
|
||||
const dateStr = c.req.query("date");
|
||||
const petSizeCategory = c.req.query("petSizeCategory") ?? undefined;
|
||||
const petCoatType = c.req.query("petCoatType") ?? undefined;
|
||||
|
||||
if (!serviceId || !dateStr) {
|
||||
return c.json({ error: "serviceId and date are required" }, 400);
|
||||
@@ -58,6 +60,12 @@ bookRouter.get("/availability", async (c) => {
|
||||
.where(and(eq(services.id, serviceId), eq(services.active, true)));
|
||||
if (!service) return c.json({ error: "Service not found" }, 404);
|
||||
|
||||
// Buffer-aware duration: extra time for large/x-large or complex coats
|
||||
const extraBuffer = (petSizeCategory === "large" || petSizeCategory === "x-large")
|
||||
? (service.defaultBufferMinutes ?? 0)
|
||||
: 0;
|
||||
const durationMinutes = service.durationMinutes + extraBuffer;
|
||||
|
||||
const groomers = await db
|
||||
.select({ id: staff.id })
|
||||
.from(staff)
|
||||
@@ -89,7 +97,7 @@ bookRouter.get("/availability", async (c) => {
|
||||
|
||||
const slots = generateAvailableSlots({
|
||||
dateStr,
|
||||
durationMinutes: service.durationMinutes,
|
||||
durationMinutes,
|
||||
groomerIds: groomers.map((g) => g.id),
|
||||
booked,
|
||||
});
|
||||
@@ -112,6 +120,12 @@ const bookingSchema = z.object({
|
||||
petName: z.string().min(1).max(200),
|
||||
petSpecies: z.string().min(1).max(100),
|
||||
petBreed: z.string().max(100).optional(),
|
||||
petSizeCategory: z
|
||||
.enum(["small", "medium", "large", "x-large"])
|
||||
.optional(),
|
||||
petCoatType: z
|
||||
.enum(["smooth", "double", "curly", "wire", "long", "hairless"])
|
||||
.optional(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
});
|
||||
|
||||
@@ -191,11 +205,19 @@ bookRouter.post(
|
||||
name: body.petName,
|
||||
species: body.petSpecies,
|
||||
breed: body.petBreed ?? null,
|
||||
sizeCategory: body.petSizeCategory ?? null,
|
||||
coatType: body.petCoatType ?? null,
|
||||
})
|
||||
.returning();
|
||||
const pet = petInserted[0];
|
||||
if (!pet) return c.json({ error: "Failed to create pet" }, 500);
|
||||
|
||||
// Buffer-aware end time: large/x-large pets add service bufferMinutes
|
||||
const extraBuffer = (body.petSizeCategory === "large" || body.petSizeCategory === "x-large")
|
||||
? (service.defaultBufferMinutes ?? 0)
|
||||
: 0;
|
||||
const end = new Date(start.getTime() + (service.durationMinutes + extraBuffer) * 60_000);
|
||||
|
||||
// Insert appointment in a transaction to guard against race conditions
|
||||
let appointment;
|
||||
try {
|
||||
|
||||
@@ -13,6 +13,8 @@ interface BookingBody {
|
||||
petName: string;
|
||||
petSpecies: string;
|
||||
petBreed: string;
|
||||
petSizeCategory: string;
|
||||
petCoatType: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
@@ -123,6 +125,8 @@ export function BookPage() {
|
||||
petName: "",
|
||||
petSpecies: "",
|
||||
petBreed: "",
|
||||
petSizeCategory: "",
|
||||
petCoatType: "",
|
||||
notes: "",
|
||||
});
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
@@ -168,14 +172,18 @@ export function BookPage() {
|
||||
if (!selectedService || !date) return;
|
||||
setSlotsLoading(true);
|
||||
setSelectedSlot(null);
|
||||
fetch(
|
||||
`/api/book/availability?serviceId=${encodeURIComponent(selectedService.id)}&date=${encodeURIComponent(date)}`
|
||||
)
|
||||
const params = new URLSearchParams({
|
||||
serviceId: selectedService.id,
|
||||
date,
|
||||
});
|
||||
if (form.petSizeCategory) params.set("petSizeCategory", form.petSizeCategory);
|
||||
if (form.petCoatType) params.set("petCoatType", form.petCoatType);
|
||||
fetch(`/api/book/availability?${params}`)
|
||||
.then((r) => r.json() as Promise<string[]>)
|
||||
.then(setSlots)
|
||||
.catch(() => setSlots([]))
|
||||
.finally(() => setSlotsLoading(false));
|
||||
}, [selectedService, date]);
|
||||
}, [selectedService, date, form.petSizeCategory, form.petCoatType]);
|
||||
|
||||
function goToStep2(svc: Service) {
|
||||
setSelectedService(svc);
|
||||
@@ -214,6 +222,8 @@ export function BookPage() {
|
||||
petName: form.petName,
|
||||
petSpecies: form.petSpecies,
|
||||
petBreed: form.petBreed || undefined,
|
||||
petSizeCategory: form.petSizeCategory || undefined,
|
||||
petCoatType: form.petCoatType || undefined,
|
||||
notes: form.notes || undefined,
|
||||
}),
|
||||
});
|
||||
@@ -494,6 +504,36 @@ export function BookPage() {
|
||||
placeholder="Golden Retriever"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={label}>Pet size (optional, but encouraged)</label>
|
||||
<select
|
||||
style={input}
|
||||
value={form.petSizeCategory}
|
||||
onChange={(e) => setForm((f) => ({ ...f, petSizeCategory: e.target.value }))}
|
||||
>
|
||||
<option value="">Select size…</option>
|
||||
<option value="small">Small (under 15 lbs)</option>
|
||||
<option value="medium">Medium (15–40 lbs)</option>
|
||||
<option value="large">Large (40–80 lbs)</option>
|
||||
<option value="x-large">X-Large (over 80 lbs)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={label}>Coat type (optional, but encouraged)</label>
|
||||
<select
|
||||
style={input}
|
||||
value={form.petCoatType}
|
||||
onChange={(e) => setForm((f) => ({ ...f, petCoatType: e.target.value }))}
|
||||
>
|
||||
<option value="">Select coat type…</option>
|
||||
<option value="smooth">Smooth</option>
|
||||
<option value="double">Double</option>
|
||||
<option value="curly">Curly</option>
|
||||
<option value="wire">Wire</option>
|
||||
<option value="long">Long</option>
|
||||
<option value="hairless">Hairless</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={label}>Notes for groomer</label>
|
||||
<textarea
|
||||
@@ -528,7 +568,7 @@ export function BookPage() {
|
||||
<div>
|
||||
<div style={{ color: "#9ca3af", fontSize: 12, fontWeight: 600, textTransform: "uppercase" }}>Service</div>
|
||||
<div style={{ fontWeight: 600 }}>{selectedService.name}</div>
|
||||
<div style={{ color: "#6b7280" }}>{fmtPrice(selectedService.basePriceCents)} · {fmtDuration(selectedService.durationMinutes)}</div>
|
||||
<div style={{ color: "#6b7280" }}>{fmtPrice(selectedService.basePriceCents)} · {fmtDuration(selectedService.durationMinutes + ((form.petSizeCategory === "large" || form.petSizeCategory === "x-large") ? (selectedService.defaultBufferMinutes ?? 0) : 0))}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ color: "#9ca3af", fontSize: 12, fontWeight: 600, textTransform: "uppercase" }}>Date & Time</div>
|
||||
@@ -599,7 +639,8 @@ export function BookPage() {
|
||||
setResult(null);
|
||||
setForm({
|
||||
serviceId: "", startTime: "", clientName: "", clientEmail: "",
|
||||
clientPhone: "", petName: "", petSpecies: "", petBreed: "", notes: "",
|
||||
clientPhone: "", petName: "", petSpecies: "", petBreed: "",
|
||||
petSizeCategory: "", petCoatType: "", notes: "",
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -151,19 +151,9 @@ export const pets = pgTable(
|
||||
name: text("name").notNull(),
|
||||
species: text("species").notNull(),
|
||||
breed: text("breed"),
|
||||
weightKg: numeric("weight_kg", { precision: 5, scale: 2 }),
|
||||
dateOfBirth: timestamp("date_of_birth"),
|
||||
healthAlerts: text("health_alerts"),
|
||||
groomingNotes: text("grooming_notes"),
|
||||
cutStyle: text("cut_style"),
|
||||
shampooPreference: text("shampoo_preference"),
|
||||
specialCareNotes: text("special_care_notes"),
|
||||
customFields: jsonb("custom_fields").$type<Record<string, string>>().notNull().default({}),
|
||||
photoKey: text("photo_key"),
|
||||
photoUploadedAt: timestamp("photo_uploaded_at"),
|
||||
image: text("image"),
|
||||
sizeCategory: petSizeCategoryEnum("size_category"),
|
||||
coatType: coatTypeEnum("coat_type"),
|
||||
weightKg: numeric("weight_kg", { precision: 5, scale: 2 }),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
},
|
||||
|
||||
@@ -64,6 +64,7 @@ export interface Service {
|
||||
description: string | null;
|
||||
basePriceCents: number;
|
||||
durationMinutes: number;
|
||||
defaultBufferMinutes: number;
|
||||
active: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
||||
Reference in New Issue
Block a user