feat: extract groombook/web from monorepo

- Copy apps/web/ with all src, components, pages, portal
- Inline packages/types/ as local packages/types module
- Add tsconfig path aliases for @groombook/types
- Port Dockerfile and CI workflow
- Image name: ghcr.io/groombook/web

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
groombook-engineer[bot]
2026-05-02 21:38:42 +00:00
parent e03d052ec6
commit 45ed3587ba
131 changed files with 22602 additions and 0 deletions
+203
View File
@@ -0,0 +1,203 @@
import { useState, useEffect } from "react";
import { Calendar, RefreshCw, Trash2, Copy, Check } from "lucide-react";
interface Props {
staffId: string;
staffName: string;
}
export function CalendarSyncSection({ staffId }: Props) {
const [token, setToken] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [actionLoading, setActionLoading] = useState<"generate" | "revoke" | null>(null);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [showRevokeConfirm, setShowRevokeConfirm] = useState(false);
useEffect(() => {
fetchToken();
}, [staffId]);
async function fetchToken() {
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/staff/${staffId}`);
if (!res.ok) throw new Error("Failed to fetch staff data");
const data = await res.json();
setToken(data.icalToken || null);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load");
} finally {
setLoading(false);
}
}
async function generateToken() {
setActionLoading("generate");
setError(null);
try {
const res = await fetch(`/api/staff/${staffId}/ical-token`, { method: "POST" });
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Failed to generate token");
}
const data = await res.json();
setToken(data.icalToken);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to generate token");
} finally {
setActionLoading(null);
}
}
async function revokeToken() {
if (!showRevokeConfirm) {
setShowRevokeConfirm(true);
return;
}
setActionLoading("revoke");
setError(null);
try {
const res = await fetch(`/api/staff/${staffId}/ical-token`, { method: "DELETE" });
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Failed to revoke token");
}
setToken(null);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to revoke token");
} finally {
setActionLoading(null);
setShowRevokeConfirm(false);
}
}
async function copyFeedUrl() {
if (!token) return;
const url = `${window.location.origin}/api/calendar/${staffId}.ics?token=${token}`;
await navigator.clipboard.writeText(url);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
const feedUrl = token ? `/api/calendar/${staffId}.ics?token=${token}` : null;
return (
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
<div className="flex items-center gap-2 mb-4">
<Calendar size={18} className="text-(--color-accent)" />
<h3 className="font-medium text-stone-800">Calendar Sync</h3>
</div>
<p className="text-sm text-stone-500 mb-4">
Generate a calendar feed link to share your upcoming appointments with any calendar app that supports iCal (Apple Calendar, Google Calendar, Outlook).
</p>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600">
{error}
</div>
)}
{loading ? (
<div className="text-sm text-stone-400">Loading...</div>
) : token ? (
<div className="space-y-4">
<div>
<label className="block text-xs font-medium text-stone-500 mb-1">Your Calendar Feed URL</label>
<div className="flex gap-2">
<input
type="text"
readOnly
value={feedUrl ?? ""}
className="flex-1 text-sm border border-stone-200 rounded-lg px-3 py-2 bg-stone-50 text-stone-600 font-mono"
/>
<button
onClick={copyFeedUrl}
className="flex items-center gap-1.5 px-3 py-2 border border-stone-200 rounded-lg text-sm text-stone-600 hover:bg-stone-50"
title="Copy link"
>
{copied ? <Check size={14} className="text-green-600" /> : <Copy size={14} />}
</button>
</div>
</div>
{showRevokeConfirm ? (
<div className="flex items-center gap-3 p-3 bg-red-50 border border-red-200 rounded-lg">
<p className="flex-1 text-sm text-red-700">
Revoke your calendar feed link? Anyone with the current link will lose access.
</p>
<button
onClick={revokeToken}
disabled={actionLoading !== null}
className="flex items-center gap-1.5 px-3 py-1.5 bg-red-600 text-white rounded-lg text-sm font-medium hover:bg-red-700 disabled:opacity-50"
>
{actionLoading === "revoke" ? (
<RefreshCw size={14} className="animate-spin" />
) : (
<Trash2 size={14} />
)}
Revoke
</button>
<button
onClick={() => setShowRevokeConfirm(false)}
disabled={actionLoading !== null}
className="px-3 py-1.5 border border-stone-200 rounded-lg text-sm text-stone-600 hover:bg-stone-50 disabled:opacity-50"
>
Cancel
</button>
</div>
) : (
<div className="flex gap-2">
<button
onClick={generateToken}
disabled={actionLoading !== null}
className="flex items-center gap-1.5 px-3 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover) disabled:opacity-50"
>
{actionLoading === "generate" ? (
<RefreshCw size={14} className="animate-spin" />
) : (
<RefreshCw size={14} />
)}
Regenerate
</button>
<button
onClick={revokeToken}
disabled={actionLoading !== null}
className="flex items-center gap-1.5 px-3 py-2 border border-red-200 rounded-lg text-sm text-red-600 hover:bg-red-50 disabled:opacity-50"
>
{actionLoading === "revoke" ? (
<RefreshCw size={14} className="animate-spin" />
) : (
<Trash2 size={14} />
)}
Revoke
</button>
</div>
)}
<p className="text-xs text-stone-400">
Regenerating will create a new URL and invalidate the old one.
</p>
</div>
) : (
<div className="space-y-4">
<p className="text-sm text-stone-600">You don&apos;t have a calendar feed set up yet.</p>
<button
onClick={generateToken}
disabled={actionLoading !== null}
className="flex items-center gap-1.5 px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover) disabled:opacity-50"
>
{actionLoading === "generate" ? (
<RefreshCw size={14} className="animate-spin" />
) : (
<Calendar size={14} />
)}
{actionLoading === "generate" ? "Generating..." : "Generate Calendar Feed"}
</button>
</div>
)}
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { Link } from "react-router-dom";
import { getDevUser } from "../pages/DevLoginSelector.js";
export function DevSessionIndicator() {
const user = getDevUser();
if (!user) return null;
return (
<div
style={{
position: "fixed",
bottom: 0,
left: 0,
right: 0,
background: "#1a202c",
color: "#e2e8f0",
padding: "0.4rem 1rem",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "0.75rem",
fontSize: 12,
zIndex: 9999,
}}
>
<span>
Dev mode: acting as <strong>{user.name}</strong> ({user.type})
</span>
<Link
to="/login"
style={{
color: "var(--color-primary)",
textDecoration: "underline",
fontSize: 12,
}}
>
Switch user
</Link>
</div>
);
}
+287
View File
@@ -0,0 +1,287 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Search } from "lucide-react";
interface ClientResult {
id: string;
name: string;
email: string | null;
phone: string | null;
}
interface PetResult {
id: string;
name: string;
breed: string | null;
clientId: string;
ownerName: string;
}
interface SearchResults {
clients: ClientResult[];
pets: PetResult[];
}
export function GlobalSearch() {
const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchResults | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [open, setOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const navigate = useNavigate();
// Debounced search
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
const trimmed = query.trim();
if (trimmed.length === 0) {
setResults(null);
setOpen(false);
return;
}
debounceRef.current = setTimeout(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(trimmed)}`);
if (res.ok) {
const data: SearchResults = await res.json();
setResults(data);
setOpen(true);
} else {
setError("Search failed. Please try again.");
}
} catch {
setError("Search failed. Please try again.");
} finally {
setLoading(false);
}
}, 300);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [query]);
// Close dropdown on outside click
useEffect(() => {
function handleClick(e: MouseEvent) {
if (
inputRef.current &&
!inputRef.current.contains(e.target as Node) &&
dropdownRef.current &&
!dropdownRef.current.contains(e.target as Node)
) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
function handleClientClick(client: ClientResult) {
setOpen(false);
setQuery("");
navigate(`/admin/clients?highlight=${client.id}`);
}
function handlePetClick(pet: PetResult) {
setOpen(false);
setQuery("");
navigate(`/admin/clients?highlight=${pet.clientId}`);
}
const hasResults = results && (results.clients.length > 0 || results.pets.length > 0);
return (
<div style={{ position: "relative", flex: "1 1 0", maxWidth: 320, minWidth: 0 }}>
<div style={{ position: "relative" }}>
<Search
size={15}
style={{
position: "absolute",
left: 10,
top: "50%",
transform: "translateY(-50%)",
color: "#9ca3af",
pointerEvents: "none",
}}
/>
<input
ref={inputRef}
type="search"
placeholder="Search clients & pets…"
value={query}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => results && setOpen(true)}
style={{
width: "100%",
boxSizing: "border-box",
height: 44,
paddingLeft: 32,
paddingRight: 12,
fontSize: 13,
border: "1px solid #e2e8f0",
borderRadius: 8,
outline: "none",
background: "#f8fafc",
color: "#1a202c",
}}
aria-label="Search clients and pets"
aria-expanded={open}
aria-haspopup="listbox"
role="combobox"
aria-autocomplete="list"
/>
</div>
{open && (
<div
ref={dropdownRef}
role="listbox"
style={{
position: "absolute",
top: "calc(100% + 4px)",
left: 0,
right: 0,
background: "#fff",
border: "1px solid #e2e8f0",
borderRadius: 10,
boxShadow: "0 8px 24px rgba(0,0,0,0.10)",
zIndex: 100,
overflow: "hidden",
minWidth: "100%",
}}
>
{loading && (
<div style={{ padding: "12px 16px", fontSize: 13, color: "#6b7280" }}>
Searching
</div>
)}
{!loading && error && (
<div style={{ padding: "12px 16px", fontSize: 13, color: "#dc2626" }}>
{error}
</div>
)}
{!loading && !error && !hasResults && (
<div style={{ padding: "12px 16px", fontSize: 13, color: "#6b7280" }}>
No results found
</div>
)}
{!loading && results && results.clients.length > 0 && (
<div>
<div
style={{
padding: "6px 16px 4px",
fontSize: 11,
fontWeight: 600,
color: "#9ca3af",
textTransform: "uppercase",
letterSpacing: "0.05em",
borderBottom: "1px solid #f1f5f9",
}}
>
Clients
</div>
{results.clients.map((client) => (
<button
key={client.id}
role="option"
onClick={() => handleClientClick(client)}
style={{
display: "flex",
flexDirection: "column",
width: "100%",
padding: "12px 16px",
minHeight: 48,
background: "transparent",
border: "none",
borderBottom: "1px solid #f1f5f9",
cursor: "pointer",
textAlign: "left",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLButtonElement).style.background = "#f8fafc";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
}}
>
<span style={{ fontSize: 13, fontWeight: 500, color: "#1a202c" }}>
{client.name}
</span>
{client.phone && (
<span style={{ fontSize: 12, color: "#6b7280", marginTop: 1 }}>
{client.phone}
</span>
)}
</button>
))}
</div>
)}
{!loading && results && results.pets.length > 0 && (
<div>
<div
style={{
padding: "6px 16px 4px",
fontSize: 11,
fontWeight: 600,
color: "#9ca3af",
textTransform: "uppercase",
letterSpacing: "0.05em",
borderBottom: "1px solid #f1f5f9",
}}
>
Pets
</div>
{results.pets.map((pet) => (
<button
key={pet.id}
role="option"
onClick={() => handlePetClick(pet)}
style={{
display: "flex",
flexDirection: "column",
width: "100%",
padding: "12px 16px",
minHeight: 48,
background: "transparent",
border: "none",
borderBottom: "1px solid #f1f5f9",
cursor: "pointer",
textAlign: "left",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLButtonElement).style.background = "#f8fafc";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
}}
>
<span style={{ fontSize: 13, fontWeight: 500, color: "#1a202c" }}>
{pet.name}
{pet.breed && (
<span style={{ fontWeight: 400, color: "#4b5563" }}> · {pet.breed}</span>
)}
</span>
<span style={{ fontSize: 12, color: "#6b7280", marginTop: 1 }}>
Owner: {pet.ownerName}
</span>
</button>
))}
</div>
)}
</div>
)}
</div>
);
}
+86
View File
@@ -0,0 +1,86 @@
import { useEffect, useState } from "react";
interface Props {
petId: string;
/** Size of the photo avatar in pixels. Default: 64. */
size?: number;
className?: string;
}
type PhotoState =
| { status: "idle" }
| { status: "loading" }
| { status: "loaded"; url: string }
| { status: "none" }
| { status: "error" };
/**
* Fetches and displays a pet's photo from the API.
* Shows a loading skeleton while fetching, a paw-print placeholder when no photo exists,
* and gracefully falls back to the placeholder on error.
*/
export function PetPhotoDisplay({ petId, size = 64, className }: Props) {
const [state, setState] = useState<PhotoState>({ status: "idle" });
useEffect(() => {
setState({ status: "loading" });
fetch(`/api/pets/${petId}/photo`)
.then(async (res) => {
if (res.status === 404) {
setState({ status: "none" });
return;
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = (await res.json()) as { url: string };
setState({ status: "loaded", url: data.url });
})
.catch(() => setState({ status: "error" }));
}, [petId]);
const containerStyle: React.CSSProperties = {
width: size,
height: size,
borderRadius: Math.round(size * 0.2),
overflow: "hidden",
background: "#f0ebe4",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
};
if (state.status === "loading") {
return (
<div
className={className}
style={{
...containerStyle,
background: "linear-gradient(90deg, #f0ebe4 25%, #e8e0d8 50%, #f0ebe4 75%)",
backgroundSize: "200% 100%",
animation: "shimmer 1.5s infinite",
}}
aria-label="Loading photo…"
/>
);
}
if (state.status === "loaded") {
return (
<div className={className} style={containerStyle}>
<img
src={state.url}
alt="Pet photo"
style={{ width: "100%", height: "100%", objectFit: "cover" }}
loading="lazy"
/>
</div>
);
}
// no photo / error — paw placeholder
return (
<div className={className} style={containerStyle} aria-label="No photo">
<span style={{ fontSize: Math.round(size * 0.45), userSelect: "none" }}>🐾</span>
</div>
);
}
+212
View File
@@ -0,0 +1,212 @@
import { useRef, useState } from "react";
interface Props {
petId: string;
/** Called after a successful upload so the parent can refresh the display. */
onUploaded: () => void;
}
const MAX_DIMENSION = 1200;
const ACCEPTED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"];
/**
* Client-side-resize-then-upload component.
*
* Flow:
* 1. User selects a file
* 2. Component resizes to max 1200px on the longest side (canvas)
* 3. Requests a presigned PUT URL from the API
* 4. PUTs the resized blob directly to object storage
* 5. Confirms upload with the API (records the key in DB)
*/
export function PetPhotoUpload({ petId, onUploaded }: Props) {
const inputRef = useRef<HTMLInputElement>(null);
const [state, setState] = useState<
| { status: "idle" }
| { status: "resizing" }
| { status: "uploading"; progress: number }
| { status: "confirming" }
| { status: "done" }
| { status: "error"; message: string }
>({ status: "idle" });
async function resizeImage(file: File): Promise<{ blob: Blob; contentType: string }> {
// GIFs must bypass canvas resize — canvas destroys animation frames
if (file.type === "image/gif") {
return { blob: file, contentType: "image/gif" };
}
return new Promise((resolve, reject) => {
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(url);
const { width, height } = img;
const scale =
Math.max(width, height) > MAX_DIMENSION
? MAX_DIMENSION / Math.max(width, height)
: 1;
const canvas = document.createElement("canvas");
canvas.width = Math.round(width * scale);
canvas.height = Math.round(height * scale);
const ctx = canvas.getContext("2d");
if (!ctx) return reject(new Error("Canvas not supported"));
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
const contentType = file.type === "image/png" ? "image/png" : "image/jpeg";
canvas.toBlob(
(blob) => {
if (!blob) return reject(new Error("Failed to encode image"));
resolve({ blob, contentType });
},
contentType,
0.85
);
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error("Failed to load image"));
};
img.src = url;
});
}
async function handleFile(file: File) {
const MAX_FILE_SIZE = 50 * 1024 * 1024;
if (file.size > MAX_FILE_SIZE) {
setState({ status: "error", message: "File exceeds 50MB limit. Please choose a smaller image." });
return;
}
if (!ACCEPTED_TYPES.includes(file.type)) {
setState({ status: "error", message: "Please select a JPEG, PNG, WebP, or GIF image." });
return;
}
setState({ status: "resizing" });
let blob: Blob;
let contentType: string;
try {
({ blob, contentType } = await resizeImage(file));
} catch (e) {
setState({ status: "error", message: e instanceof Error ? e.message : "Image resize failed" });
return;
}
// Get presigned upload URL
setState({ status: "uploading", progress: 0 });
let uploadUrl: string;
let key: string;
try {
const res = await fetch(`/api/pets/${petId}/photo/upload-url`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contentType, fileSizeBytes: blob.size }),
});
if (!res.ok) {
const err = (await res.json()) as { error?: string };
throw new Error(err.error ?? `HTTP ${res.status}`);
}
const data = (await res.json()) as { uploadUrl: string; key: string };
uploadUrl = data.uploadUrl;
key = data.key;
} catch (e) {
setState({ status: "error", message: e instanceof Error ? e.message : "Failed to get upload URL" });
return;
}
// Upload directly to object storage
try {
const xhr = new XMLHttpRequest();
await new Promise<void>((resolve, reject) => {
xhr.upload.addEventListener("progress", (ev) => {
if (ev.lengthComputable) {
setState({ status: "uploading", progress: Math.round((ev.loaded / ev.total) * 100) });
}
});
xhr.addEventListener("load", () => {
if (xhr.status >= 200 && xhr.status < 300) resolve();
else reject(new Error(`Upload failed: HTTP ${xhr.status}`));
});
xhr.addEventListener("error", () => reject(new Error("Upload failed: network error")));
xhr.open("PUT", uploadUrl);
xhr.setRequestHeader("Content-Type", contentType);
xhr.send(blob);
});
} catch (e) {
setState({ status: "error", message: e instanceof Error ? e.message : "Upload failed" });
return;
}
// Confirm with API
setState({ status: "confirming" });
try {
const res = await fetch(`/api/pets/${petId}/photo/confirm`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key }),
});
if (!res.ok) {
const err = (await res.json()) as { error?: string };
throw new Error(err.error ?? `HTTP ${res.status}`);
}
} catch (e) {
setState({ status: "error", message: e instanceof Error ? e.message : "Failed to confirm upload" });
return;
}
setState({ status: "done" });
onUploaded();
// Reset after a moment
setTimeout(() => setState({ status: "idle" }), 2000);
}
const busy = state.status === "resizing" || state.status === "uploading" || state.status === "confirming";
return (
<div>
<input
ref={inputRef}
type="file"
accept={ACCEPTED_TYPES.join(",")}
style={{ display: "none" }}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) void handleFile(file);
// reset so re-selecting same file works
e.target.value = "";
}}
/>
<button
type="button"
disabled={busy}
onClick={() => inputRef.current?.click()}
style={{
fontSize: 12,
padding: "0.2rem 0.55rem",
borderRadius: 5,
border: "1px solid #d1d5db",
background: "#fff",
cursor: busy ? "not-allowed" : "pointer",
color: busy ? "#9ca3af" : "#374151",
display: "inline-flex",
alignItems: "center",
gap: "0.3rem",
}}
>
{state.status === "idle" && "📷 Upload photo"}
{state.status === "resizing" && "Resizing…"}
{state.status === "uploading" && `Uploading ${state.progress}%`}
{state.status === "confirming" && "Saving…"}
{state.status === "done" && "✓ Uploaded"}
{state.status === "error" && "📷 Upload photo"}
</button>
{state.status === "error" && (
<div style={{ fontSize: 11, color: "#dc2626", marginTop: "0.2rem" }}>
{state.message}
</div>
)}
</div>
);
}