diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index e263b57..4f363ef 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -8,6 +8,7 @@ import { servicesRouter } from "./routes/services.js"; import { appointmentsRouter } from "./routes/appointments.js"; import { staffRouter } from "./routes/staff.js"; import { invoicesRouter } from "./routes/invoices.js"; +import { bookRouter } from "./routes/book.js"; import { authMiddleware } from "./middleware/auth.js"; const app = new Hono(); @@ -25,6 +26,9 @@ app.use( // Health check (no auth required) app.get("/health", (c) => c.json({ status: "ok" })); +// Public booking routes — no auth required, must be registered before auth middleware +app.route("/api/book", bookRouter); + // Protected API routes const api = app.basePath("/api"); api.use("*", authMiddleware); diff --git a/apps/api/src/routes/book.ts b/apps/api/src/routes/book.ts new file mode 100644 index 0000000..82b9b62 --- /dev/null +++ b/apps/api/src/routes/book.ts @@ -0,0 +1,257 @@ +import { Hono } from "hono"; +import { zValidator } from "@hono/zod-validator"; +import { z } from "zod"; +import { + and, + eq, + gt, + gte, + lt, + ne, + getDb, + services, + staff, + appointments, + clients, + pets, +} from "@groombook/db"; + +export const bookRouter = new Hono(); + +// Business hours (UTC) — 09:00–17:00 +const BUSINESS_START_HOUR = 9; +const BUSINESS_END_HOUR = 17; + +// ─── GET /api/book/services ───────────────────────────────────────────────── +// Public: list active services for the booking flow + +bookRouter.get("/services", async (c) => { + const db = getDb(); + const rows = await db + .select() + .from(services) + .where(eq(services.active, true)) + .orderBy(services.name); + return c.json(rows); +}); + +// ─── GET /api/book/availability ───────────────────────────────────────────── +// Public: return ISO startTime strings for slots where ≥1 groomer is free +// Query params: serviceId (uuid), date (YYYY-MM-DD) + +bookRouter.get("/availability", async (c) => { + const serviceId = c.req.query("serviceId"); + const dateStr = c.req.query("date"); + + if (!serviceId || !dateStr) { + return c.json({ error: "serviceId and date are required" }, 400); + } + if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) { + return c.json({ error: "date must be YYYY-MM-DD" }, 400); + } + + const db = getDb(); + const [service] = await db + .select() + .from(services) + .where(and(eq(services.id, serviceId), eq(services.active, true))); + if (!service) return c.json({ error: "Service not found" }, 404); + + const groomers = await db + .select({ id: staff.id }) + .from(staff) + .where(and(eq(staff.active, true), eq(staff.role, "groomer"))); + + if (groomers.length === 0) return c.json([]); + + const dayStart = new Date(`${dateStr}T00:00:00Z`); + dayStart.setUTCHours(BUSINESS_START_HOUR, 0, 0, 0); + const dayEnd = new Date(`${dateStr}T00:00:00Z`); + dayEnd.setUTCHours(BUSINESS_END_HOUR, 0, 0, 0); + + // Fetch all active appointments for the day (any groomer) + const booked = await db + .select({ + staffId: appointments.staffId, + startTime: appointments.startTime, + endTime: appointments.endTime, + }) + .from(appointments) + .where( + and( + gte(appointments.startTime, dayStart), + lt(appointments.startTime, dayEnd), + ne(appointments.status, "cancelled"), + ne(appointments.status, "no_show"), + ) + ); + + const durationMs = service.durationMinutes * 60_000; + const slots: string[] = []; + let slotStart = dayStart.getTime(); + + while (slotStart + durationMs <= dayEnd.getTime()) { + const slotEnd = slotStart + durationMs; + const hasGroomer = groomers.some(({ id: groomerId }) => + !booked.some( + (a) => + a.staffId === groomerId && + a.startTime.getTime() < slotEnd && + a.endTime.getTime() > slotStart + ) + ); + if (hasGroomer) slots.push(new Date(slotStart).toISOString()); + slotStart += durationMs; + } + + return c.json(slots); +}); + +// ─── POST /api/book/appointments ───────────────────────────────────────────── +// Public: create a booking. Finds or creates client by email, always creates pet. + +const bookingSchema = z.object({ + serviceId: z.string().uuid(), + startTime: z.string().datetime(), + clientName: z.string().min(1).max(200), + clientEmail: z.string().email(), + clientPhone: z.string().max(50).optional(), + petName: z.string().min(1).max(200), + petSpecies: z.string().min(1).max(100), + petBreed: z.string().max(100).optional(), + notes: z.string().max(2000).optional(), +}); + +bookRouter.post( + "/appointments", + zValidator("json", bookingSchema), + async (c) => { + const db = getDb(); + const body = c.req.valid("json"); + const start = new Date(body.startTime); + + const [service] = await db + .select() + .from(services) + .where(and(eq(services.id, body.serviceId), eq(services.active, true))); + if (!service) return c.json({ error: "Service not found" }, 404); + + const end = new Date(start.getTime() + service.durationMinutes * 60_000); + + // Find all active groomers + const groomers = await db + .select({ id: staff.id }) + .from(staff) + .where(and(eq(staff.active, true), eq(staff.role, "groomer"))); + + if (groomers.length === 0) { + return c.json({ error: "No groomers available" }, 409); + } + + // Find conflicting appointments for this time window + const booked = await db + .select({ staffId: appointments.staffId }) + .from(appointments) + .where( + and( + lt(appointments.startTime, end), + gt(appointments.endTime, start), + ne(appointments.status, "cancelled"), + ne(appointments.status, "no_show"), + ) + ); + + const busyIds = new Set(booked.map((a) => a.staffId)); + const freeGroomer = groomers.find(({ id }) => !busyIds.has(id)); + if (!freeGroomer) { + return c.json( + { error: "No groomers available at this time. Please choose another slot." }, + 409 + ); + } + + // Find or create client by email + let [client] = await db + .select() + .from(clients) + .where(eq(clients.email, body.clientEmail)); + + if (!client) { + const inserted = await db + .insert(clients) + .values({ + name: body.clientName, + email: body.clientEmail, + phone: body.clientPhone ?? null, + }) + .returning(); + client = inserted[0]; + } + + if (!client) return c.json({ error: "Failed to create client" }, 500); + + // Create pet + const petInserted = await db + .insert(pets) + .values({ + clientId: client.id, + name: body.petName, + species: body.petSpecies, + breed: body.petBreed ?? null, + }) + .returning(); + const pet = petInserted[0]; + if (!pet) return c.json({ error: "Failed to create pet" }, 500); + + // Insert appointment in a transaction to guard against race conditions + let appointment; + try { + appointment = await db.transaction(async (tx) => { + const conflicts = await tx + .select({ id: appointments.id }) + .from(appointments) + .where( + and( + eq(appointments.staffId, freeGroomer.id), + lt(appointments.startTime, end), + gt(appointments.endTime, start), + ne(appointments.status, "cancelled"), + ne(appointments.status, "no_show"), + ) + ) + .limit(1); + + if (conflicts.length > 0) { + throw Object.assign(new Error("conflict"), { statusCode: 409 }); + } + + const apptInserted = await tx + .insert(appointments) + .values({ + clientId: client.id, + petId: pet.id, + serviceId: body.serviceId, + staffId: freeGroomer.id, + startTime: start, + endTime: end, + notes: body.notes ?? null, + }) + .returning(); + return apptInserted[0]; + }); + } catch (err: unknown) { + const code = (err as Error & { statusCode?: number }).statusCode; + if (code === 409) { + return c.json( + { error: "This slot was just taken. Please choose another time." }, + 409 + ); + } + throw err; + } + + if (!appointment) return c.json({ error: "Failed to create appointment" }, 500); + + return c.json({ appointment, client, pet }, 201); + } +); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 5c39acd..81f7562 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -4,6 +4,7 @@ import { ClientsPage } from "./pages/Clients.js"; import { ServicesPage } from "./pages/Services.js"; import { StaffPage } from "./pages/Staff.js"; import { InvoicesPage } from "./pages/Invoices.js"; +import { BookPage } from "./pages/Book.js"; const NAV_LINKS = [ { to: "/", label: "Appointments" }, @@ -28,6 +29,21 @@ export function App() { }} > Groom Book + + Book + {NAV_LINKS.map(({ to, label }) => { const active = to === "/" ? location.pathname === "/" : location.pathname.startsWith(to); @@ -57,6 +73,7 @@ export function App() { } /> } /> } /> + } /> diff --git a/apps/web/src/pages/Book.tsx b/apps/web/src/pages/Book.tsx new file mode 100644 index 0000000..ecfd5bb --- /dev/null +++ b/apps/web/src/pages/Book.tsx @@ -0,0 +1,575 @@ +import { useEffect, useState } from "react"; +import type { Service } from "@groombook/types"; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +interface BookingBody { + serviceId: string; + startTime: string; + clientName: string; + clientEmail: string; + clientPhone: string; + petName: string; + petSpecies: string; + petBreed: string; + notes: string; +} + +interface BookingResult { + appointment: { id: string; startTime: string; endTime: string }; + client: { id: string; name: string; email: string | null }; + pet: { id: string; name: string }; +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function fmtPrice(cents: number): string { + return `$${(cents / 100).toFixed(2)}`; +} + +function fmtDuration(minutes: number): string { + if (minutes < 60) return `${minutes} min`; + const h = Math.floor(minutes / 60); + const m = minutes % 60; + return m > 0 ? `${h}h ${m}m` : `${h}h`; +} + +function fmtTime(iso: string): string { + return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); +} + +function fmtDateLong(isoDate: string): string { + const d = new Date(isoDate + "T12:00:00Z"); + return d.toLocaleDateString([], { weekday: "long", year: "numeric", month: "long", day: "numeric" }); +} + +function todayIso(): string { + return new Date().toISOString().slice(0, 10); +} + +// ─── Sub-components ─────────────────────────────────────────────────────────── + +function StepIndicator({ step }: { step: number }) { + const steps = ["Service", "Date & Time", "Your Info", "Confirm"]; + return ( +
+ {steps.map((label, i) => { + const idx = i + 1; + const active = idx === step; + const done = idx < step; + return ( +
+ + {done ? "✓" : idx} + + {label} +
+ ); + })} +
+ ); +} + +// ─── Main Component ─────────────────────────────────────────────────────────── + +export function BookPage() { + const [step, setStep] = useState(1); + + // Step 1 — service + const [services, setServices] = useState([]); + const [servicesLoading, setServicesLoading] = useState(true); + const [selectedService, setSelectedService] = useState(null); + + // Step 2 — date & time + const [date, setDate] = useState(todayIso()); + const [slots, setSlots] = useState([]); + const [slotsLoading, setSlotsLoading] = useState(false); + const [selectedSlot, setSelectedSlot] = useState(null); + + // Step 3 — contact info + const [form, setForm] = useState({ + serviceId: "", + startTime: "", + clientName: "", + clientEmail: "", + clientPhone: "", + petName: "", + petSpecies: "", + petBreed: "", + notes: "", + }); + const [formError, setFormError] = useState(null); + + // Step 4 — result + const [submitting, setSubmitting] = useState(false); + const [result, setResult] = useState(null); + const [submitError, setSubmitError] = useState(null); + + // Load services on mount + useEffect(() => { + fetch("/api/book/services") + .then((r) => r.json() as Promise) + .then(setServices) + .catch(() => setServices([])) + .finally(() => setServicesLoading(false)); + }, []); + + // Load slots when service or date changes (step 2) + useEffect(() => { + if (!selectedService || !date) return; + setSlotsLoading(true); + setSelectedSlot(null); + fetch( + `/api/book/availability?serviceId=${encodeURIComponent(selectedService.id)}&date=${encodeURIComponent(date)}` + ) + .then((r) => r.json() as Promise) + .then(setSlots) + .catch(() => setSlots([])) + .finally(() => setSlotsLoading(false)); + }, [selectedService, date]); + + function goToStep2(svc: Service) { + setSelectedService(svc); + setForm((f) => ({ ...f, serviceId: svc.id })); + setStep(2); + } + + function goToStep3() { + if (!selectedSlot) return; + setForm((f) => ({ ...f, startTime: selectedSlot })); + setStep(3); + } + + function goToStep4() { + if (!form.clientName.trim() || !form.clientEmail.trim() || !form.petName.trim() || !form.petSpecies.trim()) { + setFormError("Please fill in all required fields."); + return; + } + setFormError(null); + setStep(4); + } + + async function submitBooking() { + setSubmitting(true); + setSubmitError(null); + try { + const res = await fetch("/api/book/appointments", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + serviceId: form.serviceId, + startTime: form.startTime, + clientName: form.clientName, + clientEmail: form.clientEmail, + clientPhone: form.clientPhone || undefined, + petName: form.petName, + petSpecies: form.petSpecies, + petBreed: form.petBreed || undefined, + notes: form.notes || undefined, + }), + }); + if (!res.ok) { + const body = (await res.json()) as { error?: string }; + throw new Error(body.error ?? `HTTP ${res.status}`); + } + const data = (await res.json()) as BookingResult; + setResult(data); + setStep(5); + } catch (e: unknown) { + setSubmitError(e instanceof Error ? e.message : "Something went wrong. Please try again."); + } finally { + setSubmitting(false); + } + } + + // ── Styles ── + const card: React.CSSProperties = { + background: "#fff", + border: "1px solid #e5e7eb", + borderRadius: 8, + padding: "1rem", + cursor: "pointer", + }; + + const selectedCard: React.CSSProperties = { + ...card, + border: "2px solid #4f8a6f", + background: "#f0faf5", + }; + + const input: React.CSSProperties = { + width: "100%", + padding: "0.5rem 0.75rem", + border: "1px solid #d1d5db", + borderRadius: 6, + fontSize: 14, + boxSizing: "border-box", + }; + + const label: React.CSSProperties = { + display: "block", + fontSize: 13, + fontWeight: 600, + color: "#374151", + marginBottom: 4, + }; + + const btn: React.CSSProperties = { + padding: "0.6rem 1.25rem", + borderRadius: 6, + border: "none", + cursor: "pointer", + fontSize: 14, + fontWeight: 600, + }; + + const primaryBtn: React.CSSProperties = { + ...btn, + background: "#4f8a6f", + color: "#fff", + }; + + const secondaryBtn: React.CSSProperties = { + ...btn, + background: "#f3f4f6", + color: "#374151", + }; + + return ( +
+
+

