c826f65bd6
Backend:
- GET /api/search?q={query} — returns up to 10 matching active clients and 10
matching pets in a single request; clients matched on name/email/phone,
pets matched on name/breed with owner name included
- Special chars (%, _, \) escaped before ILIKE to prevent injection/accidents
- Disabled clients excluded; pets from disabled client owners excluded via JOIN filter
- Route registered under protected API (auth + RBAC middleware applies automatically)
- Export `ilike` from @groombook/db alongside existing drizzle-orm helpers
Frontend:
- GlobalSearch component in sticky admin header: debounced input (300ms),
grouped dropdown (Clients / Pets sections), loading/empty states
- Client results show name + phone; pet results show name, breed, owner name
- Touch-friendly: 44px input height, 48px min row height, full-width dropdown
- Outside-click closes dropdown; selecting a result navigates to /admin/clients
Tests (apps/api/src/__tests__/search.test.ts):
- 400 on missing/empty/whitespace q
- Returns matching clients and pets
- Empty arrays on no match
- Response shape always has clients/pets keys
- Special character inputs handled without errors
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
172 lines
5.5 KiB
TypeScript
172 lines
5.5 KiB
TypeScript
import { Routes, Route, Link, useLocation, Navigate } from "react-router-dom";
|
|
import { useEffect, useState } from "react";
|
|
import { AppointmentsPage } from "./pages/Appointments.js";
|
|
import { ClientsPage } from "./pages/Clients.js";
|
|
import { ServicesPage } from "./pages/Services.js";
|
|
import { StaffPage } from "./pages/Staff.js";
|
|
import { InvoicesPage } from "./pages/Invoices.js";
|
|
import { BookPage } from "./pages/Book.js";
|
|
import { ReportsPage } from "./pages/Reports.js";
|
|
import { GroupBookingPage } from "./pages/GroupBooking.js";
|
|
import { SettingsPage } from "./pages/Settings.js";
|
|
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" },
|
|
{ to: "/admin/clients", label: "Clients" },
|
|
{ to: "/admin/services", label: "Services" },
|
|
{ to: "/admin/staff", label: "Staff" },
|
|
{ to: "/admin/invoices", label: "Invoices" },
|
|
{ to: "/admin/group-bookings", label: "Group Bookings" },
|
|
{ to: "/admin/reports", label: "Reports" },
|
|
{ to: "/admin/settings", label: "Settings" },
|
|
{ to: "/", label: "Customer Portal" },
|
|
];
|
|
|
|
function AdminLayout() {
|
|
const location = useLocation();
|
|
const { branding } = useBranding();
|
|
|
|
const logoSrc = branding.logoBase64 && branding.logoMimeType
|
|
? `data:${branding.logoMimeType};base64,${branding.logoBase64}`
|
|
: null;
|
|
|
|
return (
|
|
<div style={{ minHeight: "100vh", fontFamily: "system-ui, sans-serif", background: "#f0f2f5" }}>
|
|
<nav
|
|
style={{
|
|
padding: "0 1.25rem",
|
|
height: 52,
|
|
borderBottom: "1px solid #e2e8f0",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "0.25rem",
|
|
background: "#fff",
|
|
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04)",
|
|
position: "sticky",
|
|
top: 0,
|
|
zIndex: 50,
|
|
}}
|
|
>
|
|
<div style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 8,
|
|
marginRight: "1.25rem",
|
|
}}>
|
|
{logoSrc && (
|
|
<img src={logoSrc} alt="" style={{ width: 24, height: 24, objectFit: "contain" }} />
|
|
)}
|
|
<strong style={{
|
|
fontSize: 17,
|
|
color: "#1a202c",
|
|
letterSpacing: "-0.02em",
|
|
}}>
|
|
{branding.businessName}
|
|
</strong>
|
|
</div>
|
|
<GlobalSearch />
|
|
<Link
|
|
to="/admin/book"
|
|
style={{
|
|
padding: "0.4rem 0.85rem",
|
|
borderRadius: 6,
|
|
textDecoration: "none",
|
|
fontSize: 13,
|
|
fontWeight: 600,
|
|
color: "#fff",
|
|
background: branding.primaryColor,
|
|
marginRight: "0.5rem",
|
|
boxShadow: "0 1px 2px rgba(79, 138, 111, 0.3)",
|
|
}}
|
|
>
|
|
Book
|
|
</Link>
|
|
{NAV_LINKS.map(({ to, label }) => {
|
|
const active =
|
|
to === "/admin"
|
|
? location.pathname === "/admin"
|
|
: location.pathname.startsWith(to);
|
|
return (
|
|
<Link
|
|
key={to}
|
|
to={to}
|
|
style={{
|
|
padding: "0.4rem 0.75rem",
|
|
borderRadius: 6,
|
|
textDecoration: "none",
|
|
fontSize: 13,
|
|
fontWeight: active ? 600 : 500,
|
|
color: active ? "#2d6a4f" : "#4b5563",
|
|
background: active ? "#ecfdf5" : "transparent",
|
|
}}
|
|
>
|
|
{label}
|
|
</Link>
|
|
);
|
|
})}
|
|
</nav>
|
|
<main style={{ padding: "1.25rem 1.5rem" }}>
|
|
<Routes>
|
|
<Route path="/" element={<AppointmentsPage />} />
|
|
<Route path="/clients" element={<ClientsPage />} />
|
|
<Route path="/services" element={<ServicesPage />} />
|
|
<Route path="/staff" element={<StaffPage />} />
|
|
<Route path="/invoices" element={<InvoicesPage />} />
|
|
<Route path="/book" element={<BookPage />} />
|
|
<Route path="/group-bookings" element={<GroupBookingPage />} />
|
|
<Route path="/reports" element={<ReportsPage />} />
|
|
<Route path="/settings" element={<SettingsPage />} />
|
|
</Routes>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function App() {
|
|
const location = useLocation();
|
|
const [authDisabled, setAuthDisabled] = useState<boolean | null>(null);
|
|
|
|
useEffect(() => {
|
|
fetch("/api/dev/config")
|
|
.then((r) => r.json())
|
|
.then((data) => setAuthDisabled(data.authDisabled === true))
|
|
.catch(() => setAuthDisabled(false));
|
|
}, []);
|
|
|
|
// Show login selector page
|
|
if (location.pathname === "/login") {
|
|
return <DevLoginSelector />;
|
|
}
|
|
|
|
// While checking auth config, render nothing briefly
|
|
if (authDisabled === null) return null;
|
|
|
|
// If auth is disabled and no dev user is selected, redirect to login selector
|
|
if (authDisabled && !getDevUser() && location.pathname !== "/login") {
|
|
return <Navigate to="/login" replace />;
|
|
}
|
|
|
|
return (
|
|
<BrandingProvider>
|
|
{location.pathname.startsWith("/admin") ? (
|
|
<>
|
|
<Routes>
|
|
<Route path="/admin/*" element={<AdminLayout />} />
|
|
</Routes>
|
|
{authDisabled && <DevSessionIndicator />}
|
|
</>
|
|
) : (
|
|
<>
|
|
<CustomerPortal />
|
|
{authDisabled && <DevSessionIndicator />}
|
|
</>
|
|
)}
|
|
</BrandingProvider>
|
|
);
|
|
}
|