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() {
+ Schedule a grooming appointment for your pet in minutes. +
+Loading services…
} + {!servicesLoading && services.length === 0 && ( +No services available. Please contact us to book.
+ )} ++ {selectedService.name} — {fmtDuration(selectedService.durationMinutes)} — {fmtPrice(selectedService.basePriceCents)} +
+ +Checking availability…
} + {!slotsLoading && slots.length === 0 && ( ++ No available slots on this date. Please try another day. +
+ )} + {!slotsLoading && slots.length > 0 && ( +{formError}
+ )} + +{submitError}
+ )} + ++ We've booked {result.pet.name} in for{" "} + {selectedService?.name} on {fmtDateLong(date)} at{" "} + {fmtTime(result.appointment.startTime)}. +
++ A confirmation will be sent to {result.client.email}. + If you need to reschedule or cancel, please contact us. +
+