feat: quick-find search for clients and pets (GH #97) #103
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,6 +14,7 @@ import { appointmentGroupsRouter } from "./routes/appointmentGroups.js";
|
|||||||
import { groomingLogsRouter } from "./routes/groomingLogs.js";
|
import { groomingLogsRouter } from "./routes/groomingLogs.js";
|
||||||
import { impersonationRouter } from "./routes/impersonation.js";
|
import { impersonationRouter } from "./routes/impersonation.js";
|
||||||
import { settingsRouter } from "./routes/settings.js";
|
import { settingsRouter } from "./routes/settings.js";
|
||||||
|
import { searchRouter } from "./routes/search.js";
|
||||||
import { getDb, businessSettings } from "@groombook/db";
|
import { getDb, businessSettings } from "@groombook/db";
|
||||||
import { authMiddleware } from "./middleware/auth.js";
|
import { authMiddleware } from "./middleware/auth.js";
|
||||||
import { resolveStaffMiddleware, requireRole } from "./middleware/rbac.js";
|
import { resolveStaffMiddleware, requireRole } from "./middleware/rbac.js";
|
||||||
@@ -98,6 +99,7 @@ api.route("/appointment-groups", appointmentGroupsRouter);
|
|||||||
api.route("/grooming-logs", groomingLogsRouter);
|
api.route("/grooming-logs", groomingLogsRouter);
|
||||||
api.route("/impersonation", impersonationRouter);
|
api.route("/impersonation", impersonationRouter);
|
||||||
api.route("/admin/settings", settingsRouter);
|
api.route("/admin/settings", settingsRouter);
|
||||||
|
api.route("/search", searchRouter);
|
||||||
|
|
||||||
const port = Number(process.env.PORT ?? 3000);
|
const port = Number(process.env.PORT ?? 3000);
|
||||||
console.log(`API server listening on port ${port}`);
|
console.log(`API server listening on port ${port}`);
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
@@ -13,6 +13,7 @@ import { CustomerPortal } from "./portal/CustomerPortal.js";
|
|||||||
import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js";
|
import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js";
|
||||||
import { DevSessionIndicator } from "./components/DevSessionIndicator.js";
|
import { DevSessionIndicator } from "./components/DevSessionIndicator.js";
|
||||||
import { BrandingProvider, useBranding } from "./BrandingContext.js";
|
import { BrandingProvider, useBranding } from "./BrandingContext.js";
|
||||||
|
import { GlobalSearch } from "./components/GlobalSearch.js";
|
||||||
|
|
||||||
const NAV_LINKS = [
|
const NAV_LINKS = [
|
||||||
{ to: "/admin", label: "Appointments" },
|
{ to: "/admin", label: "Appointments" },
|
||||||
@@ -68,6 +69,7 @@ function AdminLayout() {
|
|||||||
{branding.businessName}
|
{branding.businessName}
|
||||||
</strong>
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
|
<GlobalSearch />
|
||||||
<Link
|
<Link
|
||||||
to="/admin/book"
|
to="/admin/book"
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import postgres from "postgres";
|
|||||||
import * as schema from "./schema.js";
|
import * as schema from "./schema.js";
|
||||||
|
|
||||||
export * 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;
|
let _db: ReturnType<typeof drizzle> | null = null;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user