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:
@@ -0,0 +1,22 @@
|
||||
FROM node:20-alpine AS base
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
|
||||
WORKDIR /app
|
||||
|
||||
# Install deps
|
||||
FROM base AS deps
|
||||
COPY package.json pnpm-workspace.yaml ./
|
||||
COPY apps/web/package.json apps/web/
|
||||
COPY packages/types/package.json packages/types/
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Build
|
||||
FROM deps AS builder
|
||||
COPY packages/types/ packages/types/
|
||||
COPY apps/web/ apps/web/
|
||||
RUN pnpm --filter @groombook/web build
|
||||
|
||||
# Serve with nginx
|
||||
FROM nginx:alpine AS runner
|
||||
COPY apps/web/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=builder /app/apps/web/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#4f8a6f" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>Groom Book</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,16 @@
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|svg|ico|woff2)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# SPA fallback — serve index.html for all routes
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@groombook/web",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint src --ext .ts,.tsx",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@groombook/types": "workspace:*",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.6",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"eslint": "^9.18.0",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.20.0",
|
||||
"vite": "^6.0.7",
|
||||
"vite-plugin-pwa": "^0.21.1",
|
||||
"vitest": "^3.0.4"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { VitePWA } from "vite-plugin-pwa";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: "autoUpdate",
|
||||
includeAssets: ["favicon.svg", "apple-touch-icon.png"],
|
||||
manifest: {
|
||||
name: "Groom Book",
|
||||
short_name: "GroomBook",
|
||||
description: "Pet grooming business management",
|
||||
theme_color: "#4f8a6f",
|
||||
background_color: "#ffffff",
|
||||
display: "standalone",
|
||||
scope: "/",
|
||||
start_url: "/",
|
||||
icons: [
|
||||
{
|
||||
src: "pwa-192x192.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
src: "pwa-512x512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
src: "pwa-512x512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "any maskable",
|
||||
},
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2}"],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /^http.*\/api\/.*/i,
|
||||
handler: "NetworkFirst",
|
||||
options: {
|
||||
cacheName: "api-cache",
|
||||
expiration: {
|
||||
maxEntries: 100,
|
||||
maxAgeSeconds: 60 * 60 * 24, // 24 hours
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:3000",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user