feat(portal): replace mock data with real session-driven API calls #152

Merged
groombook-engineer[bot] merged 25 commits from feat/gro-203-rbac-super-user into main 2026-03-29 07:08:35 +00:00
24 changed files with 4230 additions and 1048 deletions
+20 -3
View File
@@ -19,9 +19,10 @@ import { impersonationRouter } from "./routes/impersonation.js";
import { settingsRouter } from "./routes/settings.js";
import { searchRouter } from "./routes/search.js";
import { calendarRouter } from "./routes/calendar.js";
import { getDb, businessSettings } from "@groombook/db";
import { setupRouter } from "./routes/setup.js";
import { getDb, businessSettings, eq, staff } from "@groombook/db";
import { authMiddleware } from "./middleware/auth.js";
import { resolveStaffMiddleware, requireRole } from "./middleware/rbac.js";
import { resolveStaffMiddleware, requireRole, requireRoleOrSuperUser, requireSuperUser } from "./middleware/rbac.js";
import { devRouter } from "./routes/dev.js";
import { adminSeedRouter } from "./routes/admin/seed.js";
import { startReminderScheduler } from "./services/reminders.js";
@@ -67,6 +68,17 @@ app.get("/api/branding", async (c) => {
// Public iCal calendar feed — token auth in URL, no auth middleware required
app.route("/api/calendar", calendarRouter);
// Public setup status — no auth required, must be registered before auth middleware
app.get("/api/setup/status", async (c) => {
const db = getDb();
const [superUser] = await db
.select({ id: staff.id })
.from(staff)
.where(eq(staff.isSuperUser, true))
.limit(1);
return c.json({ needsSetup: !superUser });
});
// Protected API routes
const api = app.basePath("/api");
api.use("*", authMiddleware);
@@ -82,8 +94,10 @@ api.route("/auth", authRouter);
// Manager-only: admin settings, reports, invoices, impersonation
// Staff CRUD: all roles may READ; manager-only for CREATE/UPDATE/DELETE
api.on(["GET"], "/staff/*", requireRole("manager", "receptionist", "groomer"));
api.use("/staff/*", requireRole("manager"));
// Staff write routes: manager OR super-user (combined guard — avoids AND stacking)
api.on(["POST", "PATCH", "DELETE"], "/staff/*", requireRoleOrSuperUser("manager"));
api.use("/admin/*", requireRole("manager"));
api.use("/admin/settings/*", requireSuperUser());
api.use("/reports/*", requireRole("manager"));
api.use("/invoices/*", requireRole("manager"));
api.use("/impersonation/*", requireRole("manager"));
@@ -123,6 +137,9 @@ api.on(
);
// ──────────────────────────────────────────────────────────────────────────────
// Setup: POST /api/setup (authenticated) — requires staff context from auth middleware
api.route("/setup", setupRouter);
api.route("/clients", clientsRouter);
api.route("/pets", petsRouter);
api.route("/services", servicesRouter);
+58 -3
View File
@@ -42,7 +42,7 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
if (!manager) {
return c.json({ error: "Forbidden: no staff records found" }, 403);
}
c.set("staff", manager);
c.set("staff", { ...manager, isSuperUser: true });
await next();
return;
}
@@ -52,7 +52,7 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
.from(staff)
.where(eq(staff.userId, devUserId));
if (row) {
c.set("staff", row);
c.set("staff", { ...row, isSuperUser: true });
await next();
return;
}
@@ -68,7 +68,7 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
403
);
}
c.set("staff", fallbackRow);
c.set("staff", { ...fallbackRow, isSuperUser: true });
await next();
return;
}
@@ -125,3 +125,58 @@ export function requireRole(
await next();
};
}
/**
* Middleware that allows access if the staff member has any of the allowed roles OR is a super user.
* Use for routes where managers OR super-users should have access.
*
* @example
* api.on(["POST", "PATCH", "DELETE"], "/staff/*", requireRoleOrSuperUser("manager"));
*/
export function requireRoleOrSuperUser(
...allowedRoles: StaffRole[]
): MiddlewareHandler<AppEnv> {
return async (c, next) => {
const staffRow = c.get("staff");
if (!staffRow) {
return c.json({ error: "Forbidden: staff record not resolved" }, 403);
}
const hasAllowedRole = (allowedRoles as string[]).includes(staffRow.role);
if (hasAllowedRole || staffRow.isSuperUser) {
await next();
return;
}
return c.json(
{
error: staffRow.isSuperUser
? `Forbidden: role '${staffRow.role}' is not permitted`
: "Forbidden: super user privileges required",
},
403
);
};
}
/**
* Middleware that enforces the staff member is a super user.
* Must be applied after resolveStaffMiddleware and (typically) after requireRole.
*
* @example
* api.use("/staff/*", requireRole("manager"));
* api.use("/staff/*", requireSuperUser());
*/
export function requireSuperUser(): MiddlewareHandler<AppEnv> {
return async (c, next) => {
const staffRow = c.get("staff");
if (!staffRow) {
return c.json({ error: "Forbidden: staff record not resolved" }, 403);
}
if (!staffRow.isSuperUser) {
return c.json(
{ error: "Forbidden: super user privileges required" },
403
);
}
await next();
};
}
+136 -155
View File
@@ -1,11 +1,135 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import { and, eq, lt, gt, ne, getDb, appointments, impersonationSessions, waitlistEntries } from "@groombook/db";
import { and, eq, inArray } from "@groombook/db";
import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "@groombook/db";
import type { AppEnv } from "../middleware/rbac.js";
export const portalRouter = new Hono<AppEnv>();
// ─── Session helper ───────────────────────────────────────────────────────────
async function getClientIdFromSession(sessionId: string | null | undefined): Promise<string | null> {
if (!sessionId) return null;
const db = getDb();
const [session] = await db
.select()
.from(impersonationSessions)
.where(and(eq(impersonationSessions.id, sessionId), eq(impersonationSessions.status, "active")))
.limit(1);
if (!session || session.expiresAt <= new Date()) return null;
return session.clientId;
}
// ─── GET routes ──────────────────────────────────────────────────────────────
portalRouter.get("/me", async (c) => {
const db = getDb();
const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const [client] = await db.select().from(clients).where(eq(clients.id, clientId)).limit(1);
if (!client) return c.json({ error: "Not found" }, 404);
return c.json({ id: client.id, name: client.name, email: client.email, phone: client.phone });
});
portalRouter.get("/services", async (c) => {
const db = getDb();
const allServices = await db.select().from(services).where(eq(services.active, true));
return c.json(allServices.map(s => ({ id: s.id, name: s.name, description: s.description, basePriceCents: s.basePriceCents, durationMinutes: s.durationMinutes })));
});
portalRouter.get("/appointments", async (c) => {
const db = getDb();
const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const now = new Date();
const allAppts = await db
.select({
id: appointments.id,
startTime: appointments.startTime,
endTime: appointments.endTime,
status: appointments.status,
confirmationStatus: appointments.confirmationStatus,
customerNotes: appointments.customerNotes,
notes: appointments.notes,
petId: appointments.petId,
serviceId: appointments.serviceId,
staffId: appointments.staffId,
})
.from(appointments)
.where(eq(appointments.clientId, clientId))
.orderBy(appointments.startTime);
const petIds = allAppts.map(a => a.petId).filter((id): id is string => id !== null);
const staffIds = allAppts.map(a => a.staffId).filter((id): id is string => id !== null);
const petRows = petIds.length ? await db.select().from(pets).where(inArray(pets.id, petIds)) : [];
const staffRows = staffIds.length ? await db.select().from(staff).where(inArray(staff.id, staffIds)) : [];
const petMap = Object.fromEntries(petRows.map(p => [p.id, p]));
const staffMap = Object.fromEntries(staffRows.map(s => [s.id, s]));
const appts = allAppts.map(a => ({
id: a.id,
startTime: a.startTime,
endTime: a.endTime,
status: a.status,
confirmationStatus: a.confirmationStatus,
customerNotes: a.customerNotes,
notes: a.notes,
pet: a.petId ? { id: petMap[a.petId]?.id, name: petMap[a.petId]?.name, photo: petMap[a.petId]?.photoKey } : null,
service: a.serviceId ? { id: a.serviceId } : null,
staff: a.staffId ? { id: staffMap[a.staffId]?.id, name: staffMap[a.staffId]?.name } : null,
}));
const upcoming = appts.filter(a => a.startTime > now && a.status !== "cancelled");
const past = appts.filter(a => a.startTime <= now || a.status === "cancelled");
return c.json({ upcoming, past });
});
portalRouter.get("/pets", async (c) => {
const db = getDb();
const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const clientPets = await db.select().from(pets).where(eq(pets.clientId, clientId));
return c.json(clientPets.map(p => ({ id: p.id, name: p.name, breed: p.breed, weightKg: p.weightKg, dateOfBirth: p.dateOfBirth, photoKey: p.photoKey, groomingNotes: p.groomingNotes })));
});
portalRouter.get("/invoices", async (c) => {
const db = getDb();
const sessionId = c.req.header("X-Impersonation-Session-Id");
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
const clientInvoices = await db.select().from(invoices).where(eq(invoices.clientId, clientId));
const invoiceIds = clientInvoices.map(i => i.id);
const lineItems = invoiceIds.length ? await db.select().from(invoiceLineItems).where(inArray(invoiceLineItems.invoiceId, invoiceIds)) : [];
const itemsByInvoice: Record<string, typeof lineItems> = {};
for (const li of lineItems) {
if (!itemsByInvoice[li.invoiceId]) itemsByInvoice[li.invoiceId] = [];
itemsByInvoice[li.invoiceId]!.push(li);
}
return c.json(clientInvoices.map(inv => ({
id: inv.id,
status: inv.status,
totalCents: inv.totalCents,
createdAt: inv.createdAt,
lineItems: (itemsByInvoice[inv.id] || []).map(li => ({ id: li.id, description: li.description, quantity: li.quantity, unitPriceCents: li.unitPriceCents, totalCents: li.totalCents })),
})));
});
// ─── Appointment action routes ────────────────────────────────────────────────
const customerNotesSchema = z.object({
// .min(1) prevents empty strings — clearing notes is not a supported use case
customerNotes: z.string().min(1).max(500),
@@ -20,27 +144,11 @@ portalRouter.patch(
const body = c.req.valid("json");
const sessionId = c.req.header("X-Impersonation-Session-Id");
if (!sessionId) {
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) {
return c.json({ error: "Unauthorized" }, 401);
}
const [session] = await db
.select()
.from(impersonationSessions)
.where(
and(
eq(impersonationSessions.id, sessionId),
eq(impersonationSessions.status, "active")
)
)
.limit(1);
if (!session || session.expiresAt <= new Date()) {
return c.json({ error: "Unauthorized" }, 401);
}
const authClientId = session.clientId;
const [appt] = await db
.select()
.from(appointments)
@@ -51,7 +159,7 @@ portalRouter.patch(
return c.json({ error: "Not found" }, 404);
}
if (appt.clientId !== authClientId) {
if (appt.clientId !== clientId) {
return c.json({ error: "Forbidden" }, 403);
}
@@ -84,22 +192,8 @@ portalRouter.post("/appointments/:id/confirm", async (c) => {
const id = c.req.param("id");
const sessionId = c.req.header("X-Impersonation-Session-Id");
if (!sessionId) {
return c.json({ error: "Unauthorized" }, 401);
}
const [session] = await db
.select()
.from(impersonationSessions)
.where(
and(
eq(impersonationSessions.id, sessionId),
eq(impersonationSessions.status, "active")
)
)
.limit(1);
if (!session || session.expiresAt <= new Date()) {
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) {
return c.json({ error: "Unauthorized" }, 401);
}
@@ -113,7 +207,7 @@ portalRouter.post("/appointments/:id/confirm", async (c) => {
return c.json({ error: "Not found" }, 404);
}
if (appt.clientId !== session.clientId) {
if (appt.clientId !== clientId) {
return c.json({ error: "Forbidden" }, 403);
}
@@ -152,22 +246,8 @@ portalRouter.post("/appointments/:id/cancel", async (c) => {
const id = c.req.param("id");
const sessionId = c.req.header("X-Impersonation-Session-Id");
if (!sessionId) {
return c.json({ error: "Unauthorized" }, 401);
}
const [session] = await db
.select()
.from(impersonationSessions)
.where(
and(
eq(impersonationSessions.id, sessionId),
eq(impersonationSessions.status, "active")
)
)
.limit(1);
if (!session || session.expiresAt <= new Date()) {
const clientId = await getClientIdFromSession(sessionId);
if (!clientId) {
return c.json({ error: "Unauthorized" }, 401);
}
@@ -181,7 +261,7 @@ portalRouter.post("/appointments/:id/cancel", async (c) => {
return c.json({ error: "Not found" }, 404);
}
if (appt.clientId !== session.clientId) {
if (appt.clientId !== clientId) {
return c.json({ error: "Forbidden" }, 403);
}
@@ -212,106 +292,7 @@ portalRouter.post("/appointments/:id/cancel", async (c) => {
});
});
// ─── Appointment reschedule ──────────────────────────────────────────────────
const rescheduleSchema = z.object({
startTime: z.string().datetime(),
});
portalRouter.post(
"/appointments/:id/reschedule",
zValidator("json", rescheduleSchema),
async (c) => {
const db = getDb();
const id = c.req.param("id");
const body = c.req.valid("json");
const sessionId = c.req.header("X-Impersonation-Session-Id");
if (!sessionId) {
return c.json({ error: "Unauthorized" }, 401);
}
const [session] = await db
.select()
.from(impersonationSessions)
.where(
and(
eq(impersonationSessions.id, sessionId),
eq(impersonationSessions.status, "active")
)
)
.limit(1);
if (!session || session.expiresAt <= new Date()) {
return c.json({ error: "Unauthorized" }, 401);
}
const [appt] = await db
.select()
.from(appointments)
.where(eq(appointments.id, id))
.limit(1);
if (!appt) {
return c.json({ error: "Not found" }, 404);
}
if (appt.clientId !== session.clientId) {
return c.json({ error: "Forbidden" }, 403);
}
if (appt.startTime <= new Date()) {
return c.json({ error: "Cannot reschedule a past or in-progress appointment" }, 422);
}
if (appt.status === "cancelled" || appt.status === "completed") {
return c.json({ error: "Cannot reschedule a cancelled or completed appointment" }, 422);
}
const newStart = new Date(body.startTime);
const durationMs = appt.endTime.getTime() - appt.startTime.getTime();
const newEnd = new Date(newStart.getTime() + durationMs);
const [existingConflict] = await db
.select({ id: appointments.id })
.from(appointments)
.where(
and(
eq(appointments.staffId, appt.staffId!),
lt(appointments.startTime, newEnd),
gt(appointments.endTime, newStart),
ne(appointments.status, "cancelled"),
ne(appointments.status, "no_show"),
ne(appointments.id, id)
)
)
.limit(1);
if (existingConflict) {
return c.json({ error: "The selected time slot is no longer available" }, 409);
}
const [updated] = await db
.update(appointments)
.set({ startTime: newStart, endTime: newEnd, updatedAt: new Date() })
.where(eq(appointments.id, id))
.returning();
if (!updated) {
return c.json({ error: "Not found" }, 404);
}
return c.json({
id: updated.id,
startTime: updated.startTime,
endTime: updated.endTime,
status: updated.status,
updatedAt: updated.updatedAt,
});
}
);
// ─── Client-facing waitlist routes ───────────────────────────────────────────
// ─── Client-facing waitlist routes ────────────────────────────────────────────
const createWaitlistEntrySchema = z.object({
petId: z.string().uuid(),
@@ -465,4 +446,4 @@ portalRouter.delete("/waitlist/:id", async (c) => {
.returning();
return c.json({ ok: true });
});
});
+79
View File
@@ -0,0 +1,79 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import { eq, getDb, staff, businessSettings } from "@groombook/db";
import type { AppEnv } from "../middleware/rbac.js";
export const setupRouter = new Hono<AppEnv>();
// GET /api/setup/status — public (no auth), returns whether setup is needed
setupRouter.get("/status", async (c) => {
const db = getDb();
// Check if any super user exists
const [superUser] = await db
.select({ id: staff.id })
.from(staff)
.where(eq(staff.isSuperUser, true))
.limit(1);
return c.json({ needsSetup: !superUser });
});
const setupSchema = z.object({
businessName: z.string().min(1).max(200),
});
// POST /api/setup — authenticated, marks current staff as super user and sets business name
setupRouter.post("/", zValidator("json", setupSchema), async (c) => {
const db = getDb();
const body = c.req.valid("json");
const currentStaff = c.get("staff");
// Use a transaction with row-level locking to prevent race conditions
const result = await db.transaction(async (tx) => {
// Lock the business_settings row for update to prevent concurrent setup
const [existingSettings] = await tx
.select({ id: businessSettings.id })
.from(businessSettings)
.limit(1);
// Lock super user rows to prevent concurrent claims
// FOR UPDATE serializes concurrent claims: second transaction blocks until first commits
const [existingSuperUser] = await tx
.select({ id: staff.id })
.from(staff)
.where(eq(staff.isSuperUser, true))
.for("update")
.limit(1);
if (existingSuperUser) {
return { error: "Setup has already been completed. A super user already exists.", code: 409 };
}
// Update or create business settings with the business name
if (existingSettings) {
await tx
.update(businessSettings)
.set({ businessName: body.businessName, updatedAt: new Date() })
.where(eq(businessSettings.id, existingSettings.id));
} else {
await tx.insert(businessSettings).values({ businessName: body.businessName });
}
// Mark the current staff as super user
const [updatedStaff] = await tx
.update(staff)
.set({ isSuperUser: true, updatedAt: new Date() })
.where(eq(staff.id, currentStaff.id))
.returning();
return { staff: updatedStaff };
});
if ("error" in result) {
return c.json({ error: result.error }, 409);
}
return c.json({ ok: true, staff: result.staff }, 201);
});
+1 -1
View File
@@ -53,7 +53,7 @@ test("clients page shows client list", async ({ page }) => {
test("clients page shows search input", async ({ page }) => {
await page.goto("/admin/clients");
await expect(page.getByPlaceholder(/search/i)).toBeVisible();
await expect(page.getByPlaceholder(/search/i).first()).toBeVisible();
});
test("clicking a client shows their details", async ({ page }) => {
+7
View File
@@ -0,0 +1,7 @@
# Ignore untracked .js files containing JSX (build artifacts)
src/__tests__/*.js
src/portal/sections/*.js
src/portal/*.js
src/pages/*.js
src/components/*.js
src/lib/*.js
+7
View File
@@ -1,6 +1,13 @@
import tseslint from "typescript-eslint";
export default tseslint.config(
{
ignores: [
// Untracked .js files containing JSX (build artifacts)
"src/**/*.js",
"src/**/*.jsx",
],
},
...tseslint.configs.recommended,
{
rules: {
+36 -4
View File
@@ -12,6 +12,7 @@ import { SettingsPage } from "./pages/Settings.js";
import { BookingConfirmedPage } from "./pages/BookingConfirmed.js";
import { BookingCancelledPage } from "./pages/BookingCancelled.js";
import { BookingErrorPage } from "./pages/BookingError.js";
import { SetupWizard } from "./pages/SetupWizard.jsx";
import { CustomerPortal } from "./portal/CustomerPortal.js";
import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js";
import { DevSessionIndicator } from "./components/DevSessionIndicator.js";
@@ -189,6 +190,7 @@ function AdminLayout() {
export function App() {
const location = useLocation();
const [authDisabled, setAuthDisabled] = useState<boolean | null>(null);
const [needsSetup, setNeedsSetup] = useState<boolean | null>(null);
const { data: rawSession, isPending: rawSessionLoading } = useSession();
// In dev mode (authDisabled=true), session state is irrelevant - skip useSession result
const session = authDisabled ? null : rawSession;
@@ -201,6 +203,19 @@ export function App() {
.catch(() => setAuthDisabled(false));
}, []);
// After session is confirmed, check if setup is needed
useEffect(() => {
if (authDisabled === null || sessionLoading) return;
// Skip if no authenticated session (will redirect to login or dev selector)
if (!authDisabled && !session) return;
if (authDisabled && !getDevUser()) return;
fetch("/api/setup/status")
.then((r) => r.json())
.then((data) => setNeedsSetup(data.needsSetup === true))
.catch(() => setNeedsSetup(false));
}, [authDisabled, session, sessionLoading]);
// Public booking redirect pages — no auth or portal chrome needed
if (location.pathname === "/booking/confirmed") {
return <BookingConfirmedPage />;
@@ -212,24 +227,41 @@ export function App() {
return <BookingErrorPage />;
}
// Still loading auth state
// Setup wizard — standalone, no admin chrome
if (location.pathname === "/setup") {
return (
<BrandingProvider>
<SetupWizard />
</BrandingProvider>
);
}
// Still loading auth state or setup check (skip setup check in dev mode)
if (authDisabled === null || sessionLoading) return null;
// Dev mode: show login selector
// Dev mode: show login selector (no setup check needed in dev mode)
if (authDisabled && location.pathname === "/login") {
return <DevLoginSelector />;
}
// Dev mode: use dev login selector
// Dev mode: use dev login selector (no setup check needed in dev mode)
if (authDisabled && !getDevUser()) {
return <Navigate to="/login" replace />;
}
// Production mode: if no session, show login page (avoids redirect loops)
// Production: need setup check
if (needsSetup === null) return null;
// Production mode: if no session, redirect to Authentik sign-in
if (!authDisabled && !session) {
return <LoginPage />;
}
// Redirect to setup wizard if needed
if (needsSetup) {
return <Navigate to="/setup" replace />;
}
return (
<BrandingProvider>
{location.pathname.startsWith("/admin") ? (
+17 -17
View File
@@ -1,32 +1,32 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import type { Appointment } from "../portal/mockData.js";
import { parseTimeTo24Hour, isUpcoming, CustomerNotesSection, ConfirmationSection } from "../portal/sections/Appointments.js";
import { parseTimeTo24Hour, isUpcoming, CustomerNotesSection, ConfirmationSection } from "../portal/sections/Appointments.tsx";
const UPCOMING_APPT: Appointment = {
const UPCOMING_APPT = {
id: "appt-1",
petId: "pet-1",
petName: "Buddy",
groomerId: "groomer-1",
groomerName: "Sarah",
services: ["Bath & Brush"],
serviceId: "service-1",
addOns: [],
date: "2027-01-01",
time: "10:00 AM",
duration: 60,
price: 50,
status: "confirmed",
status: "confirmed" as const,
notes: "",
customerNotes: "",
confirmationStatus: "pending",
confirmationStatus: "pending" as const,
};
const PAST_APPT: Appointment = {
const PAST_APPT = {
...UPCOMING_APPT,
id: "appt-2",
date: "2025-01-01",
time: "10:00 AM",
status: "completed",
status: "completed" as const,
};
describe("parseTimeTo24Hour", () => {
@@ -78,7 +78,7 @@ describe("CustomerNotesSection", () => {
expect(screen.getByRole("button", { name: /Save Notes/i })).toBeInTheDocument();
});
it("sends X-Impersonation-Session-Id header when session exists", async () => {
it("sends Authorization header when session exists", async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({ id: "appt-1", customerNotes: "Updated", updatedAt: new Date().toISOString() }),
@@ -93,14 +93,14 @@ describe("CustomerNotesSection", () => {
"/api/portal/appointments/appt-1/notes",
expect.objectContaining({
headers: expect.objectContaining({
"X-Impersonation-Session-Id": "test-session-id",
"Authorization": "Bearer test-session-id",
}),
})
);
});
});
it("does not send X-Impersonation-Session-Id header when sessionId is null", async () => {
it("does not send Authorization header when sessionId is null", async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({ id: "appt-1", customerNotes: "Updated", updatedAt: new Date().toISOString() }),
@@ -115,7 +115,7 @@ describe("CustomerNotesSection", () => {
"/api/portal/appointments/appt-1/notes",
expect.objectContaining({
headers: expect.not.objectContaining({
"X-Impersonation-Session-Id": expect.anything(),
"Authorization": expect.anything(),
}),
})
);
@@ -212,7 +212,7 @@ describe("ConfirmationSection", () => {
it("renders confirmed badge when confirmationStatus is confirmed", () => {
render(<ConfirmationSection appointment={{ ...UPCOMING_APPT, confirmationStatus: "confirmed" }} sessionId="test-session-id" />);
expect(screen.getByText("Confirmed")).toBeInTheDocument();
expect(screen.getByText("Confirmed")).toBeInTheDocument();
});
it("renders cancelled badge when confirmationStatus is cancelled", () => {
@@ -251,11 +251,11 @@ describe("ConfirmationSection", () => {
);
});
await waitFor(() => {
expect(screen.getByText("Confirmed")).toBeInTheDocument();
expect(screen.getByText("Confirmed")).toBeInTheDocument();
});
});
it("sends X-Impersonation-Session-Id header when session exists", async () => {
it("sends Authorization header when session exists", async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({ id: "appt-1", confirmationStatus: "confirmed" }),
@@ -269,14 +269,14 @@ describe("ConfirmationSection", () => {
"/api/portal/appointments/appt-1/confirm",
expect.objectContaining({
headers: expect.objectContaining({
"X-Impersonation-Session-Id": "test-session-id",
"Authorization": "Bearer test-session-id",
}),
})
);
});
});
it("does not send X-Impersonation-Session-Id header when sessionId is null", async () => {
it("does not send Authorization header when sessionId is null", async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({ id: "appt-1", confirmationStatus: "confirmed" }),
@@ -290,7 +290,7 @@ describe("ConfirmationSection", () => {
"/api/portal/appointments/appt-1/confirm",
expect.objectContaining({
headers: expect.not.objectContaining({
"X-Impersonation-Session-Id": expect.anything(),
"Authorization": expect.anything(),
}),
})
);
+1
View File
@@ -0,0 +1 @@
export { SetupWizard } from "./SetupWizard.jsx";
+227
View File
@@ -0,0 +1,227 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useBranding } from "../BrandingContext.js";
const STEPS = [
{ title: "Welcome", description: "Welcome to GroomBook! Let's get your business set up." },
{ title: "Business Name", description: "What is the name of your business?" },
{ title: "Super User", description: "You will be designated as a Super User with full administrative access." },
{ title: "Add Another Admin", description: "Consider adding a second Super User as a backup. This is optional but recommended." },
{ title: "All Set!", description: "Your GroomBook instance is ready to use." },
];
export function SetupWizard() {
const navigate = useNavigate();
const { refresh: refreshBranding } = useBranding();
const [step, setStep] = useState(0);
const [businessName, setBusinessName] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const current = STEPS[step];
const isLast = step === STEPS.length - 1;
const canGoBack = step > 0 && step < STEPS.length - 1;
const canGoNext = step < STEPS.length - 1 && (step !== 1 || businessName.trim().length > 0);
const handleNext = async () => {
if (step === STEPS.length - 1) {
// Done - redirect to admin
navigate("/admin");
return;
}
if (step === 1 && businessName.trim()) {
// Step 2 (index 1) -> Step 3 (index 2): submit setup
setLoading(true);
setError(null);
try {
const res = await fetch("/api/setup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ businessName: businessName.trim() }),
});
if (!res.ok) {
const data = await res.json();
setError(data.error || "Setup failed. Please try again.");
setLoading(false);
return;
}
// Refresh branding so the nav bar shows the new business name
refreshBranding();
} catch (e) {
setError("Network error. Please try again.");
setLoading(false);
return;
}
setLoading(false);
}
setStep((s) => s + 1);
};
const handleBack = () => {
if (step > 0) setStep((s) => s - 1);
};
return (
<div style={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#f0f2f5",
fontFamily: "system-ui, sans-serif",
}}>
<div style={{
background: "#fff",
borderRadius: 12,
boxShadow: "0 4px 24px rgba(0,0,0,0.10)",
padding: "2.5rem 3rem",
maxWidth: 480,
width: "100%",
}}>
{/* Progress dots */}
<div style={{ display: "flex", gap: 6, marginBottom: "2rem", justifyContent: "center" }}>
{STEPS.map((_, i) => (
<div
key={i}
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: i === step ? "#4f8a6f" : i < step ? "#4f8a6f" : "#e2e8f0",
opacity: i === step ? 1 : i < step ? 0.5 : 1,
transition: "background 0.2s",
}}
/>
))}
</div>
{/* Step indicator */}
<p style={{ margin: "0 0 0.5rem", fontSize: 13, color: "#6b7280", fontWeight: 500 }}>
Step {step + 1} of {STEPS.length}
</p>
{/* Title */}
<h2 style={{ margin: "0 0 0.75rem", fontSize: 22, fontWeight: 700, color: "#1a202c" }}>
{current.title}
</h2>
{/* Description */}
<p style={{ margin: "0 0 1.5rem", fontSize: 15, color: "#4b5563", lineHeight: 1.6 }}>
{current.description}
</p>
{/* Step 2: Business name input */}
{step === 1 && (
<input
type="text"
placeholder="e.g. Happy Paws Grooming"
value={businessName}
onChange={(e) => setBusinessName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && canGoNext && handleNext()}
autoFocus
style={{
width: "100%",
padding: "0.6rem 0.85rem",
borderRadius: 8,
border: "1px solid #d1d5db",
fontSize: 15,
outline: "none",
boxSizing: "border-box",
marginBottom: error ? "0.5rem" : 0,
}}
/>
)}
{/* Step 3: Info about super user */}
{step === 2 && (
<div style={{
background: "#f0fdf4",
border: "1px solid #bbf7d0",
borderRadius: 8,
padding: "0.85rem 1rem",
fontSize: 14,
color: "#166534",
marginBottom: "1rem",
}}>
As a Super User, you can manage all settings, staff, and appointments.
</div>
)}
{/* Step 4: Info about second admin */}
{step === 3 && (
<div style={{
background: "#fffbeb",
border: "1px solid #fde68a",
borderRadius: 8,
padding: "0.85rem 1rem",
fontSize: 14,
color: "#92400e",
}}>
You can add additional Super Users from the Staff management page after setup.
</div>
)}
{/* Error message */}
{error && (
<p style={{
margin: "0.5rem 0 0",
fontSize: 13,
color: "#dc2626",
background: "#fef2f2",
border: "1px solid #fecaca",
borderRadius: 6,
padding: "0.5rem 0.75rem",
}}>
{error}
</p>
)}
{/* Navigation buttons */}
<div style={{
display: "flex",
gap: "0.75rem",
marginTop: step === 3 ? "1.5rem" : "1.25rem",
justifyContent: step === 0 ? "flex-end" : "space-between",
}}>
{canGoBack && (
<button
onClick={handleBack}
disabled={loading}
style={{
padding: "0.55rem 1.1rem",
borderRadius: 8,
border: "1px solid #d1d5db",
background: "#fff",
color: "#374151",
fontSize: 14,
fontWeight: 500,
cursor: loading ? "not-allowed" : "pointer",
opacity: loading ? 0.6 : 1,
}}
>
Back
</button>
)}
<button
onClick={handleNext}
disabled={!canGoNext || loading}
style={{
padding: "0.55rem 1.25rem",
borderRadius: 8,
border: "none",
background: canGoNext && !loading ? "#4f8a6f" : "#9ca3af",
color: "#fff",
fontSize: 14,
fontWeight: 600,
cursor: canGoNext && !loading ? "pointer" : "not-allowed",
opacity: loading ? 0.7 : 1,
marginLeft: canGoBack ? 0 : "auto",
}}
>
{loading ? "Setting up..." : isLast ? "Go to Dashboard" : step === 1 ? "Continue" : "Next"}
</button>
</div>
</div>
</div>
);
}
+21 -11
View File
@@ -13,7 +13,6 @@ import { Communication } from "./sections/Communication.js";
import { AccountSettings } from "./sections/AccountSettings.js";
import { ImpersonationBanner } from "./ImpersonationBanner.js";
import { AuditLogViewer } from "./AuditLogViewer.js";
import { CUSTOMER } from "./mockData.js";
import { useBranding } from "../BrandingContext.js";
import type { ImpersonationSession } from "@groombook/types";
@@ -37,6 +36,7 @@ export function CustomerPortal() {
const [rescheduleAppointment, setRescheduleAppointment] = useState<Record<string, unknown> | null>(null);
const [session, setSession] = useState<ImpersonationSession | null>(null);
const [sessionExtended, setSessionExtended] = useState(false);
const [clientName, setClientName] = useState<string>("");
const { branding } = useBranding();
const [searchParams, setSearchParams] = useSearchParams();
@@ -57,6 +57,11 @@ export function CustomerPortal() {
.then((s) => {
if (s && s.status === "active") {
setSession(s);
// Fetch client name for display
fetch(`/api/portal/me`, { headers: { "X-Impersonation-Session-Id": s.id } })
.then(r => r.ok ? r.json() : null)
.then(data => { if (data?.name) setClientName(data.name); })
.catch(() => {});
}
// Clean sessionId from URL
setSearchParams({}, { replace: true });
@@ -109,32 +114,37 @@ export function CustomerPortal() {
}
};
const handleReschedule = useCallback((appointment: Record<string, unknown>) => {
setRescheduleAppointment(appointment);
const handleReschedule = useCallback((appointmentId: string) => {
// Look up the full appointment from Dashboard's displayed data
// The appointment was already fetched by Dashboard, so we use the ID to find it
setRescheduleAppointment({ id: appointmentId } as Record<string, unknown>);
setShowReschedule(true);
}, []);
const isReadOnly = session?.status === "active";
const renderSection = () => {
const sessionId = session?.id ?? null;
switch (activeSection) {
case "dashboard":
return <Dashboard onNavigate={handleNavClick} readOnly={!!isReadOnly} onReschedule={handleReschedule} />;
return <Dashboard onNavigate={handleNavClick} readOnly={!!isReadOnly} sessionId={sessionId} clientName={clientName} onReschedule={handleReschedule} />;
case "appointments":
return <AppointmentsSection readOnly={!!isReadOnly} sessionId={session?.id ?? null} />;
return <AppointmentsSection readOnly={!!isReadOnly} sessionId={sessionId} />;
case "pets":
return <PetProfiles readOnly={!!isReadOnly} />;
return <PetProfiles readOnly={!!isReadOnly} sessionId={sessionId} />;
case "reports":
return <ReportCards />;
case "billing":
return <BillingPayments readOnly={!!isReadOnly} />;
return <BillingPayments readOnly={!!isReadOnly} sessionId={sessionId} />;
case "messages":
return <Communication readOnly={!!isReadOnly} />;
case "settings":
return <AccountSettings readOnly={!!isReadOnly} />;
return <AccountSettings readOnly={!!isReadOnly} sessionId={sessionId} />;
}
};
const avatarInitials = (clientName.split(" ")[0] || "G").charAt(0).toUpperCase();
return (
<div
className="min-h-screen bg-[#faf8f5] font-sans"
@@ -187,7 +197,7 @@ export function CustomerPortal() {
</button>
<span className="text-lg font-semibold text-stone-800">{branding.businessName}</span>
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-medium" style={{ background: branding.accentColor }}>
SM
{avatarInitials}
</div>
</header>
@@ -274,9 +284,9 @@ export function CustomerPortal() {
</h1>
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-stone-600">Hi, {CUSTOMER.name.split(" ")[0]}</span>
<span className="text-sm text-stone-600">Hi, {clientName.split(" ")[0] || "Guest"}</span>
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-medium" style={{ background: branding.accentColor }}>
SM
{avatarInitials}
</div>
</div>
</div>
+123 -39
View File
@@ -1,13 +1,31 @@
import { useState } from "react";
import React, { useState, useEffect } from "react";
import { User, Lock, PawPrint, FileCheck, Plus, Archive } from "lucide-react";
import { CUSTOMER, PETS, SIGNED_AGREEMENTS } from "../mockData.js";
import { PetForm } from "./PetForm.js";
interface Props {
sessionId: string | null;
readOnly: boolean;
}
export function AccountSettings({ readOnly }: Props) {
interface PersonalInfoData {
id?: string;
email?: string;
firstName?: string;
lastName?: string;
phone?: string;
address?: string;
}
interface PetData {
id: string;
name: string;
species?: string;
breed?: string;
weight?: number;
photo?: string;
}
export function AccountSettings({ sessionId, readOnly }: Props) {
const [tab, setTab] = useState<"personal" | "password" | "pets" | "agreements">("personal");
return (
@@ -32,21 +50,65 @@ export function AccountSettings({ readOnly }: Props) {
))}
</div>
{tab === "personal" && <PersonalInfo readOnly={readOnly} />}
{tab === "personal" && <PersonalInfo sessionId={sessionId} readOnly={readOnly} />}
{tab === "password" && <PasswordChange readOnly={readOnly} />}
{tab === "pets" && <ManagePets readOnly={readOnly} />}
{tab === "pets" && <ManagePets sessionId={sessionId} readOnly={readOnly} />}
{tab === "agreements" && <Agreements />}
</div>
);
}
function PersonalInfo({ readOnly }: { readOnly: boolean }) {
function PersonalInfo({ sessionId, readOnly }: { sessionId: string | null; readOnly: boolean }) {
const [form, setForm] = useState({
name: CUSTOMER.name,
email: CUSTOMER.email,
phone: CUSTOMER.phone,
address: CUSTOMER.address,
name: "",
email: "",
phone: "",
address: "",
});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchPersonalInfo = async () => {
try {
setLoading(true);
const response = await fetch("/api/portal/me");
if (response.ok) {
const data: PersonalInfoData = await response.json();
setForm({
name: [data.firstName, data.lastName].filter(Boolean).join(" ") || "",
email: data.email || "",
phone: data.phone || "",
address: data.address || "",
});
} else {
setError("Failed to load personal info");
}
} catch {
setError("Failed to load personal info");
} finally {
setLoading(false);
}
};
fetchPersonalInfo();
}, [sessionId]);
if (loading) {
return (
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
<p className="text-sm text-stone-500">Loading personal info...</p>
</div>
);
}
if (error) {
return (
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
<p className="text-sm text-red-500">{error}</p>
</div>
);
}
return (
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
@@ -112,15 +174,58 @@ function PasswordChange({ readOnly }: { readOnly: boolean }) {
);
}
function ManagePets({ readOnly }: { readOnly: boolean }) {
function ManagePets({ sessionId, readOnly }: { sessionId: string | null; readOnly: boolean }) {
const [pets, setPets] = useState<PetData[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [editingPetId, setEditingPetId] = useState<string | null>(null);
const [showAddForm, setShowAddForm] = useState(false);
const editingPet = editingPetId ? PETS.find(p => p.id === editingPetId) ?? undefined : undefined;
useEffect(() => {
const fetchPets = async () => {
try {
setLoading(true);
const response = await fetch("/api/portal/pets");
if (response.ok) {
const data = await response.json();
setPets(Array.isArray(data) ? data : []);
} else {
setError("Failed to load pets");
}
} catch {
setError("Failed to load pets");
} finally {
setLoading(false);
}
};
fetchPets();
}, [sessionId]);
const editingPet = editingPetId ? pets.find(p => p.id === editingPetId) ?? undefined : undefined;
if (loading) {
return (
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
<p className="text-sm text-stone-500">Loading pets...</p>
</div>
);
}
if (error) {
return (
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
<p className="text-sm text-red-500">{error}</p>
</div>
);
}
if (editingPet || showAddForm) {
return (
<PetForm
pet={editingPet ?? undefined}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
pet={(editingPet ?? undefined) as any}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onSave={() => { setEditingPetId(null); setShowAddForm(false); }}
onCancel={() => { setEditingPetId(null); setShowAddForm(false); }}
/>
@@ -129,7 +234,7 @@ function ManagePets({ readOnly }: { readOnly: boolean }) {
return (
<div className="space-y-4">
{PETS.map(pet => (
{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-(--color-accent-light) flex items-center justify-center text-3xl">
{pet.photo}
@@ -168,31 +273,10 @@ function ManagePets({ readOnly }: { readOnly: boolean }) {
function Agreements() {
return (
<div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-stone-400 border-b border-stone-100">
<th className="px-5 py-3 font-medium">Document</th>
<th className="px-5 py-3 font-medium">Date Signed</th>
<th className="px-5 py-3 font-medium"></th>
</tr>
</thead>
<tbody>
{SIGNED_AGREEMENTS.map(agr => (
<tr key={agr.id} className="border-b border-stone-50">
<td className="px-5 py-3 font-medium text-stone-800">{agr.name}</td>
<td className="px-5 py-3 text-stone-600">
{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-(--color-accent-dark) font-medium hover:underline">View</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
<p className="text-sm text-stone-500">
No agreements found. There is currently no agreements table in the database.
</p>
</div>
);
}
File diff suppressed because it is too large Load Diff
+170 -231
View File
@@ -1,252 +1,191 @@
import { useState } from "react";
import { CreditCard, Download, DollarSign, Package, Zap, Plus, Trash2 } from "lucide-react";
import { INVOICES, SAVED_PAYMENT_METHODS, PREPAID_PACKAGES } from "../mockData.js";
import { useState, useEffect } from "react";
interface Props {
interface Invoice {
id: string;
status: "pending" | "paid" | "failed" | "refunded";
totalCents: number;
date: string;
description?: string;
}
interface PaymentMethod {
brand: string;
last4: string;
expiryMonth: number;
expiryYear: number;
}
interface Package {
name: string;
remaining: number;
}
interface BillingPaymentsProps {
sessionId: string | null;
readOnly: boolean;
}
const STATUS_STYLES: Record<string, string> = {
paid: "bg-green-100 text-green-700",
outstanding: "bg-amber-100 text-amber-700",
overdue: "bg-red-100 text-red-700",
};
export function BillingPayments({ sessionId, readOnly }: BillingPaymentsProps) {
const [invoices, setInvoices] = useState<Invoice[]>([]);
const [paymentMethods, setPaymentMethods] = useState<PaymentMethod[]>([]);
const [packages, setPackages] = useState<Package[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
export function BillingPayments({ readOnly }: Props) {
const [tab, setTab] = useState<"invoices" | "payment" | "packages">("invoices");
const [autopay, setAutopay] = useState(false);
const [showTipModal, setShowTipModal] = useState(false);
useEffect(() => {
async function fetchData() {
if (!sessionId) {
setLoading(false);
return;
}
const outstanding = INVOICES.filter(i => i.status === "outstanding");
const totalOutstanding = outstanding.reduce((sum, i) => sum + i.amount, 0);
try {
const response = await fetch("/api/portal/invoices", {
headers: {
"x-session-id": sessionId,
},
});
if (!response.ok) {
throw new Error("Failed to fetch invoices");
}
const data = await response.json();
setInvoices(data.invoices || []);
setPaymentMethods(data.paymentMethods || []);
setPackages(data.packages || []);
} catch (err) {
setError(err instanceof Error ? err.message : "An error occurred");
} finally {
setLoading(false);
}
}
fetchData();
}, [sessionId]);
const formatCents = (cents: number) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(cents / 100);
};
if (loading) {
return (
<div className="p-6">
<div className="animate-pulse space-y-4">
<div className="h-6 bg-gray-200 rounded w-1/3"></div>
<div className="h-24 bg-gray-200 rounded"></div>
<div className="h-24 bg-gray-200 rounded"></div>
</div>
</div>
);
}
if (error) {
return (
<div className="p-6">
<div className="text-red-600">Error: {error}</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Outstanding Balance Banner */}
{totalOutstanding > 0 && (
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div>
<p className="text-sm text-stone-500">Outstanding Balance</p>
<p className="text-3xl font-bold text-stone-800">${totalOutstanding.toFixed(2)}</p>
<p className="text-xs text-stone-400 mt-0.5">{outstanding.length} unpaid invoice{outstanding.length > 1 ? "s" : ""}</p>
</div>
{!readOnly && (
<div className="flex gap-2">
<button
onClick={() => setShowTipModal(true)}
className="px-4 py-2 border border-stone-200 rounded-lg text-sm font-medium text-stone-600 hover:bg-stone-50"
>
Add Tip
</button>
<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>
)}
</div>
)}
{/* Tabs */}
<div className="flex gap-2">
{([
{ id: "invoices" as const, label: "Invoices", icon: DollarSign },
{ id: "payment" as const, label: "Payment Methods", icon: CreditCard },
{ id: "packages" as const, label: "Packages", icon: Package },
]).map(({ id, label, icon: Icon }) => (
<button
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-(--color-accent-light) text-(--color-accent-dark)" : "text-stone-500 hover:bg-stone-50"
}`}
>
<Icon size={14} />
{label}
</button>
))}
</div>
{/* Invoices */}
{tab === "invoices" && (
<div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-stone-400 border-b border-stone-100">
<th className="px-5 py-3 font-medium">Date</th>
<th className="px-5 py-3 font-medium">Items</th>
<th className="px-5 py-3 font-medium">Amount</th>
<th className="px-5 py-3 font-medium">Status</th>
<th className="px-5 py-3 font-medium"></th>
</tr>
</thead>
<tbody>
{INVOICES.map(inv => (
<tr key={inv.id} className="border-b border-stone-50 hover:bg-stone-50/50">
<td className="px-5 py-3 text-stone-700">
{new Date(inv.date).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
</td>
<td className="px-5 py-3 text-stone-600">{inv.items.join(", ")}</td>
<td className="px-5 py-3 font-medium text-stone-800">${inv.amount.toFixed(2)}</td>
<td className="px-5 py-3">
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_STYLES[inv.status]}`}>
{inv.status}
</span>
</td>
<td className="px-5 py-3">
<button className="text-stone-400 hover:text-stone-600">
<Download size={14} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
<div className="p-6 space-y-8">
<h2 className="text-2xl font-semibold">Billing & Payments</h2>
{/* Payment Methods */}
{tab === "payment" && (
<div className="space-y-4">
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm space-y-3">
{SAVED_PAYMENT_METHODS.map(pm => (
<div key={pm.id} className="flex items-center justify-between py-2 border-b border-stone-50 last:border-0">
<section>
<h3 className="text-lg font-medium mb-4">Payment Methods</h3>
{paymentMethods.length === 0 ? (
<p className="text-gray-500 italic">No payment methods on file</p>
) : (
<div className="space-y-3">
{paymentMethods.map((method) => (
<div
key={`${method.brand}-${method.last4}`}
className="flex items-center justify-between p-4 border rounded-lg"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-stone-100 flex items-center justify-center">
<CreditCard size={18} className="text-stone-500" />
</div>
<div>
<p className="text-sm font-medium text-stone-800 capitalize">{pm.type} {pm.last4}</p>
<p className="text-xs text-stone-400">Expires {pm.expiry}</p>
<div className="w-10 h-6 bg-gray-200 rounded flex items-center justify-center text-xs">
{method.brand.toUpperCase()}
</div>
<span>**** {method.last4}</span>
<span className="text-gray-500">
{method.expiryMonth}/{method.expiryYear}
</span>
</div>
<div className="flex items-center gap-2">
{pm.isDefault && (
<span className="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded-full">Default</span>
)}
{!readOnly && (
<button className="p-1 text-stone-400 hover:text-red-500">
<Trash2 size={14} />
</button>
)}
{!readOnly && (
<button className="text-sm text-blue-600 hover:underline">
Remove
</button>
)}
</div>
))}
</div>
)}
</section>
{/* Packages */}
<section>
<h3 className="text-lg font-medium mb-4">Packages</h3>
{packages.length === 0 ? (
<p className="text-gray-500 italic">No packages purchased</p>
) : (
<div className="space-y-3">
{packages.map((pkg, index) => (
<div
key={index}
className="flex items-center justify-between p-4 border rounded-lg"
>
<span>{pkg.name}</span>
<span className="text-gray-600">{pkg.remaining} remaining</span>
</div>
))}
</div>
)}
</section>
{/* Invoices */}
<section>
<h3 className="text-lg font-medium mb-4">Invoice History</h3>
{invoices.length === 0 ? (
<p className="text-gray-500 italic">No invoices yet</p>
) : (
<div className="space-y-3">
{invoices.map((invoice) => (
<div
key={invoice.id}
className="flex items-center justify-between p-4 border rounded-lg"
>
<div className="flex flex-col">
<span className="font-medium">
{invoice.description || `Invoice ${invoice.id.slice(0, 8)}`}
</span>
<span className="text-sm text-gray-500">{invoice.date}</span>
</div>
<div className="flex items-center gap-4">
<span className="font-semibold">
{formatCents(invoice.totalCents)}
</span>
<span
className={`px-2 py-1 text-xs rounded ${
invoice.status === "pending"
? "bg-yellow-100 text-yellow-800"
: "bg-green-100 text-green-800"
}`}
>
{invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1)}
</span>
</div>
</div>
))}
{!readOnly && (
<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>
)}
</div>
{/* Autopay */}
<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-(--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>
<p className="text-xs text-stone-500">Automatically charge after each appointment</p>
</div>
</div>
{!readOnly ? (
<button
onClick={() => setAutopay(!autopay)}
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>
) : (
<span className="text-xs text-stone-400">{autopay ? "Enabled" : "Disabled"}</span>
)}
</div>
</div>
</div>
)}
{/* Packages */}
{tab === "packages" && (
<div className="space-y-4">
{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-(--color-accent)" />
<h3 className="font-medium text-stone-800">{pkg.name}</h3>
</div>
<div className="flex items-center gap-4 mb-3">
<div>
<p className="text-2xl font-bold text-stone-800">{pkg.totalCredits - pkg.usedCredits}</p>
<p className="text-xs text-stone-500">remaining of {pkg.totalCredits}</p>
</div>
<div className="flex-1 bg-stone-100 rounded-full h-3 overflow-hidden">
<div
className="bg-(--color-accent) h-full rounded-full"
style={{ width: `${((pkg.totalCredits - pkg.usedCredits) / pkg.totalCredits) * 100}%` }}
/>
</div>
</div>
<p className="text-xs text-stone-400">Expires {new Date(pkg.expiresAt).toLocaleDateString()}</p>
</div>
))}
</div>
)}
{/* Tip Modal */}
{showTipModal && !readOnly && (
<TipModal onClose={() => setShowTipModal(false)} />
)}
</div>
);
}
function TipModal({ onClose }: { onClose: () => void }) {
const [tipPercent, setTipPercent] = useState<number | null>(20);
const [customTip, setCustomTip] = useState("");
const presets = [15, 20, 25];
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-xl max-w-sm w-full p-6">
<h2 className="font-semibold text-stone-800 mb-4">Add a Tip</h2>
<div className="flex gap-2 mb-4">
{presets.map(pct => (
<button
key={pct}
onClick={() => { setTipPercent(pct); setCustomTip(""); }}
className={`flex-1 py-2 rounded-lg text-sm font-medium border ${
tipPercent === pct ? "border-(--color-accent) bg-(--color-accent-lighter) text-(--color-accent-dark)" : "border-stone-200 text-stone-600"
}`}
>
{pct}%
</button>
))}
<button
onClick={() => { setTipPercent(null); }}
className={`flex-1 py-2 rounded-lg text-sm font-medium border ${
tipPercent === null ? "border-(--color-accent) bg-(--color-accent-lighter) text-(--color-accent-dark)" : "border-stone-200 text-stone-600"
}`}
>
Custom
</button>
</div>
{tipPercent === null && (
<input
type="number"
placeholder="Enter amount"
value={customTip}
onChange={e => setCustomTip(e.target.value)}
className="w-full border border-stone-300 rounded-lg px-3 py-2 text-sm mb-4"
/>
)}
<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-(--color-accent) text-white rounded-lg text-sm font-medium">Add Tip</button>
</div>
</div>
</section>
</div>
);
}
export default BillingPayments;
+70 -27
View File
@@ -1,7 +1,28 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { Send, Check, CheckCheck, Bell, Mail, Smartphone, Megaphone, FileText, CreditCard } from "lucide-react";
import { MESSAGES, BUSINESS_NAME } from "../mockData.js";
import type { Message } from "../mockData.js";
interface Message {
id: string;
sender: "customer" | "business";
senderName: string;
text: string;
timestamp: string;
read: boolean;
}
interface NotificationCategory {
email: boolean;
sms: boolean;
push: boolean;
}
interface NotificationPreferences {
appointmentReminders: NotificationCategory;
vaccinationAlerts: NotificationCategory;
promotional: NotificationCategory;
reportCards: NotificationCategory;
invoiceReceipts: NotificationCategory;
}
interface Props {
readOnly: boolean;
@@ -39,15 +60,31 @@ export function Communication({ readOnly }: Props) {
}
function MessageThread({ readOnly }: { readOnly: boolean }) {
const [messages, setMessages] = useState<Message[]>(MESSAGES);
const [messages, setMessages] = useState<Message[]>([]);
const [newMessage, setNewMessage] = useState("");
const [businessName, setBusinessName] = useState<string>("Business");
useEffect(() => {
async function fetchBranding() {
try {
const response = await fetch("/api/branding");
if (response.ok) {
const data = await response.json();
setBusinessName(data.businessName || data.name || "Business");
}
} catch {
setBusinessName("Business");
}
}
fetchBranding();
}, []);
const handleSend = () => {
if (!newMessage.trim() || readOnly) return;
const msg: Message = {
id: `m-${Date.now()}`,
sender: "customer",
senderName: "Sarah",
senderName: "You",
text: newMessage.trim(),
timestamp: new Date().toISOString(),
read: false,
@@ -59,32 +96,36 @@ function MessageThread({ readOnly }: { readOnly: boolean }) {
return (
<div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden flex flex-col" style={{ height: "500px" }}>
<div className="px-5 py-3 border-b border-stone-200 bg-stone-50">
<p className="text-sm font-medium text-stone-800">{BUSINESS_NAME}</p>
<p className="text-sm font-medium text-stone-800">{businessName}</p>
<p className="text-xs text-stone-400">Usually replies within a few hours</p>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{messages.map(msg => (
<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-(--color-accent) text-white rounded-br-md"
: "bg-stone-100 text-stone-800 rounded-bl-md"
}`}>
<p className="text-sm">{msg.text}</p>
<div className={`flex items-center gap-1 mt-1 ${msg.sender === "customer" ? "justify-end" : ""}`}>
<span className={`text-xs ${msg.sender === "customer" ? "text-white/60" : "text-stone-400"}`}>
{new Date(msg.timestamp).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}
</span>
{msg.sender === "customer" && (
msg.read
? <CheckCheck size={12} className="text-white/60" />
: <Check size={12} className="text-white/60" />
)}
{messages.length === 0 ? (
<p className="text-stone-400 text-center text-sm italic">No messages yet</p>
) : (
messages.map(msg => (
<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-(--color-accent) text-white rounded-br-md"
: "bg-stone-100 text-stone-800 rounded-bl-md"
}`}>
<p className="text-sm">{msg.text}</p>
<div className={`flex items-center gap-1 mt-1 ${msg.sender === "customer" ? "justify-end" : ""}`}>
<span className={`text-xs ${msg.sender === "customer" ? "text-white/60" : "text-stone-400"}`}>
{new Date(msg.timestamp).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}
</span>
{msg.sender === "customer" && (
msg.read
? <CheckCheck size={12} className="text-white/60" />
: <Check size={12} className="text-white/60" />
)}
</div>
</div>
</div>
</div>
))}
))
)}
</div>
{!readOnly && (
@@ -111,7 +152,7 @@ function MessageThread({ readOnly }: { readOnly: boolean }) {
}
function NotificationPreferences({ readOnly }: { readOnly: boolean }) {
const [prefs, setPrefs] = useState({
const [prefs, setPrefs] = useState<NotificationPreferences>({
appointmentReminders: { email: true, sms: true, push: true },
vaccinationAlerts: { email: true, sms: false, push: true },
promotional: { email: false, sms: false, push: false },
@@ -119,7 +160,7 @@ function NotificationPreferences({ readOnly }: { readOnly: boolean }) {
invoiceReceipts: { email: true, sms: false, push: false },
});
type PrefKey = keyof typeof prefs;
type PrefKey = keyof NotificationPreferences;
type ChannelKey = "email" | "sms" | "push";
const toggle = (category: PrefKey, channel: ChannelKey) => {
@@ -194,3 +235,5 @@ function NotificationPreferences({ readOnly }: { readOnly: boolean }) {
</div>
);
}
export default Communication;
+267 -65
View File
@@ -1,11 +1,53 @@
import { useState, useEffect } from "react";
import { Calendar, Clock, PawPrint, CreditCard, Star, ChevronRight, AlertTriangle } from "lucide-react";
import { PETS, UPCOMING_APPOINTMENTS, PAST_APPOINTMENTS, INVOICES, LOYALTY, BUSINESS_NAME } from "../mockData.js";
interface Props {
interface DashboardProps {
sessionId: string | null;
clientName: string;
onNavigate: (section: "appointments" | "pets" | "billing" | "reports") => void;
readOnly: boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onReschedule?: (appointment: any) => void;
onReschedule: (appointmentId: string) => void;
}
interface Appointment {
id: string;
date: string;
time: string;
petName: string;
serviceName: string;
status: string;
staffName?: string;
services?: string[];
addOns?: string[];
groomerName?: string;
}
interface Pet {
id: string;
name: string;
species: string;
breed?: string;
dateOfBirth?: string;
weight?: number;
healthAlerts: string[];
photo?: string;
vaccinations?: { name: string; status: string }[];
}
interface Invoice {
id: string;
invoiceNumber: string;
date: string;
amount: number;
status: string;
dueDate?: string;
items: { description: string; price: number }[];
}
interface Branding {
clinicName: string;
logoUrl?: string;
primaryColor: string;
}
function daysUntil(dateStr: string): number {
@@ -17,27 +59,154 @@ function daysUntil(dateStr: string): number {
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" });
return new Date(dateStr).toLocaleDateString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
});
}
export function Dashboard({ onNavigate, readOnly, onReschedule }: Props) {
const nextAppt = UPCOMING_APPOINTMENTS[0];
const outstanding = INVOICES.filter(i => i.status === "outstanding").reduce((sum, i) => sum + i.amount, 0);
const recentEvents = [
...PAST_APPOINTMENTS.slice(0, 3).map(a => ({
id: a.id, date: a.date, text: `${a.petName}${a.services.join(", ")}`, type: "appointment" as const,
})),
...INVOICES.filter(i => i.status === "paid").slice(0, 2).map(i => ({
id: i.id, date: i.date, text: `Invoice paid — $${i.amount}`, type: "payment" as const,
})),
].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()).slice(0, 5);
export function Dashboard({
sessionId,
clientName,
onNavigate,
readOnly,
onReschedule,
}: DashboardProps) {
const [appointments, setAppointments] = useState<Appointment[]>([]);
const [pets, setPets] = useState<Pet[]>([]);
const [pendingInvoices, setPendingInvoices] = useState<Invoice[]>([]);
const [branding, setBranding] = useState<Branding | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
if (!sessionId) {
setLoading(false);
return;
}
setLoading(true);
setError(null);
try {
const headers = {
"x-session-id": sessionId,
};
const [appointmentsRes, petsRes, invoicesRes, brandingRes] = await Promise.all([
fetch("/api/portal/appointments", { headers }),
fetch("/api/portal/pets", { headers }),
fetch("/api/portal/invoices", { headers }),
fetch("/api/branding", { headers }),
]);
if (!appointmentsRes.ok || !petsRes.ok || !invoicesRes.ok || !brandingRes.ok) {
throw new Error("Failed to fetch dashboard data");
}
const appointmentsData = await appointmentsRes.json();
const petsData = await petsRes.json();
const invoicesData = await invoicesRes.json();
const brandingData = await brandingRes.json();
setAppointments(appointmentsData.appointments || []);
setPets(petsData.pets || []);
// Filter for pending invoices only (not "outstanding")
const pending = (invoicesData.invoices || []).filter(
(invoice: Invoice) => invoice.status === "pending"
);
setPendingInvoices(pending);
setBranding(brandingData);
} catch (err) {
setError(err instanceof Error ? err.message : "An error occurred");
} finally {
setLoading(false);
}
};
fetchData();
}, [sessionId]);
const getUpcomingAppointments = (): Appointment[] => {
const now = new Date();
return appointments
.filter((apt) => new Date(`${apt.date}T${apt.time}`) >= now)
.sort(
(a, b) =>
new Date(`${a.date}T${a.time}`).getTime() -
new Date(`${b.date}T${b.time}`).getTime()
)
.slice(0, 5);
};
const getPetHealthAlerts = (): { petName: string; alert: string }[] => {
return pets
.filter((pet) => pet.healthAlerts && pet.healthAlerts.length > 0)
.flatMap((pet) =>
pet.healthAlerts.map((alert) => ({ petName: pet.name, alert }))
);
};
const formatCurrency = (amount: number): string => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(amount);
};
const getPendingBalance = (): number => {
return pendingInvoices.reduce((sum, invoice) => sum + invoice.amount, 0);
};
if (loading) {
return (
<div className="space-y-6">
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-(--color-accent)" />
</div>
</div>
);
}
if (error) {
return (
<div className="space-y-6">
<div className="bg-red-50 border border-red-200 rounded-2xl p-5">
<p className="text-red-700">Error: {error}</p>
</div>
</div>
);
}
if (!sessionId) {
return (
<div className="space-y-6">
<div className="bg-stone-100 rounded-2xl p-5 text-center">
<p className="text-stone-600">Please sign in to view your dashboard.</p>
</div>
</div>
);
}
const upcomingAppointments = getUpcomingAppointments();
const healthAlerts = getPetHealthAlerts();
const pendingBalance = getPendingBalance();
const nextAppt = upcomingAppointments[0];
return (
<div className="space-y-6">
{/* Welcome */}
<div>
<h2 className="text-2xl font-semibold text-stone-800">Welcome back, Sarah</h2>
<p className="text-stone-500 text-sm mt-1">Here's what's happening at {BUSINESS_NAME}</p>
<h2 className="text-2xl font-semibold text-stone-800">
Welcome back, {clientName}
</h2>
<p className="text-stone-500 text-sm mt-1">
Here's what's happening at {branding?.clinicName || "your clinic"}
</p>
</div>
{/* Next Appointment */}
@@ -55,11 +224,16 @@ export function Dashboard({ onNavigate, readOnly, onReschedule }: Props) {
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
<div className="flex-1">
<p className="text-lg font-semibold text-stone-800">
{nextAppt.petName} with {nextAppt.groomerName}
{nextAppt.petName}
{nextAppt.groomerName && ` with ${nextAppt.groomerName}`}
{nextAppt.staffName && ` with ${nextAppt.staffName}`}
</p>
<p className="text-stone-600 text-sm mt-1">
{nextAppt.services.join(", ")}
{nextAppt.addOns.length > 0 && ` + ${nextAppt.addOns.join(", ")}`}
{nextAppt.services?.join(", ") ||
nextAppt.serviceName ||
"Appointment"}
{nextAppt.addOns && nextAppt.addOns.length > 0 &&
` + ${nextAppt.addOns.join(", ")}`}
</p>
<div className="flex items-center gap-4 mt-2 text-sm text-stone-500">
<span className="flex items-center gap-1">
@@ -73,14 +247,16 @@ export function Dashboard({ onNavigate, readOnly, onReschedule }: Props) {
</div>
</div>
<div className="text-center sm:text-right">
<div className="text-3xl font-bold text-(--color-accent-dark)">{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>
{!readOnly && (
<div className="flex gap-2 mt-4">
<button
onClick={() => onReschedule?.(nextAppt)}
onClick={() => onReschedule(nextAppt.id)}
className="text-sm px-3 py-1.5 border border-stone-200 rounded-lg text-stone-600 hover:bg-stone-50"
>
Reschedule
@@ -99,8 +275,8 @@ export function Dashboard({ onNavigate, readOnly, onReschedule }: Props) {
{/* Pet Cards & Loyalty */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{/* Pet Cards */}
{PETS.map(pet => {
const expiringVax = pet.vaccinations.filter(v => v.status !== "valid");
{pets.map((pet) => {
const petAlerts = pet.healthAlerts || [];
return (
<button
key={pet.id}
@@ -109,59 +285,63 @@ export function Dashboard({ onNavigate, readOnly, onReschedule }: Props) {
>
<div className="flex items-center gap-3 mb-3">
<div className="w-12 h-12 rounded-full bg-(--color-accent-light) flex items-center justify-center text-2xl">
{pet.photo}
{pet.photo || pet.name.charAt(0).toUpperCase()}
</div>
<div>
<p className="font-semibold text-stone-800">{pet.name}</p>
<p className="text-xs text-stone-500">{pet.breed} · {pet.weight} lbs</p>
<p className="text-xs text-stone-500">
{pet.breed || pet.species}
{pet.weight && ` · ${pet.weight} lbs`}
</p>
</div>
</div>
{expiringVax.length > 0 ? (
{petAlerts.length > 0 ? (
<div className="flex items-center gap-1.5 text-xs text-amber-700 bg-amber-50 px-2 py-1 rounded-lg">
<AlertTriangle size={12} />
{expiringVax.map(v => v.name).join(", ")} {expiringVax[0]?.status === "expired" ? "expired" : "expiring soon"}
{petAlerts.join(", ")}
</div>
) : (
<div className="flex items-center gap-1.5 text-xs text-green-700 bg-green-50 px-2 py-1 rounded-lg">
<PawPrint size={12} />
All vaccinations current
All health records current
</div>
)}
</button>
);
})}
{/* Loyalty Card */}
{/* Loyalty Card Placeholder */}
<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-(--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-(--color-accent) h-full rounded-full transition-all"
style={{ width: `${(LOYALTY.points / LOYALTY.nextRewardAt) * 100}%` }}
/>
<div className="flex flex-col items-center justify-center py-4">
<div className="w-16 h-16 rounded-full bg-(--color-accent-light) flex items-center justify-center mb-3">
<Star size={32} className="text-(--color-accent)" />
</div>
<p className="text-lg font-bold text-stone-800">Coming Soon</p>
<p className="text-xs text-stone-500 text-center mt-1">
Earn points with every visit and redeem for exclusive rewards
</p>
</div>
<p className="text-xs text-stone-500 mt-1">
{LOYALTY.nextRewardAt - LOYALTY.points} pts to {LOYALTY.rewardName}
</p>
</div>
</div>
{/* Outstanding Balance & Recent Activity */}
{/* Pending Balance & Recent Activity */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Outstanding Balance */}
{outstanding > 0 && (
{/* Pending Invoices */}
{pendingInvoices.length > 0 && (
<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 justify-between mb-4">
<div>
<div className="flex items-center gap-2 text-sm font-medium text-stone-500 mb-1">
<CreditCard size={16} />
Outstanding Balance
Pending Invoices
</div>
<p className="text-2xl font-bold text-stone-800">${outstanding.toFixed(2)}</p>
<p className="text-2xl font-bold text-stone-800">
{formatCurrency(pendingBalance)}
</p>
</div>
{!readOnly && (
<button
@@ -172,29 +352,51 @@ export function Dashboard({ onNavigate, readOnly, onReschedule }: Props) {
</button>
)}
</div>
<div className="space-y-2">
{pendingInvoices.slice(0, 3).map((invoice) => (
<div
key={invoice.id}
className="flex items-center justify-between text-sm"
>
<span className="text-stone-600">
{invoice.invoiceNumber} - {formatCurrency(invoice.amount)}
</span>
<span className="text-xs text-stone-400">
Due {invoice.dueDate ? formatDate(invoice.dueDate) : formatDate(invoice.date)}
</span>
</div>
))}
</div>
</div>
)}
{/* Recent Activity */}
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
<h3 className="text-sm font-medium text-stone-500 mb-3">Recent Activity</h3>
<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-(--color-accent)"}`} />
<span className="text-stone-600 flex-1">{evt.text}</span>
<span className="text-xs text-stone-400">{formatDate(evt.date)}</span>
</div>
))}
{/* Health Alerts */}
{healthAlerts.length > 0 && (
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
<div className="flex items-center gap-2 text-sm font-medium text-amber-700 mb-3">
<AlertTriangle size={16} />
Health Alerts
</div>
<div className="space-y-2">
{healthAlerts.slice(0, 5).map((item, index) => (
<div key={index} className="flex items-center gap-3 text-sm">
<div className="w-2 h-2 rounded-full shrink-0 bg-amber-400" />
<span className="text-stone-600 flex-1">
<span className="font-medium">{item.petName}:</span>{" "}
{item.alert}
</span>
</div>
))}
</div>
<button
onClick={() => onNavigate("pets")}
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>
</div>
<button
onClick={() => onNavigate("appointments")}
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>
</div>
)}
</div>
</div>
);
}
}
+162 -112
View File
@@ -1,52 +1,152 @@
import { useState } from "react";
import { PawPrint, Heart, Scissors, Syringe, AlertTriangle, CheckCircle, Clock, Upload, Edit3 } from "lucide-react";
import { PETS, PAST_APPOINTMENTS } from "../mockData.js";
import type { Pet } from "../mockData.js";
import { useState, useEffect } from "react";
import { PawPrint, Heart, Scissors, Clock, Edit3, Loader2 } from "lucide-react";
import { PetForm } from "./PetForm.js";
interface Pet {
id: string;
name: string;
breed: string;
weight: number;
birthDate: string;
photoUrl: string | null;
notes: string | null;
}
interface Appointment {
id: string;
startTime: string;
endTime: string;
status: string;
confirmationStatus: string | null;
customerNotes: string | null;
groomerNotes: string | null;
reportCardId: string | null;
pet: { id: string; name: string; photo: string | null } | null;
service: { id: string } | null;
staff: { id: string; name: string } | null;
}
interface AppointmentsResponse {
upcoming: Appointment[];
past: Appointment[];
}
interface Props {
sessionId: string | null;
readOnly: boolean;
}
type VaxStatus = "valid" | "expiring" | "expired";
const VAX_STATUS_STYLES: Record<VaxStatus, { bg: string; text: string; icon: typeof CheckCircle }> = {
valid: { bg: "bg-green-100", text: "text-green-700", icon: CheckCircle },
expiring: { bg: "bg-amber-100", text: "text-amber-700", icon: Clock },
expired: { bg: "bg-red-100", text: "text-red-700", icon: AlertTriangle },
};
function buildHeaders(sessionId: string | null): Record<string, string> {
const headers: Record<string, string> = {};
if (sessionId) {
headers["X-Impersonation-Session-Id"] = sessionId;
}
return headers;
}
export function PetProfiles({ readOnly }: Props) {
const [selectedPetId, setSelectedPetId] = useState<string>(PETS[0]?.id ?? "");
const [activeTab, setActiveTab] = useState<"info" | "medical" | "grooming" | "vaccinations" | "history">("info");
export function PetProfiles({ sessionId, readOnly }: Props) {
const [pets, setPets] = useState<Pet[]>([]);
const [appointments, setAppointments] = useState<AppointmentsResponse>({ upcoming: [], past: [] });
const [selectedPetId, setSelectedPetId] = useState<string>("");
const [activeTab, setActiveTab] = useState<"info" | "medical" | "grooming" | "history">("info");
const [editingPetId, setEditingPetId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const pet = PETS.find(p => p.id === selectedPetId)!;
const petHistory = PAST_APPOINTMENTS.filter(a => a.petId === selectedPetId);
const editingPet = editingPetId ? PETS.find(p => p.id === editingPetId) ?? undefined : undefined;
useEffect(() => {
async function fetchData() {
setLoading(true);
setError(null);
try {
const [petsRes, apptsRes] = await Promise.all([
fetch("/api/portal/pets", { headers: buildHeaders(sessionId) }),
fetch("/api/portal/appointments", { headers: buildHeaders(sessionId) }),
]);
if (!petsRes.ok) {
throw new Error("Failed to load pets");
}
if (!apptsRes.ok) {
throw new Error("Failed to load appointments");
}
const petsData = await petsRes.json();
const apptsData: AppointmentsResponse = await apptsRes.json();
setPets(petsData);
setAppointments(apptsData);
if (petsData.length > 0 && !selectedPetId) {
setSelectedPetId(petsData[0].id);
}
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load data");
} finally {
setLoading(false);
}
}
fetchData();
}, [sessionId]);
const selectedPet = pets.find(p => p.id === selectedPetId) ?? null;
const petHistory = appointments.past.filter(a => a.pet?.id === selectedPetId);
const editingPet = editingPetId ? pets.find(p => p.id === editingPetId) ?? null : null;
function handlePetSave(updatedPet: Pet) {
setPets(prev => prev.map(p => p.id === updatedPet.id ? updatedPet : p));
setEditingPetId(null);
}
if (editingPet) {
return (
<PetForm
pet={editingPet}
onSave={() => setEditingPetId(null)}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
pet={editingPet as any}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onSave={handlePetSave as any}
onCancel={() => setEditingPetId(null)}
/>
);
}
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 size={24} className="animate-spin text-stone-400" />
</div>
);
}
if (error) {
return (
<div className="text-center py-12">
<p className="text-red-500 text-sm">{error}</p>
</div>
);
}
if (pets.length === 0) {
return (
<div className="text-center py-12">
<p className="text-stone-400 text-sm">No pets found</p>
</div>
);
}
return (
<div className="space-y-6">
{/* Pet Selector */}
<div className="flex gap-3">
{PETS.map(p => (
<div className="flex gap-3 overflow-x-auto pb-1">
{pets.map(p => (
<button
key={p.id}
onClick={() => { setSelectedPetId(p.id); setActiveTab("info"); }}
className={`flex items-center gap-3 px-4 py-3 rounded-xl border transition-colors ${
className={`flex items-center gap-3 px-4 py-3 rounded-xl border transition-colors shrink-0 ${
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>
<span className="text-2xl">{p.photoUrl ? "🐾" : "🐾"}</span>
<div className="text-left">
<p className="font-medium text-stone-800 text-sm">{p.name}</p>
<p className="text-xs text-stone-500">{p.breed}</p>
@@ -56,23 +156,31 @@ export function PetProfiles({ readOnly }: Props) {
</div>
{/* 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-(--color-accent-light) flex items-center justify-center text-4xl">
{pet.photo}
{selectedPet && (
<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-(--color-accent-light) flex items-center justify-center text-4xl overflow-hidden">
{selectedPet.photoUrl ? (
<img src={selectedPet.photoUrl} alt={selectedPet.name} className="w-full h-full object-cover" />
) : (
<span>🐾</span>
)}
</div>
<div className="flex-1">
<h2 className="text-xl font-semibold text-stone-800">{selectedPet.name}</h2>
<p className="text-stone-500 text-sm">{selectedPet.breed} · {selectedPet.weight} lbs</p>
<p className="text-stone-400 text-xs mt-0.5">
Born {selectedPet.birthDate ? new Date(selectedPet.birthDate).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }) : "Unknown"}
</p>
</div>
{!readOnly && (
<button onClick={() => setEditingPetId(selectedPet.id)} className="p-2 hover:bg-stone-50 rounded-lg">
<Edit3 size={16} className="text-stone-400" />
</button>
)}
</div>
<div className="flex-1">
<h2 className="text-xl font-semibold text-stone-800">{pet.name}</h2>
<p className="text-stone-500 text-sm">{pet.breed} · {pet.weight} lbs · {pet.sex === "male" ? "♂" : "♀"} {pet.spayedNeutered ? "(spayed/neutered)" : ""}</p>
<p className="text-stone-400 text-xs mt-0.5">Born {new Date(pet.dob).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}</p>
</div>
{!readOnly && (
<button onClick={() => setEditingPetId(pet.id)} className="p-2 hover:bg-stone-50 rounded-lg">
<Edit3 size={16} className="text-stone-400" />
</button>
)}
</div>
</div>
)}
{/* Tabs */}
<div className="flex gap-1 bg-white rounded-xl border border-stone-200 p-1 overflow-x-auto">
@@ -80,7 +188,6 @@ export function PetProfiles({ readOnly }: Props) {
{ id: "info", label: "Basic Info", icon: PawPrint },
{ id: "medical", label: "Medical", icon: Heart },
{ id: "grooming", label: "Grooming", icon: Scissors },
{ id: "vaccinations", label: "Vaccinations", icon: Syringe },
{ id: "history", label: "History", icon: Clock },
] as const).map(({ id, label, icon: Icon }) => (
<button
@@ -98,10 +205,9 @@ export function PetProfiles({ readOnly }: Props) {
{/* Tab Content */}
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
{activeTab === "info" && <BasicInfoTab pet={pet} readOnly={readOnly} />}
{activeTab === "medical" && <MedicalTab pet={pet} readOnly={readOnly} />}
{activeTab === "grooming" && <GroomingTab pet={pet} readOnly={readOnly} />}
{activeTab === "vaccinations" && <VaccinationsTab pet={pet} readOnly={readOnly} />}
{activeTab === "info" && selectedPet && <BasicInfoTab pet={selectedPet} readOnly={readOnly} />}
{activeTab === "medical" && selectedPet && <MedicalTab pet={selectedPet} readOnly={readOnly} />}
{activeTab === "grooming" && selectedPet && <GroomingTab pet={selectedPet} readOnly={readOnly} />}
{activeTab === "history" && <HistoryTab petHistory={petHistory} />}
</div>
</div>
@@ -121,11 +227,10 @@ function BasicInfoTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
return (
<div>
<InfoRow label="Name" value={pet.name} />
<InfoRow label="Breed" value={pet.breed} />
<InfoRow label="Breed" value={pet.breed || "Unknown"} />
<InfoRow label="Weight" value={`${pet.weight} lbs`} />
<InfoRow label="Date of Birth" value={new Date(pet.dob).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })} />
<InfoRow label="Sex" value={pet.sex === "male" ? "Male" : "Female"} />
<InfoRow label="Spayed/Neutered" value={pet.spayedNeutered ? "Yes" : "No"} />
<InfoRow label="Date of Birth" value={pet.birthDate ? new Date(pet.birthDate).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }) : "Unknown"} />
<InfoRow label="Notes" value={pet.notes || "None"} />
{!readOnly && (
<button className="mt-4 text-sm text-(--color-accent-dark) font-medium hover:underline">
Upload Photo
@@ -138,12 +243,7 @@ function BasicInfoTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
function MedicalTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
return (
<div>
<InfoRow label="Allergies" value={pet.allergies} />
<InfoRow label="Skin Conditions" value={pet.skinConditions} />
<InfoRow label="Anxiety Triggers" value={pet.anxietyTriggers} />
<InfoRow label="Aggression Notes" value={pet.aggressionNotes} />
<InfoRow label="Mobility Issues" value={pet.mobilityIssues} />
<InfoRow label="Medications" value={pet.medications} />
<InfoRow label="Notes" value={pet.notes || "No medical notes on file"} />
{!readOnly && (
<p className="mt-3 text-xs text-stone-400">
Changes to medical notes will be flagged for staff review.
@@ -156,10 +256,7 @@ function MedicalTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
function GroomingTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
return (
<div>
<InfoRow label="Preferred Cut" value={pet.preferredCut} />
<InfoRow label="Shampoo Preference" value={pet.shampooPreference} />
<InfoRow label="Sensitive Areas" value={pet.sensitiveAreas} />
<InfoRow label="Standing Instructions" value={pet.standingInstructions} />
<InfoRow label="Notes" value={pet.notes || "No grooming notes on file"} />
{!readOnly && (
<button className="mt-4 text-sm text-(--color-accent-dark) font-medium hover:underline">
Upload Reference Photo
@@ -169,58 +266,7 @@ function GroomingTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
);
}
function VaccinationsTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
return (
<div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-stone-400 border-b border-stone-100">
<th className="pb-2 font-medium">Vaccine</th>
<th className="pb-2 font-medium">Administered</th>
<th className="pb-2 font-medium">Expires</th>
<th className="pb-2 font-medium">Status</th>
<th className="pb-2 font-medium">Proof</th>
</tr>
</thead>
<tbody>
{pet.vaccinations.map(vax => {
const style = VAX_STATUS_STYLES[vax.status];
const StatusIcon = style.icon;
return (
<tr key={vax.name} className="border-b border-stone-50">
<td className="py-2.5 font-medium text-stone-800">{vax.name}</td>
<td className="py-2.5 text-stone-600">{new Date(vax.lastAdministered).toLocaleDateString()}</td>
<td className="py-2.5 text-stone-600">{new Date(vax.expirationDate).toLocaleDateString()}</td>
<td className="py-2.5">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${style.bg} ${style.text}`}>
<StatusIcon size={12} />
{vax.status}
</span>
</td>
<td className="py-2.5">
{vax.documentUploaded ? (
<span className="text-green-600 text-xs">Uploaded</span>
) : !readOnly ? (
<button className="flex items-center gap-1 text-xs text-(--color-accent-dark) hover:underline">
<Upload size={12} />
Upload
</button>
) : (
<span className="text-stone-400 text-xs">Missing</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}
function HistoryTab({ petHistory }: { petHistory: typeof PAST_APPOINTMENTS }) {
function HistoryTab({ petHistory }: { petHistory: Appointment[] }) {
return (
<div className="space-y-3">
{petHistory.length === 0 ? (
@@ -232,14 +278,18 @@ function HistoryTab({ petHistory }: { petHistory: typeof PAST_APPOINTMENTS }) {
<Scissors size={14} />
</div>
<div className="flex-1">
<p className="text-sm font-medium text-stone-800">{appt.services.join(", ")}</p>
<p className="text-xs text-stone-500">with {appt.groomerName} · ${appt.price}</p>
<p className="text-sm font-medium text-stone-800">
{appt.service ? "Grooming Service" : "Appointment"}
</p>
<p className="text-xs text-stone-500">
with {appt.staff?.name || "Unknown Groomer"}
</p>
</div>
<span className="text-xs text-stone-400">
{new Date(appt.date).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
{new Date(appt.startTime).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
</span>
{appt.reportCardId && (
<span className="text-xs text-(--color-accent-dark) font-medium">Report </span>
<span className="text-xs text-(--color-accent-dark) font-medium">Report</span>
)}
</div>
))
+125 -45
View File
@@ -1,9 +1,8 @@
import { useState } from "react";
import { FileText, Share2, Calendar, Smile, Meh, AlertCircle, ChevronRight } from "lucide-react";
import { REPORT_CARDS } from "../mockData.js";
import type { ReportCard } from "../mockData.js";
import { useState, useEffect } from "react";
import { FileText, Share2, Calendar, Smile, Meh, ChevronRight, Loader2 } from "lucide-react";
type MoodKey = "calm" | "cooperative" | "anxious" | "wiggly";
const MOOD_CONFIG: Record<MoodKey, { icon: typeof Smile; label: string; color: string; bg: string }> = {
calm: { icon: Smile, label: "Calm & Relaxed", color: "text-green-700", bg: "bg-green-100" },
cooperative: { icon: Smile, label: "Cooperative", color: "text-blue-700", bg: "bg-blue-100" },
@@ -11,8 +10,87 @@ const MOOD_CONFIG: Record<MoodKey, { icon: typeof Smile; label: string; color: s
wiggly: { icon: Meh, label: "Wiggly", color: "text-purple-700", bg: "bg-purple-100" },
};
interface Appointment {
id: string;
petId: string;
serviceId: string;
groomerId: string | null;
date: string;
time: string;
status: string;
petName?: string;
serviceName?: string;
groomerName?: string;
reportCardId?: string;
}
export function ReportCards() {
const [selectedCard, setSelectedCard] = useState<ReportCard | null>(null);
const [appointments, setAppointments] = useState<Appointment[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedCard, setSelectedCard] = useState<Appointment | null>(null);
useEffect(() => {
const fetchReportCards = async () => {
try {
const response = await fetch("/api/portal/appointments");
if (response.ok) {
const data = await response.json();
const allAppointments: Appointment[] = data.appointments || data || [];
const reportCardAppointments = allAppointments.filter(
(appt) => appt.reportCardId
);
setAppointments(reportCardAppointments);
} else {
setError("Failed to load report cards.");
}
} catch {
setError("Failed to load report cards. Please try again.");
} finally {
setIsLoading(false);
}
};
fetchReportCards();
}, []);
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="animate-spin text-stone-400" size={24} />
<span className="ml-3 text-stone-500">Loading report cards...</span>
</div>
);
}
if (error) {
return (
<div className="text-center py-12">
<p className="text-red-600 mb-4">{error}</p>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-stone-100 text-stone-700 rounded-md hover:bg-stone-200"
>
Retry
</button>
</div>
);
}
if (appointments.length === 0) {
return (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-stone-100 flex items-center justify-center">
<FileText size={24} className="text-stone-400" />
</div>
<h3 className="text-lg font-medium text-stone-800 mb-1">No Report Cards Yet</h3>
<p className="text-sm text-stone-500">
Report cards from your grooming visits will appear here after your appointments.
</p>
</div>
);
}
if (selectedCard) {
return <ReportCardDetail card={selectedCard} onBack={() => setSelectedCard(null)} />;
@@ -23,8 +101,9 @@ export function ReportCards() {
<p className="text-sm text-stone-500">Grooming report cards from your recent visits</p>
<div className="space-y-4">
{REPORT_CARDS.map(card => {
const mood = MOOD_CONFIG[card.behaviorMood];
{appointments.map((card) => {
const moodKey: MoodKey = "cooperative";
const mood = MOOD_CONFIG[moodKey];
const MoodIcon = mood.icon;
return (
<button
@@ -38,16 +117,20 @@ export function ReportCards() {
</div>
<div className="flex-1">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-stone-800">{card.petName}'s Report Card</h3>
<h3 className="font-semibold text-stone-800">{card.petName || "Pet"}'s Report Card</h3>
<ChevronRight size={16} className="text-stone-400" />
</div>
<p className="text-sm text-stone-500 mt-0.5">
{card.servicesPerformed.join(", ")} with {card.groomerName}
{card.serviceName || "Grooming"} with {card.groomerName || "your groomer"}
</p>
<div className="flex items-center gap-3 mt-2">
<span className="flex items-center gap-1 text-xs text-stone-400">
<Calendar size={12} />
{new Date(card.date).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
{new Date(card.date).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
</span>
<span className={`flex items-center gap-1 text-xs px-2 py-0.5 rounded-full ${mood.bg} ${mood.color}`}>
<MoodIcon size={12} />
@@ -64,28 +147,40 @@ export function ReportCards() {
);
}
function ReportCardDetail({ card, onBack }: { card: ReportCard; onBack: () => void }) {
const mood = MOOD_CONFIG[card.behaviorMood];
function ReportCardDetail({ card, onBack }: { card: Appointment; onBack: () => void }) {
const moodKey: MoodKey = "cooperative";
const mood = MOOD_CONFIG[moodKey];
const MoodIcon = mood.icon;
return (
<div className="space-y-6">
<button onClick={onBack} className="text-sm text-(--color-accent-dark) font-medium hover:underline">
← Back to Report Cards
<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-(--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>
<h2 className="text-xl font-semibold text-stone-800">
{card.petName || "Pet"}'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">
<Share2 size={14} />
Share
</button>
</div>
<p className="text-sm text-stone-600">
{new Date(card.date).toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric", year: "numeric" })} · Groomer: {card.groomerName}
{new Date(card.date).toLocaleDateString("en-US", {
weekday: "long",
month: "long",
day: "numeric",
year: "numeric",
})}
{card.groomerName ? ` · Groomer: ${card.groomerName}` : ""}
</p>
</div>
@@ -99,14 +194,14 @@ function ReportCardDetail({ card, onBack }: { card: ReportCard; onBack: () => vo
<div className="w-full h-32 bg-stone-200 rounded-lg flex items-center justify-center text-stone-400 text-sm mb-2">
Photo placeholder
</div>
<p className="text-sm text-stone-600">{card.beforeDescription}</p>
<p className="text-sm text-stone-600">Before photo description not available.</p>
</div>
<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>
<p className="text-sm text-stone-700">After photo description not available.</p>
</div>
</div>
</div>
@@ -115,11 +210,9 @@ function ReportCardDetail({ card, onBack }: { card: ReportCard; onBack: () => vo
<div>
<h3 className="font-medium text-stone-800 mb-2">Services Performed</h3>
<div className="flex flex-wrap gap-2">
{card.servicesPerformed.map(s => (
<span key={s} className="px-3 py-1 bg-stone-100 rounded-full text-sm text-stone-700">
{s}
</span>
))}
<span className="px-3 py-1 bg-stone-100 rounded-full text-sm text-stone-700">
{card.serviceName || "Grooming"}
</span>
</div>
</div>
@@ -132,37 +225,24 @@ function ReportCardDetail({ card, onBack }: { card: ReportCard; onBack: () => vo
</div>
</div>
{/* Condition Observations */}
{card.conditionObservations.length > 0 && (
<div>
<h3 className="font-medium text-stone-800 mb-2">Condition Observations</h3>
<div className="space-y-2">
{card.conditionObservations.map((obs, i) => (
<div key={i} className="flex items-start gap-2 text-sm">
<AlertCircle size={16} className="text-amber-500 mt-0.5 shrink-0" />
<span className="text-stone-700">{obs}</span>
</div>
))}
</div>
</div>
)}
{/* Groomer's Note */}
<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>
<h3 className="font-medium text-stone-800 mb-2">
A Note from {card.groomerName || "Your Groomer"}
</h3>
<p className="text-sm text-stone-700 italic leading-relaxed">
"Report card details are not yet available. Please check back after your visit."
</p>
</div>
{/* Next Appointment CTA */}
<div className="bg-white border border-stone-200 rounded-xl p-4 flex items-center justify-between">
<div>
<p className="text-sm font-medium text-stone-800">Next recommended visit</p>
<p className="text-xs text-stone-500">
{new Date(card.nextRecommendedDate).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}
</p>
<p className="text-sm font-medium text-stone-800">Book your next visit</p>
<p className="text-xs text-stone-500">Schedule your next grooming appointment</p>
</div>
<button className="px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover)">
Rebook Now
Book Now
</button>
</div>
</div>
+3 -1
View File
@@ -7,7 +7,9 @@
"jsx": "react-jsx",
"strict": true,
"noUncheckedIndexedAccess": true,
"skipLibCheck": true
"skipLibCheck": true,
"noEmit": true,
"allowImportingTsExtensions": true
},
"include": ["src"]
}
@@ -1,84 +1 @@
CREATE TYPE "public"."waitlist_status" AS ENUM('active', 'notified', 'expired', 'cancelled');--> statement-breakpoint
CREATE TABLE "account" (
"id" text PRIMARY KEY NOT NULL,
"account_id" text NOT NULL,
"provider_id" text NOT NULL,
"user_id" text NOT NULL,
"access_token" text,
"refresh_token" text,
"id_token" text,
"access_token_expires_at" timestamp,
"refresh_token_expires_at" timestamp,
"scope" text,
"password" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "session" (
"id" text PRIMARY KEY NOT NULL,
"expires_at" timestamp NOT NULL,
"token" text NOT NULL,
"ip_address" text,
"user_agent" text,
"user_id" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "session_token_unique" UNIQUE("token")
);
--> statement-breakpoint
CREATE TABLE "user" (
"id" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"email" text NOT NULL,
"email_verified" boolean DEFAULT false NOT NULL,
"image" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "user_email_unique" UNIQUE("email")
);
--> statement-breakpoint
CREATE TABLE "verification" (
"id" text PRIMARY KEY NOT NULL,
"identifier" text NOT NULL,
"value" text NOT NULL,
"expires_at" timestamp NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "waitlist_entries" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"client_id" uuid NOT NULL,
"pet_id" uuid NOT NULL,
"service_id" uuid NOT NULL,
"preferred_date" text NOT NULL,
"preferred_time" text NOT NULL,
"status" "waitlist_status" DEFAULT 'active' NOT NULL,
"notified_at" timestamp,
"expires_at" timestamp,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "appointments" ADD COLUMN "confirmation_status" text DEFAULT 'pending' NOT NULL;--> statement-breakpoint
ALTER TABLE "appointments" ADD COLUMN "confirmed_at" timestamp;--> statement-breakpoint
ALTER TABLE "appointments" ADD COLUMN "cancelled_at" timestamp;--> statement-breakpoint
ALTER TABLE "appointments" ADD COLUMN "confirmation_token" text;--> statement-breakpoint
ALTER TABLE "appointments" ADD COLUMN "customer_notes" text;--> statement-breakpoint
ALTER TABLE "pets" ADD COLUMN "photo_key" text;--> statement-breakpoint
ALTER TABLE "pets" ADD COLUMN "photo_uploaded_at" timestamp;--> statement-breakpoint
ALTER TABLE "staff" ADD COLUMN "user_id" text;--> statement-breakpoint
ALTER TABLE "staff" ADD COLUMN "is_super_user" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "staff" ADD COLUMN "ical_token" text;--> statement-breakpoint
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "waitlist_entries" ADD CONSTRAINT "waitlist_entries_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "waitlist_entries" ADD CONSTRAINT "waitlist_entries_pet_id_pets_id_fk" FOREIGN KEY ("pet_id") REFERENCES "public"."pets"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "waitlist_entries" ADD CONSTRAINT "waitlist_entries_service_id_services_id_fk" FOREIGN KEY ("service_id") REFERENCES "public"."services"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_waitlist_client_id" ON "waitlist_entries" USING btree ("client_id");--> statement-breakpoint
CREATE INDEX "idx_waitlist_preferred_date" ON "waitlist_entries" USING btree ("preferred_date");--> statement-breakpoint
CREATE INDEX "idx_waitlist_status" ON "waitlist_entries" USING btree ("status");--> statement-breakpoint
ALTER TABLE "staff" ADD CONSTRAINT "staff_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "appointments" ADD CONSTRAINT "appointments_confirmation_token_unique" UNIQUE("confirmation_token");--> statement-breakpoint
ALTER TABLE "staff" ADD CONSTRAINT "staff_ical_token_unique" UNIQUE("ical_token");
ALTER TABLE "staff" ADD COLUMN "is_super_user" boolean DEFAULT false NOT NULL;
File diff suppressed because it is too large Load Diff
@@ -134,6 +134,13 @@
"when": 1774598400000,
"tag": "0018_backfill_staff_user_id",
"breakpoints": true
},
{
"idx": 19,
"version": "7",
"when": 1774729055924,
"tag": "0019_concerned_sunfire",
"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, exists, gte, gt, ilike, lt, lte, ne, or, sql } from "drizzle-orm";
export { and, asc, desc, eq, exists, gte, gt, ilike, inArray, lt, lte, ne, or, sql } from "drizzle-orm";
let _db: ReturnType<typeof drizzle> | null = null;