Merge branch 'main' into feat/e2e-login-impersonation-gro-77

This commit is contained in:
groombook-ceo[bot]
2026-03-22 15:42:45 +00:00
committed by GitHub
32 changed files with 2466 additions and 120 deletions
+2 -2
View File
@@ -27,11 +27,11 @@
"@types/node": "^22.10.7",
"@types/node-cron": "^3.0.11",
"@types/nodemailer": "^6.4.17",
"@vitest/coverage-v8": "^3.0.4",
"@vitest/coverage-v8": "^3.2.4",
"eslint": "^9.18.0",
"tsx": "^4.19.2",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0",
"vitest": "^3.0.4"
"vitest": "^3.2.4"
}
}
+216
View File
@@ -0,0 +1,216 @@
import { describe, it, expect, beforeEach } from "vitest";
import {
resetFactoryCounters,
buildStaff,
buildClient,
buildPet,
buildService,
buildAppointment,
} from "@groombook/db/factories";
describe("resetFactoryCounters", () => {
it("resets all counters so IDs restart from 1", () => {
buildStaff();
buildStaff();
buildClient();
resetFactoryCounters();
const staff = buildStaff();
const client = buildClient();
expect(staff.id).toBe("staff-1");
expect(client.id).toBe("client-1");
});
it("resets counters for every entity type", () => {
const client = buildClient();
const pet = buildPet({ clientId: client.id });
const service = buildService();
buildAppointment({
clientId: client.id,
petId: pet.id,
serviceId: service.id,
staffId: "staff-1",
});
resetFactoryCounters();
expect(buildStaff().id).toBe("staff-1");
expect(buildClient().id).toBe("client-1");
expect(buildService().id).toBe("service-1");
const c = buildClient();
expect(buildPet({ clientId: c.id }).id).toBe("pet-1");
const s = buildService();
const p = buildPet({ clientId: c.id });
expect(
buildAppointment({ clientId: c.id, petId: p.id, serviceId: s.id, staffId: "s-1" }).id
).toBe("appointment-1");
});
});
describe("counter determinism", () => {
beforeEach(() => {
resetFactoryCounters();
});
it("increments staff IDs sequentially", () => {
expect(buildStaff().id).toBe("staff-1");
expect(buildStaff().id).toBe("staff-2");
expect(buildStaff().id).toBe("staff-3");
});
it("increments client IDs sequentially", () => {
expect(buildClient().id).toBe("client-1");
expect(buildClient().id).toBe("client-2");
});
it("increments pet IDs sequentially", () => {
const client = buildClient();
expect(buildPet({ clientId: client.id }).id).toBe("pet-1");
expect(buildPet({ clientId: client.id }).id).toBe("pet-2");
});
it("increments service IDs sequentially", () => {
expect(buildService().id).toBe("service-1");
expect(buildService().id).toBe("service-2");
});
it("increments appointment IDs sequentially", () => {
const client = buildClient();
const pet = buildPet({ clientId: client.id });
const service = buildService();
const required = { clientId: client.id, petId: pet.id, serviceId: service.id, staffId: "staff-1" };
expect(buildAppointment(required).id).toBe("appointment-1");
expect(buildAppointment(required).id).toBe("appointment-2");
});
it("each entity type maintains its own independent counter", () => {
buildStaff();
buildStaff();
buildClient();
// staff counter is at 2; client counter is at 1
expect(buildStaff().id).toBe("staff-3");
expect(buildClient().id).toBe("client-2");
});
});
describe("override merging", () => {
beforeEach(() => {
resetFactoryCounters();
});
it("buildStaff applies overrides over defaults", () => {
const staff = buildStaff({ role: "manager", name: "Boss" });
expect(staff.role).toBe("manager");
expect(staff.name).toBe("Boss");
expect(staff.id).toBe("staff-1");
expect(staff.active).toBe(true); // default preserved
});
it("buildStaff id override is respected without disrupting the counter", () => {
const staff = buildStaff({ id: "custom-id" });
expect(staff.id).toBe("custom-id");
// counter still ticked — next call gets staff-2
expect(buildStaff().id).toBe("staff-2");
});
it("buildClient applies overrides over defaults", () => {
const client = buildClient({ name: "Alice Smith", emailOptOut: true });
expect(client.name).toBe("Alice Smith");
expect(client.emailOptOut).toBe(true);
expect(client.status).toBe("active"); // default preserved
});
it("buildPet merges overrides and sets clientId from required arg", () => {
const pet = buildPet({ clientId: "client-99", name: "Fluffy", breed: "Poodle" });
expect(pet.clientId).toBe("client-99");
expect(pet.name).toBe("Fluffy");
expect(pet.breed).toBe("Poodle");
expect(pet.species).toBe("Dog"); // default preserved
});
it("buildService applies overrides over defaults", () => {
const service = buildService({ basePriceCents: 9900, active: false });
expect(service.basePriceCents).toBe(9900);
expect(service.active).toBe(false);
expect(service.durationMinutes).toBe(60); // default preserved
});
it("buildAppointment applies overrides over defaults", () => {
const client = buildClient();
const pet = buildPet({ clientId: client.id });
const service = buildService();
const appt = buildAppointment({
clientId: client.id,
petId: pet.id,
serviceId: service.id,
staffId: "staff-1",
status: "confirmed",
notes: "allergic to lavender",
});
expect(appt.status).toBe("confirmed");
expect(appt.notes).toBe("allergic to lavender");
expect(appt.clientId).toBe(client.id);
expect(appt.petId).toBe(pet.id);
// defaults preserved
expect(appt.batherStaffId).toBeNull();
expect(appt.priceCents).toBeNull();
});
});
describe("buildAppointment required fields", () => {
beforeEach(() => {
resetFactoryCounters();
});
it("produces a fully-populated AppointmentRow", () => {
const client = buildClient();
const pet = buildPet({ clientId: client.id });
const service = buildService();
const appt = buildAppointment({
clientId: client.id,
petId: pet.id,
serviceId: service.id,
staffId: "staff-1",
});
expect(appt.id).toBeDefined();
expect(appt.clientId).toBe(client.id);
expect(appt.petId).toBe(pet.id);
expect(appt.serviceId).toBe(service.id);
expect(appt.staffId).toBe("staff-1");
expect(appt.startTime).toBeInstanceOf(Date);
expect(appt.endTime).toBeInstanceOf(Date);
expect(appt.status).toBe("scheduled");
expect(appt.batherStaffId).toBeNull();
expect(appt.seriesId).toBeNull();
expect(appt.seriesIndex).toBeNull();
expect(appt.groupId).toBeNull();
expect(appt.notes).toBeNull();
expect(appt.priceCents).toBeNull();
expect(appt.createdAt).toBeInstanceOf(Date);
expect(appt.updatedAt).toBeInstanceOf(Date);
});
// TypeScript compile-time enforcement: omitting any required field produces a type error.
// The overrides parameter type is `Partial<AppointmentRow> & { clientId: string; petId: string; serviceId: string; staffId: string }`.
// The test below verifies the type signature is correct by using @ts-expect-error.
it("type error when required fields are missing — compile-time enforcement", () => {
// @ts-expect-error clientId is required
buildAppointment({ petId: "p", serviceId: "s", staffId: "st" });
// @ts-expect-error petId is required
buildAppointment({ clientId: "c", serviceId: "s", staffId: "st" });
// @ts-expect-error serviceId is required
buildAppointment({ clientId: "c", petId: "p", staffId: "st" });
// @ts-expect-error staffId is required
buildAppointment({ clientId: "c", petId: "p", serviceId: "s" });
});
});
+162
View File
@@ -0,0 +1,162 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Hono } from "hono";
// ─── Mock data ────────────────────────────────────────────────────────────────
const ACTIVE_CLIENT = {
id: "client-1",
name: "Alice Johnson",
email: "alice@example.com",
phone: "555-1234",
};
const PET_ROW = {
id: "pet-1",
name: "Bella",
breed: "Golden Retriever",
clientId: "client-1",
ownerName: "Alice Johnson",
};
// ─── Mock DB ──────────────────────────────────────────────────────────────────
let clientResults: typeof ACTIVE_CLIENT[] = [];
let petResults: typeof PET_ROW[] = [];
vi.mock("@groombook/db", () => {
// Proxy objects for table/column references — values don't matter for tests
const tableProxy = (name: string) =>
new Proxy(
{ _name: name },
{ get: (t, p) => (p === "_name" ? name : { table: name, column: p }) }
);
const clients = tableProxy("clients");
const pets = tableProxy("pets");
return {
getDb: () => ({
select: (_fields?: unknown) => {
// Route which mock results to use based on a global flag set per test
return {
from: (table: { _name?: string }) => {
const results = table._name === "pets" ? petResults : clientResults;
const chain: Record<string, unknown> = {};
chain.where = () => chain;
chain.innerJoin = () => chain;
chain.limit = () => Promise.resolve(results);
return chain;
},
};
},
}),
clients,
pets,
and: (...args: unknown[]) => ({ and: args }),
or: (...args: unknown[]) => ({ or: args }),
eq: (a: unknown, b: unknown) => ({ eq: [a, b] }),
ilike: (col: unknown, pat: unknown) => ({ ilike: [col, pat] }),
};
});
// ─── App under test ───────────────────────────────────────────────────────────
async function makeApp() {
const { searchRouter } = await import("../routes/search.js");
const app = new Hono();
app.route("/search", searchRouter);
return app;
}
// ─── Tests ────────────────────────────────────────────────────────────────────
beforeEach(() => {
vi.resetModules();
clientResults = [];
petResults = [];
});
describe("GET /search", () => {
it("returns 400 when q is missing", async () => {
const app = await makeApp();
const res = await app.request("/search");
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toBeTruthy();
});
it("returns 400 when q is empty string", async () => {
const app = await makeApp();
const res = await app.request("/search?q=");
expect(res.status).toBe(400);
});
it("returns 400 when q is only whitespace", async () => {
const app = await makeApp();
const res = await app.request("/search?q= ");
expect(res.status).toBe(400);
});
it("returns matching clients and pets", async () => {
clientResults = [ACTIVE_CLIENT];
petResults = [PET_ROW];
const app = await makeApp();
const res = await app.request("/search?q=bell");
expect(res.status).toBe(200);
const body = await res.json();
expect(body.clients).toEqual([ACTIVE_CLIENT]);
expect(body.pets).toEqual([PET_ROW]);
});
it("returns empty arrays when no matches", async () => {
clientResults = [];
petResults = [];
const app = await makeApp();
const res = await app.request("/search?q=xyzzy");
expect(res.status).toBe(200);
const body = await res.json();
expect(body.clients).toEqual([]);
expect(body.pets).toEqual([]);
});
it("returns shape with clients and pets keys", async () => {
const app = await makeApp();
const res = await app.request("/search?q=a");
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toHaveProperty("clients");
expect(body).toHaveProperty("pets");
expect(Array.isArray(body.clients)).toBe(true);
expect(Array.isArray(body.pets)).toBe(true);
});
it("handles special characters in query without throwing", async () => {
clientResults = [];
petResults = [];
const app = await makeApp();
// These characters should be escaped, not cause errors
const res = await app.request("/search?q=foo%25bar_baz");
expect(res.status).toBe(200);
});
});
describe("escapeLike helper (via integration)", () => {
it("% in query does not break the request", async () => {
clientResults = [];
petResults = [];
const app = await makeApp();
const res = await app.request("/search?q=%25");
expect(res.status).toBe(200);
});
it("_ in query does not break the request", async () => {
clientResults = [];
petResults = [];
const app = await makeApp();
const res = await app.request("/search?q=_");
expect(res.status).toBe(200);
});
});
+2
View File
@@ -14,6 +14,7 @@ import { appointmentGroupsRouter } from "./routes/appointmentGroups.js";
import { groomingLogsRouter } from "./routes/groomingLogs.js";
import { impersonationRouter } from "./routes/impersonation.js";
import { settingsRouter } from "./routes/settings.js";
import { searchRouter } from "./routes/search.js";
import { getDb, businessSettings } from "@groombook/db";
import { authMiddleware } from "./middleware/auth.js";
import { resolveStaffMiddleware, requireRole } from "./middleware/rbac.js";
@@ -98,6 +99,7 @@ api.route("/appointment-groups", appointmentGroupsRouter);
api.route("/grooming-logs", groomingLogsRouter);
api.route("/impersonation", impersonationRouter);
api.route("/admin/settings", settingsRouter);
api.route("/search", searchRouter);
const port = Number(process.env.PORT ?? 3000);
console.log(`API server listening on port ${port}`);
+70
View File
@@ -0,0 +1,70 @@
import { Hono } from "hono";
import { and, eq, getDb, clients, ilike, or, pets } from "@groombook/db";
export const searchRouter = new Hono();
const LIMIT = 10;
/** Escape %, _, and \ in user input before wrapping with ILIKE wildcards. */
function escapeLike(s: string): string {
return `%${s.replace(/[%_\\]/g, "\\$&")}%`;
}
/**
* GET /api/search?q={query}
*
* Returns up to 10 matching active clients and up to 10 matching pets.
* Clients are matched on name, email, or phone.
* Pets are matched on name or breed; includes owner name.
*/
searchRouter.get("/", async (c) => {
const q = c.req.query("q");
if (!q || q.trim().length === 0) {
return c.json({ error: "Query parameter q is required" }, 400);
}
const pattern = escapeLike(q.trim());
const db = getDb();
const [matchingClients, matchingPets] = await Promise.all([
db
.select({
id: clients.id,
name: clients.name,
email: clients.email,
phone: clients.phone,
})
.from(clients)
.where(
and(
eq(clients.status, "active"),
or(
ilike(clients.name, pattern),
ilike(clients.email, pattern),
ilike(clients.phone, pattern)
)
)
)
.limit(LIMIT),
db
.select({
id: pets.id,
name: pets.name,
breed: pets.breed,
clientId: pets.clientId,
ownerName: clients.name,
})
.from(pets)
.innerJoin(clients, and(eq(pets.clientId, clients.id), eq(clients.status, "active")))
.where(
or(
ilike(pets.name, pattern),
ilike(pets.breed, pattern)
)
)
.limit(LIMIT),
]);
return c.json({ clients: matchingClients, pets: matchingPets });
});
+2
View File
@@ -13,6 +13,7 @@ import { CustomerPortal } from "./portal/CustomerPortal.js";
import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js";
import { DevSessionIndicator } from "./components/DevSessionIndicator.js";
import { BrandingProvider, useBranding } from "./BrandingContext.js";
import { GlobalSearch } from "./components/GlobalSearch.js";
const NAV_LINKS = [
{ to: "/admin", label: "Appointments" },
@@ -68,6 +69,7 @@ function AdminLayout() {
{branding.businessName}
</strong>
</div>
<GlobalSearch />
<Link
to="/admin/book"
style={{
+12 -1
View File
@@ -1,4 +1,4 @@
import { createContext, useContext, useEffect, useState, useCallback } from "react";
import { createContext, useContext, useEffect, useRef, useState, useCallback } from "react";
export interface Branding {
businessName: string;
@@ -27,6 +27,7 @@ export function useBranding() {
export function BrandingProvider({ children }: { children: React.ReactNode }) {
const [branding, setBranding] = useState<Branding>(DEFAULT_BRANDING);
const metaThemeColorRef = useRef<HTMLMetaElement | null>(null);
const fetchBranding = useCallback(() => {
fetch("/api/branding")
@@ -45,6 +46,16 @@ export function BrandingProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
document.documentElement.style.setProperty("--color-primary", branding.primaryColor);
document.documentElement.style.setProperty("--color-accent", branding.accentColor);
// Keep PWA theme-color meta tag in sync with primary color
if (!metaThemeColorRef.current) {
metaThemeColorRef.current = document.querySelector<HTMLMetaElement>("meta[name='theme-color']");
if (!metaThemeColorRef.current) {
metaThemeColorRef.current = document.createElement("meta");
metaThemeColorRef.current.name = "theme-color";
document.head.appendChild(metaThemeColorRef.current);
}
}
metaThemeColorRef.current.content = branding.primaryColor;
}, [branding.primaryColor, branding.accentColor]);
return (
@@ -0,0 +1,106 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, waitFor } from "@testing-library/react";
import { BrandingProvider, useBranding } from "../BrandingContext.js";
function BrandingConsumer() {
const { branding } = useBranding();
return (
<div data-testid="branding">
<span data-testid="primary">{branding.primaryColor}</span>
<span data-testid="accent">{branding.accentColor}</span>
</div>
);
}
beforeEach(() => {
vi.restoreAllMocks();
document.documentElement.style.removeProperty("--color-primary");
document.documentElement.style.removeProperty("--color-accent");
// Remove any theme-color meta tags
document.querySelectorAll("meta[name='theme-color']").forEach((el) => el.remove());
});
describe("BrandingProvider", () => {
it("applies CSS vars to document root when branding loads", async () => {
const branding = {
businessName: "Test Salon",
primaryColor: "#123456",
accentColor: "#654321",
logoBase64: null,
logoMimeType: null,
};
global.fetch = vi.fn(() =>
Promise.resolve({ ok: true, json: async () => branding } as Response)
) as unknown as typeof fetch;
render(
<BrandingProvider>
<BrandingConsumer />
</BrandingProvider>
);
await waitFor(() => {
expect(document.documentElement.style.getPropertyValue("--color-primary")).toBe("#123456");
expect(document.documentElement.style.getPropertyValue("--color-accent")).toBe("#654321");
});
});
it("creates and updates meta[name=theme-color]", async () => {
const branding = {
businessName: "Test Salon",
primaryColor: "#abcdef",
accentColor: "#fedcba",
logoBase64: null,
logoMimeType: null,
};
global.fetch = vi.fn(() =>
Promise.resolve({ ok: true, json: async () => branding } as Response)
) as unknown as typeof fetch;
render(
<BrandingProvider>
<BrandingConsumer />
</BrandingProvider>
);
await waitFor(() => {
const meta = document.querySelector<HTMLMetaElement>("meta[name='theme-color']");
expect(meta).not.toBeNull();
expect(meta!.content).toBe("#abcdef");
});
});
it("does not create duplicate meta[name=theme-color] tags on rerender", async () => {
const branding = {
businessName: "Test Salon",
primaryColor: "#111111",
accentColor: "#222222",
logoBase64: null,
logoMimeType: null,
};
global.fetch = vi.fn(() =>
Promise.resolve({ ok: true, json: async () => branding } as Response)
) as unknown as typeof fetch;
const { rerender } = render(
<BrandingProvider>
<BrandingConsumer />
</BrandingProvider>
);
await waitFor(() => {
expect(document.querySelector("meta[name='theme-color']")).not.toBeNull();
});
rerender(
<BrandingProvider>
<BrandingConsumer />
</BrandingProvider>
);
await waitFor(() => {
const metas = document.querySelectorAll("meta[name='theme-color']");
expect(metas.length).toBe(1);
});
});
});
@@ -29,7 +29,7 @@ export function DevSessionIndicator() {
<Link
to="/login"
style={{
color: "#4f8a6f",
color: "var(--color-primary)",
textDecoration: "underline",
fontSize: 12,
}}
+277
View File
@@ -0,0 +1,277 @@
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 [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);
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(trimmed)}`);
if (res.ok) {
const data: SearchResults = await res.json();
setResults(data);
setOpen(true);
}
} catch (err) {
console.warn("GlobalSearch: fetch error", err);
} 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 && !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>
);
}
+7 -2
View File
@@ -2,7 +2,12 @@
:root {
--color-primary: #4f8a6f;
--color-primary-dark: color-mix(in srgb, var(--color-primary) 80%, #000);
--color-accent: #8b7355;
--color-accent-hover: color-mix(in srgb, var(--color-accent) 88%, #000);
--color-accent-dark: color-mix(in srgb, var(--color-accent) 78%, #000);
--color-accent-light: color-mix(in srgb, var(--color-accent) 18%, #fff);
--color-accent-lighter: color-mix(in srgb, var(--color-accent) 9%, #fff);
}
*, *::before, *::after {
@@ -51,8 +56,8 @@ button:active:not(:disabled) {
/* ─── Admin input / select focus states ─── */
input:focus, select:focus, textarea:focus {
outline: none;
border-color: #4f8a6f;
box-shadow: 0 0 0 3px rgba(79, 138, 111, 0.12);
border-color: var(--color-primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 12%, transparent);
}
/* ─── Admin card-like containers (borders get subtle shadow) ─── */
+3 -3
View File
@@ -291,7 +291,7 @@ export function AppointmentsPage() {
</button>
<button
onClick={() => openNewForm()}
style={{ ...btnStyle, backgroundColor: "#4f8a6f", color: "#fff", marginLeft: "auto", borderColor: "#4f8a6f" }}
style={{ ...btnStyle, backgroundColor: "var(--color-primary)", color: "#fff", marginLeft: "auto", borderColor: "var(--color-primary)" }}
>
+ New Appointment
</button>
@@ -374,7 +374,7 @@ export function AppointmentsPage() {
<div
style={{
padding: "0.4rem 0.6rem",
background: isToday ? "linear-gradient(135deg, #4f8a6f, #3d7a5f)" : "#f8fafc",
background: isToday ? "linear-gradient(135deg, var(--color-primary), var(--color-primary-dark))" : "#f8fafc",
color: isToday ? "#fff" : "#374151",
fontWeight: 600,
fontSize: 12,
@@ -594,7 +594,7 @@ export function AppointmentsPage() {
<button
type="submit"
disabled={saving}
style={{ ...btnStyle, backgroundColor: "#4f8a6f", color: "#fff", borderColor: "#4f8a6f" }}
style={{ ...btnStyle, backgroundColor: "var(--color-primary)", color: "#fff", borderColor: "var(--color-primary)" }}
>
{saving
? "Saving…"
+8 -8
View File
@@ -66,8 +66,8 @@ function StepIndicator({ step }: { step: number }) {
padding: "0.5rem 0.25rem",
fontSize: 12,
fontWeight: active ? 700 : 400,
color: active ? "#4f8a6f" : done ? "#4f8a6f" : "#9ca3af",
borderBottom: `3px solid ${active ? "#4f8a6f" : done ? "#4f8a6f" : "#e5e7eb"}`,
color: active ? "var(--color-primary)" : done ? "var(--color-primary)" : "#9ca3af",
borderBottom: `3px solid ${active ? "var(--color-primary)" : done ? "var(--color-primary)" : "#e5e7eb"}`,
}}
>
<span
@@ -78,7 +78,7 @@ function StepIndicator({ step }: { step: number }) {
width: 22,
height: 22,
borderRadius: "50%",
background: active ? "#4f8a6f" : done ? "#4f8a6f" : "#e5e7eb",
background: active ? "var(--color-primary)" : done ? "var(--color-primary)" : "#e5e7eb",
color: active || done ? "#fff" : "#6b7280",
fontSize: 12,
fontWeight: 700,
@@ -218,7 +218,7 @@ export function BookPage() {
const selectedCard: React.CSSProperties = {
...card,
border: "2px solid #4f8a6f",
border: "2px solid var(--color-primary)",
background: "#f0faf5",
};
@@ -250,7 +250,7 @@ export function BookPage() {
const primaryBtn: React.CSSProperties = {
...btn,
background: "#4f8a6f",
background: "var(--color-primary)",
color: "#fff",
};
@@ -301,7 +301,7 @@ export function BookPage() {
)}
</div>
<div style={{ textAlign: "right", flexShrink: 0, marginLeft: "1rem" }}>
<div style={{ fontWeight: 700, color: "#4f8a6f", fontSize: 15 }}>
<div style={{ fontWeight: 700, color: "var(--color-primary)", fontSize: 15 }}>
{fmtPrice(svc.basePriceCents)}
</div>
<div style={{ fontSize: 12, color: "#9ca3af" }}>{fmtDuration(svc.durationMinutes)}</div>
@@ -349,8 +349,8 @@ export function BookPage() {
style={{
padding: "0.4rem 0.85rem",
borderRadius: 6,
border: `2px solid ${selectedSlot === slot ? "#4f8a6f" : "#d1d5db"}`,
background: selectedSlot === slot ? "#4f8a6f" : "#fff",
border: `2px solid ${selectedSlot === slot ? "var(--color-primary)" : "#d1d5db"}`,
background: selectedSlot === slot ? "var(--color-primary)" : "#fff",
color: selectedSlot === slot ? "#fff" : "#374151",
fontSize: 13,
fontWeight: 500,
+3 -3
View File
@@ -367,7 +367,7 @@ export function ClientsPage() {
<h1 style={{ margin: 0, fontSize: 20 }}>Clients</h1>
<button
onClick={openNewClient}
style={{ ...btnStyle, backgroundColor: "#4f8a6f", color: "#fff", borderColor: "#4f8a6f", marginLeft: "auto", padding: "0.3rem 0.7rem" }}
style={{ ...btnStyle, backgroundColor: "var(--color-primary)", color: "#fff", borderColor: "var(--color-primary)", marginLeft: "auto", padding: "0.3rem 0.7rem" }}
>
+ New
</button>
@@ -622,7 +622,7 @@ export function ClientsPage() {
</Field>
{clientFormError && <p style={{ color: "red", margin: "0.5rem 0 0" }}>{clientFormError}</p>}
<div style={{ display: "flex", gap: "0.5rem", marginTop: "1rem" }}>
<button type="submit" disabled={savingClient} style={{ ...btnStyle, backgroundColor: "#4f8a6f", color: "#fff", borderColor: "#4f8a6f" }}>
<button type="submit" disabled={savingClient} style={{ ...btnStyle, backgroundColor: "var(--color-primary)", color: "#fff", borderColor: "var(--color-primary)" }}>
{savingClient ? "Saving…" : editingClient ? "Save Changes" : "Create Client"}
</button>
<button type="button" onClick={() => setShowClientForm(false)} style={btnStyle}>Cancel</button>
@@ -761,7 +761,7 @@ export function ClientsPage() {
</Field>
{logFormError && <p style={{ color: "red", margin: "0.5rem 0 0" }}>{logFormError}</p>}
<div style={{ display: "flex", gap: "0.5rem", marginTop: "1rem" }}>
<button type="submit" disabled={savingLog} style={{ ...btnStyle, backgroundColor: "#4f8a6f", color: "#fff", borderColor: "#4f8a6f" }}>
<button type="submit" disabled={savingLog} style={{ ...btnStyle, backgroundColor: "var(--color-primary)", color: "#fff", borderColor: "var(--color-primary)" }}>
{savingLog ? "Saving…" : "Save Visit Log"}
</button>
<button type="button" onClick={() => setShowLogForm(false)} style={btnStyle}>Cancel</button>
+2 -2
View File
@@ -287,7 +287,7 @@ function NewGroupBookingForm({
<button
type="submit"
disabled={saving}
style={{ ...btnStyle, backgroundColor: "#4f8a6f", color: "#fff", borderColor: "#4f8a6f" }}
style={{ ...btnStyle, backgroundColor: "var(--color-primary)", color: "#fff", borderColor: "var(--color-primary)" }}
>
{saving ? "Booking…" : "Create Group Booking"}
</button>
@@ -471,7 +471,7 @@ export function GroupBookingPage() {
</select>
<button
onClick={() => setShowCreate(true)}
style={{ ...btnStyle, marginLeft: "auto", backgroundColor: "#4f8a6f", color: "#fff", borderColor: "#4f8a6f" }}
style={{ ...btnStyle, marginLeft: "auto", backgroundColor: "var(--color-primary)", color: "#fff", borderColor: "var(--color-primary)" }}
>
+ New Group Booking
</button>
+2 -2
View File
@@ -129,7 +129,7 @@ function CreateFromAppointmentForm({
<button
type="submit"
disabled={saving || !selectedApptId}
style={{ ...btnStyle, backgroundColor: "#4f8a6f", color: "#fff", borderColor: "#4f8a6f" }}
style={{ ...btnStyle, backgroundColor: "var(--color-primary)", color: "#fff", borderColor: "var(--color-primary)" }}
>
{saving ? "Creating…" : "Create Invoice"}
</button>
@@ -540,7 +540,7 @@ export function InvoicesPage() {
</select>
<button
onClick={() => setShowCreate(true)}
style={{ ...btnStyle, backgroundColor: "#4f8a6f", color: "#fff", borderColor: "#4f8a6f", marginLeft: "auto" }}
style={{ ...btnStyle, backgroundColor: "var(--color-primary)", color: "#fff", borderColor: "var(--color-primary)", marginLeft: "auto" }}
>
+ Create Invoice
</button>
+1 -1
View File
@@ -270,7 +270,7 @@ export function ReportsPage() {
<option value="month">Month</option>
</select>
</label>
<button onClick={loadAll} style={{ ...btnStyle, background: "#4f8a6f", color: "#fff", borderColor: "#4f8a6f" }}>
<button onClick={loadAll} style={{ ...btnStyle, background: "var(--color-primary)", color: "#fff", borderColor: "var(--color-primary)" }}>
{loading ? "Loading…" : "Refresh"}
</button>
<div style={{ marginLeft: "auto", display: "flex", gap: "0.5rem" }}>
+2 -2
View File
@@ -119,7 +119,7 @@ export function ServicesPage() {
<h1 style={{ margin: 0 }}>Services</h1>
<button
onClick={openNew}
style={{ ...btnStyle, backgroundColor: "#4f8a6f", color: "#fff", borderColor: "#4f8a6f", marginLeft: "auto" }}
style={{ ...btnStyle, backgroundColor: "var(--color-primary)", color: "#fff", borderColor: "var(--color-primary)", marginLeft: "auto" }}
>
+ Add Service
</button>
@@ -232,7 +232,7 @@ export function ServicesPage() {
<button
type="submit"
disabled={saving}
style={{ ...btnStyle, backgroundColor: "#4f8a6f", color: "#fff", borderColor: "#4f8a6f" }}
style={{ ...btnStyle, backgroundColor: "var(--color-primary)", color: "#fff", borderColor: "var(--color-primary)" }}
>
{saving ? "Saving…" : editing ? "Save Changes" : "Create Service"}
</button>
+2 -2
View File
@@ -78,7 +78,7 @@ export function StaffPage() {
<div style={{ fontFamily: "system-ui, sans-serif" }}>
<div style={{ display: "flex", alignItems: "center", gap: "1rem", marginBottom: "1rem" }}>
<h1 style={{ margin: 0 }}>Staff</h1>
<button onClick={openNew} style={{ ...btnStyle, backgroundColor: "#4f8a6f", color: "#fff", borderColor: "#4f8a6f", marginLeft: "auto" }}>
<button onClick={openNew} style={{ ...btnStyle, backgroundColor: "var(--color-primary)", color: "#fff", borderColor: "var(--color-primary)", marginLeft: "auto" }}>
+ Add Staff
</button>
</div>
@@ -145,7 +145,7 @@ export function StaffPage() {
</div>
{formError && <p style={{ color: "red", margin: "0.5rem 0 0" }}>{formError}</p>}
<div style={{ display: "flex", gap: "0.5rem", marginTop: "1rem" }}>
<button type="submit" disabled={saving} style={{ ...btnStyle, backgroundColor: "#4f8a6f", color: "#fff", borderColor: "#4f8a6f" }}>
<button type="submit" disabled={saving} style={{ ...btnStyle, backgroundColor: "var(--color-primary)", color: "#fff", borderColor: "var(--color-primary)" }}>
{saving ? "Saving…" : editing ? "Save Changes" : "Add Staff"}
</button>
<button type="button" onClick={() => setShowForm(false)} style={btnStyle}>Cancel</button>
@@ -22,7 +22,7 @@ export function AccountSettings({ readOnly }: Props) {
key={id}
onClick={() => setTab(id)}
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium ${
tab === id ? "bg-[#f0ebe4] text-[#6b5a42]" : "text-stone-500 hover:bg-stone-50"
tab === id ? "bg-(--color-accent-light) text-(--color-accent-dark)" : "text-stone-500 hover:bg-stone-50"
}`}
>
<Icon size={14} />
@@ -69,7 +69,7 @@ function PersonalInfo({ readOnly }: { readOnly: boolean }) {
</div>
))}
{!readOnly && (
<button className="px-4 py-2 bg-[#8b7355] text-white rounded-lg text-sm font-medium hover:bg-[#7a6549]">
<button className="px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover)">
Save Changes
</button>
)}
@@ -103,7 +103,7 @@ function PasswordChange({ readOnly }: { readOnly: boolean }) {
<label className="block text-sm font-medium text-stone-700 mb-1">Confirm New Password</label>
<input type="password" className="w-full border border-stone-300 rounded-lg px-3 py-2 text-sm" />
</div>
<button className="px-4 py-2 bg-[#8b7355] text-white rounded-lg text-sm font-medium hover:bg-[#7a6549]">
<button className="px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover)">
Update Password
</button>
</div>
@@ -116,7 +116,7 @@ function ManagePets({ readOnly }: { readOnly: boolean }) {
<div className="space-y-4">
{PETS.map(pet => (
<div key={pet.id} className="bg-white rounded-2xl border border-stone-200 p-4 shadow-sm flex items-center gap-4">
<div className="w-14 h-14 rounded-xl bg-[#f0ebe4] flex items-center justify-center text-3xl">
<div className="w-14 h-14 rounded-xl bg-(--color-accent-light) flex items-center justify-center text-3xl">
{pet.photo}
</div>
<div className="flex-1">
@@ -136,7 +136,7 @@ function ManagePets({ readOnly }: { readOnly: boolean }) {
</div>
))}
{!readOnly && (
<button className="w-full flex items-center justify-center gap-2 py-3 border-2 border-dashed border-stone-300 rounded-2xl text-sm text-stone-500 hover:border-[#8b7355] hover:text-[#6b5a42] transition-colors">
<button className="w-full flex items-center justify-center gap-2 py-3 border-2 border-dashed border-stone-300 rounded-2xl text-sm text-stone-500 hover:border-(--color-accent) hover:text-(--color-accent-dark) transition-colors">
<Plus size={16} />
Add New Pet
</button>
@@ -165,7 +165,7 @@ function Agreements() {
{new Date(agr.dateSigned).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
</td>
<td className="px-5 py-3">
<button className="text-sm text-[#6b5a42] font-medium hover:underline">View</button>
<button className="text-sm text-(--color-accent-dark) font-medium hover:underline">View</button>
</td>
</tr>
))}
+17 -17
View File
@@ -30,13 +30,13 @@ export function AppointmentsSection({ readOnly }: Props) {
<div className="flex gap-2">
<button
onClick={() => setTab("upcoming")}
className={`px-4 py-2 rounded-lg text-sm font-medium ${tab === "upcoming" ? "bg-[#f0ebe4] text-[#6b5a42]" : "text-stone-500 hover:bg-stone-50"}`}
className={`px-4 py-2 rounded-lg text-sm font-medium ${tab === "upcoming" ? "bg-(--color-accent-light) text-(--color-accent-dark)" : "text-stone-500 hover:bg-stone-50"}`}
>
Upcoming ({UPCOMING_APPOINTMENTS.length})
</button>
<button
onClick={() => setTab("past")}
className={`px-4 py-2 rounded-lg text-sm font-medium ${tab === "past" ? "bg-[#f0ebe4] text-[#6b5a42]" : "text-stone-500 hover:bg-stone-50"}`}
className={`px-4 py-2 rounded-lg text-sm font-medium ${tab === "past" ? "bg-(--color-accent-light) text-(--color-accent-dark)" : "text-stone-500 hover:bg-stone-50"}`}
>
Past ({PAST_APPOINTMENTS.length})
</button>
@@ -44,7 +44,7 @@ export function AppointmentsSection({ readOnly }: Props) {
{!readOnly && (
<button
onClick={() => setShowBooking(true)}
className="flex items-center gap-1.5 px-4 py-2 bg-[#8b7355] text-white rounded-lg text-sm font-medium hover:bg-[#7a6549]"
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)"
>
<Plus size={16} />
Book New
@@ -101,7 +101,7 @@ function AppointmentCard({
return (
<div className="bg-white rounded-xl border border-stone-200 shadow-sm overflow-hidden">
<button onClick={onToggle} className="w-full flex items-center gap-4 p-4 text-left hover:bg-stone-50">
<div className="w-10 h-10 rounded-lg bg-[#f0ebe4] flex items-center justify-center text-lg shrink-0">
<div className="w-10 h-10 rounded-lg bg-(--color-accent-light) flex items-center justify-center text-lg shrink-0">
{PETS.find(p => p.id === appt.petId)?.photo || "🐾"}
</div>
<div className="flex-1 min-w-0">
@@ -150,7 +150,7 @@ function AppointmentCard({
)}
{appt.reportCardId && (
<div className="mt-2">
<span className="text-xs text-[#6b5a42] font-medium cursor-pointer hover:underline">
<span className="text-xs text-(--color-accent-dark) font-medium cursor-pointer hover:underline">
View Report Card
</span>
</div>
@@ -190,7 +190,7 @@ function BookingFlow({ onClose, readOnly }: { onClose: () => void; readOnly: boo
{/* Step Indicator */}
<div className="flex items-center gap-1 px-5 pt-4">
{[1, 2, 3, 4, 5].map(s => (
<div key={s} className={`flex-1 h-1.5 rounded-full ${s <= step ? "bg-[#8b7355]" : "bg-stone-200"}`} />
<div key={s} className={`flex-1 h-1.5 rounded-full ${s <= step ? "bg-(--color-accent)" : "bg-stone-200"}`} />
))}
</div>
@@ -202,7 +202,7 @@ function BookingFlow({ onClose, readOnly }: { onClose: () => void; readOnly: boo
<p className="text-sm text-stone-500 mb-4">
{selectedPet?.name} with {selectedGroomer?.name || "First Available"} on {formatDate(selectedDate)} at {selectedTime}
</p>
<button onClick={onClose} className="px-4 py-2 bg-[#8b7355] text-white rounded-lg text-sm font-medium">
<button onClick={onClose} className="px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium">
Done
</button>
</div>
@@ -218,7 +218,7 @@ function BookingFlow({ onClose, readOnly }: { onClose: () => void; readOnly: boo
key={pet.id}
onClick={() => { setSelectedPet(pet); setStep(2); }}
className={`w-full flex items-center gap-3 p-3 rounded-xl border text-left transition-colors ${
selectedPet?.id === pet.id ? "border-[#8b7355] bg-[#faf5ef]" : "border-stone-200 hover:border-stone-300"
selectedPet?.id === pet.id ? "border-(--color-accent) bg-(--color-accent-lighter)" : "border-stone-200 hover:border-stone-300"
}`}
>
<span className="text-2xl">{pet.photo}</span>
@@ -246,7 +246,7 @@ function BookingFlow({ onClose, readOnly }: { onClose: () => void; readOnly: boo
);
}}
className={`w-full flex items-center justify-between p-3 rounded-xl border text-left ${
selectedServices.find(s => s.id === svc.id) ? "border-[#8b7355] bg-[#faf5ef]" : "border-stone-200 hover:border-stone-300"
selectedServices.find(s => s.id === svc.id) ? "border-(--color-accent) bg-(--color-accent-lighter)" : "border-stone-200 hover:border-stone-300"
}`}
>
<div>
@@ -273,7 +273,7 @@ function BookingFlow({ onClose, readOnly }: { onClose: () => void; readOnly: boo
);
}}
className={`w-full flex items-center justify-between p-2.5 rounded-lg border text-left text-sm ${
selectedAddOns.find(s => s.id === svc.id) ? "border-[#8b7355] bg-[#faf5ef]" : "border-stone-200 hover:border-stone-300"
selectedAddOns.find(s => s.id === svc.id) ? "border-(--color-accent) bg-(--color-accent-lighter)" : "border-stone-200 hover:border-stone-300"
}`}
>
<div>
@@ -291,7 +291,7 @@ function BookingFlow({ onClose, readOnly }: { onClose: () => void; readOnly: boo
<button
onClick={() => setStep(3)}
disabled={selectedServices.length === 0}
className="flex-1 px-4 py-2 bg-[#8b7355] text-white rounded-lg text-sm font-medium disabled:opacity-50"
className="flex-1 px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium disabled:opacity-50"
>
Next
</button>
@@ -307,7 +307,7 @@ function BookingFlow({ onClose, readOnly }: { onClose: () => void; readOnly: boo
<button
onClick={() => { setSelectedGroomer(null); setStep(4); }}
className={`w-full flex items-center gap-3 p-3 rounded-xl border text-left ${
selectedGroomer === null ? "border-[#8b7355] bg-[#faf5ef]" : "border-stone-200 hover:border-stone-300"
selectedGroomer === null ? "border-(--color-accent) bg-(--color-accent-lighter)" : "border-stone-200 hover:border-stone-300"
}`}
>
<div className="w-10 h-10 rounded-full bg-stone-100 flex items-center justify-center">
@@ -323,10 +323,10 @@ function BookingFlow({ onClose, readOnly }: { onClose: () => void; readOnly: boo
key={g.id}
onClick={() => { setSelectedGroomer(g); setStep(4); }}
className={`w-full flex items-center gap-3 p-3 rounded-xl border text-left ${
selectedGroomer?.id === g.id ? "border-[#8b7355] bg-[#faf5ef]" : "border-stone-200 hover:border-stone-300"
selectedGroomer?.id === g.id ? "border-(--color-accent) bg-(--color-accent-lighter)" : "border-stone-200 hover:border-stone-300"
}`}
>
<div className="w-10 h-10 rounded-full bg-[#f0ebe4] flex items-center justify-center text-xl">
<div className="w-10 h-10 rounded-full bg-(--color-accent-light) flex items-center justify-center text-xl">
{g.avatar}
</div>
<div>
@@ -358,7 +358,7 @@ function BookingFlow({ onClose, readOnly }: { onClose: () => void; readOnly: boo
key={time}
onClick={() => setSelectedTime(time)}
className={`px-3 py-2 rounded-lg text-sm border ${
selectedTime === time ? "border-[#8b7355] bg-[#faf5ef] font-medium" : "border-stone-200 hover:border-stone-300"
selectedTime === time ? "border-(--color-accent) bg-(--color-accent-lighter) font-medium" : "border-stone-200 hover:border-stone-300"
}`}
>
{time}
@@ -387,7 +387,7 @@ function BookingFlow({ onClose, readOnly }: { onClose: () => void; readOnly: boo
<button
onClick={() => setStep(5)}
disabled={!selectedDate || !selectedTime}
className="flex-1 px-4 py-2 bg-[#8b7355] text-white rounded-lg text-sm font-medium disabled:opacity-50"
className="flex-1 px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium disabled:opacity-50"
>
Next
</button>
@@ -426,7 +426,7 @@ function BookingFlow({ onClose, readOnly }: { onClose: () => void; readOnly: boo
<button onClick={() => setStep(4)} className="flex-1 px-4 py-2 border border-stone-200 rounded-lg text-sm">Back</button>
<button
onClick={() => setConfirmed(true)}
className="flex-1 px-4 py-2 bg-[#8b7355] text-white rounded-lg text-sm font-medium hover:bg-[#7a6549]"
className="flex-1 px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover)"
>
Confirm Booking
</button>
@@ -38,7 +38,7 @@ export function BillingPayments({ readOnly }: Props) {
>
Add Tip
</button>
<button className="px-6 py-2 bg-[#8b7355] text-white rounded-lg text-sm font-medium hover:bg-[#7a6549]">
<button className="px-6 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover)">
Pay Now
</button>
</div>
@@ -57,7 +57,7 @@ export function BillingPayments({ readOnly }: Props) {
key={id}
onClick={() => setTab(id)}
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium ${
tab === id ? "bg-[#f0ebe4] text-[#6b5a42]" : "text-stone-500 hover:bg-stone-50"
tab === id ? "bg-(--color-accent-light) text-(--color-accent-dark)" : "text-stone-500 hover:bg-stone-50"
}`}
>
<Icon size={14} />
@@ -134,7 +134,7 @@ export function BillingPayments({ readOnly }: Props) {
</div>
))}
{!readOnly && (
<button className="flex items-center gap-2 text-sm text-[#6b5a42] font-medium hover:underline mt-2">
<button className="flex items-center gap-2 text-sm text-(--color-accent-dark) font-medium hover:underline mt-2">
<Plus size={14} />
Add Payment Method
</button>
@@ -145,8 +145,8 @@ export function BillingPayments({ readOnly }: Props) {
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-[#f0ebe4] flex items-center justify-center">
<Zap size={18} className="text-[#8b7355]" />
<div className="w-10 h-10 rounded-lg bg-(--color-accent-light) flex items-center justify-center">
<Zap size={18} className="text-(--color-accent)" />
</div>
<div>
<p className="text-sm font-medium text-stone-800">Autopay</p>
@@ -156,7 +156,7 @@ export function BillingPayments({ readOnly }: Props) {
{!readOnly ? (
<button
onClick={() => setAutopay(!autopay)}
className={`w-12 h-6 rounded-full transition-colors ${autopay ? "bg-[#8b7355]" : "bg-stone-300"}`}
className={`w-12 h-6 rounded-full transition-colors ${autopay ? "bg-(--color-accent)" : "bg-stone-300"}`}
>
<div className={`w-5 h-5 bg-white rounded-full shadow transition-transform ${autopay ? "translate-x-6" : "translate-x-0.5"}`} />
</button>
@@ -174,7 +174,7 @@ export function BillingPayments({ readOnly }: Props) {
{PREPAID_PACKAGES.map(pkg => (
<div key={pkg.id} className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
<div className="flex items-center gap-3 mb-3">
<Package size={20} className="text-[#8b7355]" />
<Package size={20} className="text-(--color-accent)" />
<h3 className="font-medium text-stone-800">{pkg.name}</h3>
</div>
<div className="flex items-center gap-4 mb-3">
@@ -184,7 +184,7 @@ export function BillingPayments({ readOnly }: Props) {
</div>
<div className="flex-1 bg-stone-100 rounded-full h-3 overflow-hidden">
<div
className="bg-[#8b7355] h-full rounded-full"
className="bg-(--color-accent) h-full rounded-full"
style={{ width: `${((pkg.totalCredits - pkg.usedCredits) / pkg.totalCredits) * 100}%` }}
/>
</div>
@@ -218,7 +218,7 @@ function TipModal({ onClose }: { onClose: () => void }) {
key={pct}
onClick={() => { setTipPercent(pct); setCustomTip(""); }}
className={`flex-1 py-2 rounded-lg text-sm font-medium border ${
tipPercent === pct ? "border-[#8b7355] bg-[#faf5ef] text-[#6b5a42]" : "border-stone-200 text-stone-600"
tipPercent === pct ? "border-(--color-accent) bg-(--color-accent-lighter) text-(--color-accent-dark)" : "border-stone-200 text-stone-600"
}`}
>
{pct}%
@@ -227,7 +227,7 @@ function TipModal({ onClose }: { onClose: () => void }) {
<button
onClick={() => { setTipPercent(null); }}
className={`flex-1 py-2 rounded-lg text-sm font-medium border ${
tipPercent === null ? "border-[#8b7355] bg-[#faf5ef] text-[#6b5a42]" : "border-stone-200 text-stone-600"
tipPercent === null ? "border-(--color-accent) bg-(--color-accent-lighter) text-(--color-accent-dark)" : "border-stone-200 text-stone-600"
}`}
>
Custom
@@ -244,7 +244,7 @@ function TipModal({ onClose }: { onClose: () => void }) {
)}
<div className="flex gap-2">
<button onClick={onClose} className="flex-1 px-4 py-2 border border-stone-200 rounded-lg text-sm">Cancel</button>
<button onClick={onClose} className="flex-1 px-4 py-2 bg-[#8b7355] text-white rounded-lg text-sm font-medium">Add Tip</button>
<button onClick={onClose} className="flex-1 px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium">Add Tip</button>
</div>
</div>
</div>
@@ -16,7 +16,7 @@ export function Communication({ readOnly }: Props) {
<button
onClick={() => setTab("messages")}
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium ${
tab === "messages" ? "bg-[#f0ebe4] text-[#6b5a42]" : "text-stone-500 hover:bg-stone-50"
tab === "messages" ? "bg-(--color-accent-light) text-(--color-accent-dark)" : "text-stone-500 hover:bg-stone-50"
}`}
>
Messages
@@ -24,7 +24,7 @@ export function Communication({ readOnly }: Props) {
<button
onClick={() => setTab("notifications")}
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium ${
tab === "notifications" ? "bg-[#f0ebe4] text-[#6b5a42]" : "text-stone-500 hover:bg-stone-50"
tab === "notifications" ? "bg-(--color-accent-light) text-(--color-accent-dark)" : "text-stone-500 hover:bg-stone-50"
}`}
>
<Bell size={14} />
@@ -68,7 +68,7 @@ function MessageThread({ readOnly }: { readOnly: boolean }) {
<div key={msg.id} className={`flex ${msg.sender === "customer" ? "justify-end" : "justify-start"}`}>
<div className={`max-w-[80%] rounded-2xl px-4 py-2.5 ${
msg.sender === "customer"
? "bg-[#8b7355] text-white rounded-br-md"
? "bg-(--color-accent) text-white rounded-br-md"
: "bg-stone-100 text-stone-800 rounded-bl-md"
}`}>
<p className="text-sm">{msg.text}</p>
@@ -95,12 +95,12 @@ function MessageThread({ readOnly }: { readOnly: boolean }) {
onChange={e => setNewMessage(e.target.value)}
onKeyDown={e => e.key === "Enter" && handleSend()}
placeholder="Type a message..."
className="flex-1 border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#8b7355]/30 focus:border-[#8b7355]"
className="flex-1 border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)/30 focus:border-(--color-accent)"
/>
<button
onClick={handleSend}
disabled={!newMessage.trim()}
className="px-4 py-2 bg-[#8b7355] text-white rounded-lg hover:bg-[#7a6549] disabled:opacity-50"
className="px-4 py-2 bg-(--color-accent) text-white rounded-lg hover:bg-(--color-accent-hover) disabled:opacity-50"
>
<Send size={16} />
</button>
@@ -177,7 +177,7 @@ function NotificationPreferences({ readOnly }: { readOnly: boolean }) {
onClick={() => toggle(cat.key, ch.key)}
disabled={readOnly}
className={`w-10 h-5 rounded-full transition-colors inline-block ${
prefs[cat.key][ch.key] ? "bg-[#8b7355]" : "bg-stone-300"
prefs[cat.key][ch.key] ? "bg-(--color-accent)" : "bg-stone-300"
} ${readOnly ? "cursor-not-allowed opacity-60" : ""}`}
>
<div className={`w-4 h-4 bg-white rounded-full shadow transition-transform ${
+8 -8
View File
@@ -42,7 +42,7 @@ export function Dashboard({ onNavigate, readOnly }: Props) {
{nextAppt && (
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2 text-sm font-medium text-[#6b5a42]">
<div className="flex items-center gap-2 text-sm font-medium text-(--color-accent-dark)">
<Calendar size={16} />
Next Appointment
</div>
@@ -71,7 +71,7 @@ export function Dashboard({ onNavigate, readOnly }: Props) {
</div>
</div>
<div className="text-center sm:text-right">
<div className="text-3xl font-bold text-[#6b5a42]">{daysUntil(nextAppt.date)}</div>
<div className="text-3xl font-bold text-(--color-accent-dark)">{daysUntil(nextAppt.date)}</div>
<div className="text-xs text-stone-500">days away</div>
</div>
</div>
@@ -103,7 +103,7 @@ export function Dashboard({ onNavigate, readOnly }: Props) {
className="bg-white rounded-2xl border border-stone-200 p-4 shadow-sm text-left hover:border-stone-300 transition-colors"
>
<div className="flex items-center gap-3 mb-3">
<div className="w-12 h-12 rounded-full bg-[#f0ebe4] flex items-center justify-center text-2xl">
<div className="w-12 h-12 rounded-full bg-(--color-accent-light) flex items-center justify-center text-2xl">
{pet.photo}
</div>
<div>
@@ -128,14 +128,14 @@ export function Dashboard({ onNavigate, readOnly }: Props) {
{/* Loyalty Card */}
<div className="bg-white rounded-2xl border border-stone-200 p-4 shadow-sm">
<div className="flex items-center gap-2 text-sm font-medium text-[#6b5a42] mb-3">
<div className="flex items-center gap-2 text-sm font-medium text-(--color-accent-dark) mb-3">
<Star size={16} />
Loyalty Rewards
</div>
<p className="text-2xl font-bold text-stone-800">{LOYALTY.points} <span className="text-sm font-normal text-stone-500">pts</span></p>
<div className="mt-2 bg-stone-100 rounded-full h-2 overflow-hidden">
<div
className="bg-[#8b7355] h-full rounded-full transition-all"
className="bg-(--color-accent) h-full rounded-full transition-all"
style={{ width: `${(LOYALTY.points / LOYALTY.nextRewardAt) * 100}%` }}
/>
</div>
@@ -161,7 +161,7 @@ export function Dashboard({ onNavigate, readOnly }: Props) {
{!readOnly && (
<button
onClick={() => onNavigate("billing")}
className="px-4 py-2 bg-[#8b7355] text-white rounded-lg text-sm font-medium hover:bg-[#7a6549]"
className="px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover)"
>
Pay Now
</button>
@@ -176,7 +176,7 @@ export function Dashboard({ onNavigate, readOnly }: Props) {
<div className="space-y-2.5">
{recentEvents.map(evt => (
<div key={evt.id} className="flex items-center gap-3 text-sm">
<div className={`w-2 h-2 rounded-full shrink-0 ${evt.type === "payment" ? "bg-green-400" : "bg-[#8b7355]"}`} />
<div className={`w-2 h-2 rounded-full shrink-0 ${evt.type === "payment" ? "bg-green-400" : "bg-(--color-accent)"}`} />
<span className="text-stone-600 flex-1">{evt.text}</span>
<span className="text-xs text-stone-400">{formatDate(evt.date)}</span>
</div>
@@ -184,7 +184,7 @@ export function Dashboard({ onNavigate, readOnly }: Props) {
</div>
<button
onClick={() => onNavigate("appointments")}
className="flex items-center gap-1 text-sm text-[#6b5a42] font-medium mt-3 hover:text-[#8b7355]"
className="flex items-center gap-1 text-sm text-(--color-accent-dark) font-medium mt-3 hover:text-(--color-accent)"
>
View all <ChevronRight size={14} />
</button>
+7 -7
View File
@@ -30,7 +30,7 @@ export function PetProfiles({ readOnly }: Props) {
key={p.id}
onClick={() => { setSelectedPetId(p.id); setActiveTab("info"); }}
className={`flex items-center gap-3 px-4 py-3 rounded-xl border transition-colors ${
p.id === selectedPetId ? "border-[#8b7355] bg-[#faf5ef]" : "border-stone-200 bg-white hover:border-stone-300"
p.id === selectedPetId ? "border-(--color-accent) bg-(--color-accent-lighter)" : "border-stone-200 bg-white hover:border-stone-300"
}`}
>
<span className="text-2xl">{p.photo}</span>
@@ -45,7 +45,7 @@ export function PetProfiles({ readOnly }: Props) {
{/* Profile Header */}
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
<div className="flex items-center gap-4">
<div className="w-20 h-20 rounded-2xl bg-[#f0ebe4] flex items-center justify-center text-4xl">
<div className="w-20 h-20 rounded-2xl bg-(--color-accent-light) flex items-center justify-center text-4xl">
{pet.photo}
</div>
<div className="flex-1">
@@ -74,7 +74,7 @@ export function PetProfiles({ readOnly }: Props) {
key={id}
onClick={() => setActiveTab(id)}
className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium whitespace-nowrap ${
activeTab === id ? "bg-[#f0ebe4] text-[#6b5a42]" : "text-stone-500 hover:text-stone-700"
activeTab === id ? "bg-(--color-accent-light) text-(--color-accent-dark)" : "text-stone-500 hover:text-stone-700"
}`}
>
<Icon size={14} />
@@ -114,7 +114,7 @@ function BasicInfoTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
<InfoRow label="Sex" value={pet.sex === "male" ? "Male" : "Female"} />
<InfoRow label="Spayed/Neutered" value={pet.spayedNeutered ? "Yes" : "No"} />
{!readOnly && (
<button className="mt-4 text-sm text-[#6b5a42] font-medium hover:underline">
<button className="mt-4 text-sm text-(--color-accent-dark) font-medium hover:underline">
Upload Photo
</button>
)}
@@ -148,7 +148,7 @@ function GroomingTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
<InfoRow label="Sensitive Areas" value={pet.sensitiveAreas} />
<InfoRow label="Standing Instructions" value={pet.standingInstructions} />
{!readOnly && (
<button className="mt-4 text-sm text-[#6b5a42] font-medium hover:underline">
<button className="mt-4 text-sm text-(--color-accent-dark) font-medium hover:underline">
Upload Reference Photo
</button>
)}
@@ -189,7 +189,7 @@ function VaccinationsTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
{vax.documentUploaded ? (
<span className="text-green-600 text-xs">Uploaded</span>
) : !readOnly ? (
<button className="flex items-center gap-1 text-xs text-[#6b5a42] hover:underline">
<button className="flex items-center gap-1 text-xs text-(--color-accent-dark) hover:underline">
<Upload size={12} />
Upload
</button>
@@ -226,7 +226,7 @@ function HistoryTab({ petHistory }: { petHistory: typeof PAST_APPOINTMENTS }) {
{new Date(appt.date).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
</span>
{appt.reportCardId && (
<span className="text-xs text-[#6b5a42] font-medium">Report </span>
<span className="text-xs text-(--color-accent-dark) font-medium">Report </span>
)}
</div>
))
+8 -8
View File
@@ -33,7 +33,7 @@ export function ReportCards() {
className="w-full bg-white rounded-2xl border border-stone-200 p-5 shadow-sm text-left hover:border-stone-300 transition-colors"
>
<div className="flex items-start gap-4">
<div className="w-14 h-14 rounded-xl bg-[#f0ebe4] flex items-center justify-center text-[#8b7355]">
<div className="w-14 h-14 rounded-xl bg-(--color-accent-light) flex items-center justify-center text-(--color-accent)">
<FileText size={24} />
</div>
<div className="flex-1">
@@ -70,13 +70,13 @@ function ReportCardDetail({ card, onBack }: { card: ReportCard; onBack: () => vo
return (
<div className="space-y-6">
<button onClick={onBack} className="text-sm text-[#6b5a42] font-medium hover:underline">
<button onClick={onBack} className="text-sm text-(--color-accent-dark) font-medium hover:underline">
← Back to Report Cards
</button>
<div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden">
{/* Header */}
<div className="bg-gradient-to-r from-[#f0ebe4] to-[#e8e0d5] p-6">
<div className="bg-gradient-to-r from-(--color-accent-lighter) to-(--color-accent-light) p-6">
<div className="flex items-center justify-between mb-1">
<h2 className="text-xl font-semibold text-stone-800">{card.petName}'s Grooming Report</h2>
<button className="flex items-center gap-1.5 px-3 py-1.5 bg-white/80 text-stone-700 rounded-lg text-sm font-medium hover:bg-white">
@@ -101,9 +101,9 @@ function ReportCardDetail({ card, onBack }: { card: ReportCard; onBack: () => vo
</div>
<p className="text-sm text-stone-600">{card.beforeDescription}</p>
</div>
<div className="rounded-xl bg-[#faf5ef] p-4">
<p className="text-xs font-medium text-[#8b7355] uppercase mb-2">After</p>
<div className="w-full h-32 bg-[#f0ebe4] rounded-lg flex items-center justify-center text-[#8b7355] text-sm mb-2">
<div className="rounded-xl bg-(--color-accent-lighter) p-4">
<p className="text-xs font-medium text-(--color-accent) uppercase mb-2">After</p>
<div className="w-full h-32 bg-(--color-accent-light) rounded-lg flex items-center justify-center text-(--color-accent) text-sm mb-2">
Photo placeholder
</div>
<p className="text-sm text-stone-700">{card.afterDescription}</p>
@@ -148,7 +148,7 @@ function ReportCardDetail({ card, onBack }: { card: ReportCard; onBack: () => vo
)}
{/* Groomer's Note */}
<div className="bg-[#faf5ef] rounded-xl p-4">
<div className="bg-(--color-accent-lighter) rounded-xl p-4">
<h3 className="font-medium text-stone-800 mb-2">A Note from {card.groomerName}</h3>
<p className="text-sm text-stone-700 italic leading-relaxed">"{card.groomerNote}"</p>
</div>
@@ -161,7 +161,7 @@ function ReportCardDetail({ card, onBack }: { card: ReportCard; onBack: () => vo
{new Date(card.nextRecommendedDate).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}
</p>
</div>
<button className="px-4 py-2 bg-[#8b7355] text-white rounded-lg text-sm font-medium hover:bg-[#7a6549]">
<button className="px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover)">
Rebook Now
</button>
</div>
@@ -0,0 +1,6 @@
-- Add indexes on impersonation tables to prevent full table scans
-- Ref: GitHub #95
CREATE INDEX "impersonation_sessions_staff_id_status_idx" ON "impersonation_sessions" USING btree ("staff_id","status");--> statement-breakpoint
CREATE INDEX "impersonation_sessions_client_id_idx" ON "impersonation_sessions" USING btree ("client_id");--> statement-breakpoint
CREATE INDEX "impersonation_audit_logs_session_id_idx" ON "impersonation_audit_logs" USING btree ("session_id");
File diff suppressed because it is too large Load Diff
@@ -78,6 +78,13 @@
"when": 1742500800000,
"tag": "0010_impersonation_sessions",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1742587200000,
"tag": "0011_impersonation_indexes",
"breakpoints": true
}
]
}
+1 -1
View File
@@ -3,7 +3,7 @@ import postgres from "postgres";
import * as schema from "./schema.js";
export * from "./schema.js";
export { and, asc, desc, eq, gte, gt, lt, lte, ne, or, sql } from "drizzle-orm";
export { and, asc, desc, eq, gte, gt, ilike, lt, lte, ne, or, sql } from "drizzle-orm";
let _db: ReturnType<typeof drizzle> | null = null;
+39 -25
View File
@@ -1,5 +1,6 @@
import {
boolean,
index,
integer,
jsonb,
numeric,
@@ -232,32 +233,45 @@ export const impersonationSessionStatusEnum = pgEnum(
["active", "ended", "expired"]
);
export const impersonationSessions = pgTable("impersonation_sessions", {
id: uuid("id").primaryKey().defaultRandom(),
staffId: uuid("staff_id")
.notNull()
.references(() => staff.id, { onDelete: "restrict" }),
clientId: uuid("client_id")
.notNull()
.references(() => clients.id, { onDelete: "restrict" }),
reason: text("reason"),
status: impersonationSessionStatusEnum("status").notNull().default("active"),
startedAt: timestamp("started_at").notNull().defaultNow(),
endedAt: timestamp("ended_at"),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
});
export const impersonationSessions = pgTable(
"impersonation_sessions",
{
id: uuid("id").primaryKey().defaultRandom(),
staffId: uuid("staff_id")
.notNull()
.references(() => staff.id, { onDelete: "restrict" }),
clientId: uuid("client_id")
.notNull()
.references(() => clients.id, { onDelete: "restrict" }),
reason: text("reason"),
status: impersonationSessionStatusEnum("status")
.notNull()
.default("active"),
startedAt: timestamp("started_at").notNull().defaultNow(),
endedAt: timestamp("ended_at"),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
},
(t) => [
index("impersonation_sessions_staff_id_status_idx").on(t.staffId, t.status),
index("impersonation_sessions_client_id_idx").on(t.clientId),
]
);
export const impersonationAuditLogs = pgTable("impersonation_audit_logs", {
id: uuid("id").primaryKey().defaultRandom(),
sessionId: uuid("session_id")
.notNull()
.references(() => impersonationSessions.id, { onDelete: "cascade" }),
action: text("action").notNull(),
pageVisited: text("page_visited"),
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
createdAt: timestamp("created_at").notNull().defaultNow(),
});
export const impersonationAuditLogs = pgTable(
"impersonation_audit_logs",
{
id: uuid("id").primaryKey().defaultRandom(),
sessionId: uuid("session_id")
.notNull()
.references(() => impersonationSessions.id, { onDelete: "cascade" }),
action: text("action").notNull(),
pageVisited: text("page_visited"),
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
createdAt: timestamp("created_at").notNull().defaultNow(),
},
(t) => [index("impersonation_audit_logs_session_id_idx").on(t.sessionId)]
);
export const businessSettings = pgTable("business_settings", {
id: uuid("id").primaryKey().defaultRandom(),
+2 -2
View File
@@ -51,7 +51,7 @@ importers:
specifier: ^6.4.17
version: 6.4.23
'@vitest/coverage-v8':
specifier: ^3.0.4
specifier: ^3.2.4
version: 3.2.4(vitest@3.2.4(@types/node@22.19.15)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0))
eslint:
specifier: ^9.18.0
@@ -66,7 +66,7 @@ importers:
specifier: ^8.20.0
version: 8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
vitest:
specifier: ^3.0.4
specifier: ^3.2.4
version: 3.2.4(@types/node@22.19.15)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)
apps/e2e: