Bootstrap monorepo: Hono API, React PWA, Drizzle DB, CI/CD

Sets up the initial project structure for groombook/groombook:

- pnpm monorepo with apps/api (Hono + TypeScript), apps/web (React + Vite + PWA), packages/db (Drizzle ORM), packages/types (shared types)
- Core DB schema: clients, pets, services, appointments, staff with CNPG-compatible Postgres
- REST API routes for clients, pets, services, appointments with Zod validation
- OIDC auth middleware for Authentik integration
- React PWA with vite-plugin-pwa, service worker, offline caching, installable manifest
- GitHub Actions CI: lint, typecheck, test, build, Docker image build (groombook-runners)
- Dockerfiles for API (Node.js) and Web (nginx)
- docker-compose.yml for local development

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Groom Book CTO
2026-03-17 16:09:55 +00:00
parent 00876d13af
commit a36436d128
36 changed files with 1419 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
import { Routes, Route, Link } from "react-router-dom";
import { AppointmentsPage } from "./pages/Appointments.js";
import { ClientsPage } from "./pages/Clients.js";
import { ServicesPage } from "./pages/Services.js";
export function App() {
return (
<div>
<nav style={{ padding: "1rem", borderBottom: "1px solid #e2e8f0" }}>
<strong style={{ marginRight: "1.5rem" }}>Groom Book</strong>
<Link to="/" style={{ marginRight: "1rem" }}>
Appointments
</Link>
<Link to="/clients" style={{ marginRight: "1rem" }}>
Clients
</Link>
<Link to="/services">Services</Link>
</nav>
<main style={{ padding: "1rem" }}>
<Routes>
<Route path="/" element={<AppointmentsPage />} />
<Route path="/clients" element={<ClientsPage />} />
<Route path="/services" element={<ServicesPage />} />
</Routes>
</main>
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
*, *::before, *::after {
box-sizing: border-box;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-size: 16px;
line-height: 1.5;
color: #1a202c;
background: #f7fafc;
}
a {
color: #4f8a6f;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
h1 {
font-size: 1.5rem;
margin-top: 0;
}
+16
View File
@@ -0,0 +1,16 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { App } from "./App.js";
import "./index.css";
const root = document.getElementById("root");
if (!root) throw new Error("Root element not found");
createRoot(root).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>
);
+41
View File
@@ -0,0 +1,41 @@
import { useEffect, useState } from "react";
import type { Appointment } from "@groombook/types";
export function AppointmentsPage() {
const [appointments, setAppointments] = useState<Appointment[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch("/api/appointments")
.then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json() as Promise<Appointment[]>;
})
.then(setAppointments)
.catch((e: unknown) =>
setError(e instanceof Error ? e.message : "Unknown error")
)
.finally(() => setLoading(false));
}, []);
if (loading) return <p>Loading appointments</p>;
if (error) return <p style={{ color: "red" }}>Error: {error}</p>;
return (
<div>
<h1>Appointments</h1>
{appointments.length === 0 ? (
<p>No appointments yet.</p>
) : (
<ul>
{appointments.map((a) => (
<li key={a.id}>
{new Date(a.startTime).toLocaleString()} {a.status}
</li>
))}
</ul>
)}
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { useEffect, useState } from "react";
import type { Client } from "@groombook/types";
export function ClientsPage() {
const [clients, setClients] = useState<Client[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch("/api/clients")
.then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json() as Promise<Client[]>;
})
.then(setClients)
.catch((e: unknown) =>
setError(e instanceof Error ? e.message : "Unknown error")
)
.finally(() => setLoading(false));
}, []);
if (loading) return <p>Loading clients</p>;
if (error) return <p style={{ color: "red" }}>Error: {error}</p>;
return (
<div>
<h1>Clients</h1>
{clients.length === 0 ? (
<p>No clients yet.</p>
) : (
<ul>
{clients.map((c) => (
<li key={c.id}>
{c.name} {c.email ? `${c.email}` : ""}
</li>
))}
</ul>
)}
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { useEffect, useState } from "react";
import type { Service } from "@groombook/types";
export function ServicesPage() {
const [services, setServices] = useState<Service[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch("/api/services")
.then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json() as Promise<Service[]>;
})
.then(setServices)
.catch((e: unknown) =>
setError(e instanceof Error ? e.message : "Unknown error")
)
.finally(() => setLoading(false));
}, []);
if (loading) return <p>Loading services</p>;
if (error) return <p style={{ color: "red" }}>Error: {error}</p>;
return (
<div>
<h1>Services</h1>
{services.length === 0 ? (
<p>No services configured yet.</p>
) : (
<ul>
{services.map((s) => (
<li key={s.id}>
{s.name} ${(s.basePriceCents / 100).toFixed(2)} /{" "}
{s.durationMinutes} min
</li>
))}
</ul>
)}
</div>
);
}