+ Book an Appointment +

+

+ Schedule a grooming appointment for your pet in minutes. +

+
+ + {step < 5 && } + + {/* ── Step 1: Select Service ── */} + {step === 1 && ( +
+

+ Choose a service +

+ {servicesLoading &&

Loading services…

} + {!servicesLoading && services.length === 0 && ( +

No services available. Please contact us to book.

+ )} +
+ {services.map((svc) => ( +
goToStep2(svc)} + role="button" + tabIndex={0} + onKeyDown={(e) => e.key === "Enter" && goToStep2(svc)} + > +
+
+
{svc.name}
+ {svc.description && ( +
{svc.description}
+ )} +
+
+
+ {fmtPrice(svc.basePriceCents)} +
+
{fmtDuration(svc.durationMinutes)}
+
+
+
+ ))} +
+
+ )} + + {/* ── Step 2: Date & Time ── */} + {step === 2 && selectedService && ( +
+

Choose a date and time

+

+ {selectedService.name} — {fmtDuration(selectedService.durationMinutes)} — {fmtPrice(selectedService.basePriceCents)} +

+ +
+ + setDate(e.target.value)} + /> +
+ +
+ + {slotsLoading &&

Checking availability…

} + {!slotsLoading && slots.length === 0 && ( +

+ No available slots on this date. Please try another day. +

+ )} + {!slotsLoading && slots.length > 0 && ( +
+ {slots.map((slot) => ( + + ))} +
+ )} +
+ +
+ + +
+
+ )} + + {/* ── Step 3: Contact Info ── */} + {step === 3 && ( +
+

Your information

+ +
+
+ + Contact details + +
+
+ + setForm((f) => ({ ...f, clientName: e.target.value }))} + placeholder="Jane Smith" + /> +
+
+ + setForm((f) => ({ ...f, clientEmail: e.target.value }))} + placeholder="jane@example.com" + /> +
+
+ + setForm((f) => ({ ...f, clientPhone: e.target.value }))} + placeholder="(555) 000-1234" + /> +
+
+
+ +
+ + Pet details + +
+
+ + setForm((f) => ({ ...f, petName: e.target.value }))} + placeholder="Buddy" + /> +
+
+ + +
+
+ + setForm((f) => ({ ...f, petBreed: e.target.value }))} + placeholder="Golden Retriever" + /> +
+
+ +