fix(portal): prefix unused totalPending param with underscore
Silence @typescript-eslint/no-unused-vars for totalPending in PaymentModal — it is accepted as a prop for API compatibility but the modal computes selectedTotal from selected invoices instead. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -12,18 +12,17 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.800.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.800.0",
|
||||
"@groombook/db": "workspace:*",
|
||||
"@groombook/types": "workspace:*",
|
||||
"@hono/node-server": "^1.13.7",
|
||||
"@hono/zod-validator": "^0.4.3",
|
||||
"@hono/zod-validator": "^0.7.6",
|
||||
"better-auth": "^1.5.6",
|
||||
"hono": "^4.6.17",
|
||||
"jose": "^5.9.6",
|
||||
"node-cron": "^3.0.3",
|
||||
"nodemailer": "^6.9.16",
|
||||
"openid-client": "^6.1.7",
|
||||
"zod": "^3.24.1",
|
||||
"@aws-sdk/client-s3": "^3.800.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.800.0"
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.7",
|
||||
@@ -35,5 +34,6 @@
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.20.0",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
},
|
||||
"license": "AGPL-3.0-only"
|
||||
}
|
||||
|
||||
@@ -15,7 +15,9 @@ import type { StaffRow } from "../middleware/rbac.js";
|
||||
const MANAGER: StaffRow = {
|
||||
id: "staff-manager-id",
|
||||
oidcSub: "oidc-manager-sub",
|
||||
userId: null,
|
||||
role: "manager",
|
||||
isSuperUser: true,
|
||||
name: "Manager McManager",
|
||||
email: "manager@example.com",
|
||||
active: true,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import type { JwtPayload } from "../middleware/auth.js";
|
||||
import type { AppEnv, StaffRow } from "../middleware/rbac.js";
|
||||
import { buildStaff } from "@groombook/db/factories";
|
||||
|
||||
@@ -167,7 +166,7 @@ function createApp(
|
||||
if (!staffRow) {
|
||||
return c.json({ error: "Forbidden: no staff record found for authenticated user" }, 403);
|
||||
}
|
||||
c.set("jwtPayload", { sub: staffRow.oidcSub } as JwtPayload);
|
||||
c.set("jwtPayload", { sub: staffRow.oidcSub } as { sub: string; email?: string; name?: string });
|
||||
c.set("staff", staffRow as unknown as StaffRow);
|
||||
await next();
|
||||
});
|
||||
|
||||
@@ -7,7 +7,9 @@ import type { AppEnv, StaffRow } from "../middleware/rbac.js";
|
||||
const MANAGER: StaffRow = {
|
||||
id: "staff-manager-id",
|
||||
oidcSub: "oidc-manager-sub",
|
||||
userId: null,
|
||||
role: "manager",
|
||||
isSuperUser: true,
|
||||
name: "Manager McManager",
|
||||
email: "manager@example.com",
|
||||
active: true,
|
||||
|
||||
@@ -8,7 +8,9 @@ import type { AppEnv, StaffRow } from "../middleware/rbac.js";
|
||||
const MANAGER: StaffRow = {
|
||||
id: "staff-manager-id",
|
||||
oidcSub: "oidc-manager-sub",
|
||||
userId: "ba-user-manager",
|
||||
role: "manager",
|
||||
isSuperUser: true,
|
||||
name: "Manager McManager",
|
||||
email: "manager@example.com",
|
||||
active: true,
|
||||
@@ -21,6 +23,7 @@ const RECEPTIONIST: StaffRow = {
|
||||
...MANAGER,
|
||||
id: "staff-receptionist-id",
|
||||
oidcSub: "oidc-receptionist-sub",
|
||||
userId: "ba-user-receptionist",
|
||||
role: "receptionist",
|
||||
name: "Receptionist Rita",
|
||||
email: "receptionist@example.com",
|
||||
@@ -30,6 +33,7 @@ const GROOMER: StaffRow = {
|
||||
...MANAGER,
|
||||
id: "staff-groomer-id",
|
||||
oidcSub: "oidc-groomer-sub",
|
||||
userId: "ba-user-groomer",
|
||||
role: "groomer",
|
||||
name: "Groomer Gary",
|
||||
email: "groomer@example.com",
|
||||
@@ -89,7 +93,7 @@ function buildApp(
|
||||
) {
|
||||
const app = new Hono<AppEnv>();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("jwtPayload", { sub: staffLookupResult?.oidcSub ?? "unknown-sub" });
|
||||
c.set("jwtPayload", { sub: staffLookupResult?.userId ?? "unknown-sub" });
|
||||
await next();
|
||||
});
|
||||
app.use("*", middleware);
|
||||
@@ -106,7 +110,7 @@ function buildWithStaff(
|
||||
) {
|
||||
const app = new Hono<AppEnv>();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("jwtPayload", { sub: staffRow.oidcSub ?? "" });
|
||||
c.set("jwtPayload", { sub: staffRow.userId ?? "" });
|
||||
c.set("staff", staffRow);
|
||||
await next();
|
||||
});
|
||||
@@ -165,7 +169,7 @@ describe("resolveStaffMiddleware", () => {
|
||||
});
|
||||
|
||||
const res = await app.request("/test", {
|
||||
headers: { "X-Dev-User-Id": GROOMER.oidcSub! },
|
||||
headers: { "X-Dev-User-Id": GROOMER.id },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(capturedStaff!.role).toBe("groomer");
|
||||
|
||||
+31
-4
@@ -2,6 +2,7 @@ import { serve } from "@hono/node-server";
|
||||
import { Hono } from "hono";
|
||||
import { logger } from "hono/logger";
|
||||
import { cors } from "hono/cors";
|
||||
import { auth } from "./lib/auth.js";
|
||||
import { clientsRouter } from "./routes/clients.js";
|
||||
import { petsRouter } from "./routes/pets.js";
|
||||
import { servicesRouter } from "./routes/services.js";
|
||||
@@ -18,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";
|
||||
@@ -65,15 +67,37 @@ 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);
|
||||
api.use("*", resolveStaffMiddleware);
|
||||
|
||||
// Better-Auth handler — mounted as sub-app to handle all /api/auth/* routes
|
||||
// authMiddleware and resolveStaffMiddleware both skip /api/auth/ paths
|
||||
const authRouter = new Hono();
|
||||
authRouter.all("/*", (c) => auth.handler(c.req.raw));
|
||||
api.route("/auth", authRouter);
|
||||
|
||||
// ── Role guards ────────────────────────────────────────────────────────────────
|
||||
// Manager-only: staff, admin settings, reports, invoices, impersonation
|
||||
api.use("/staff/*", requireRole("manager"));
|
||||
// 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"));
|
||||
// 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"));
|
||||
@@ -113,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);
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { genericOAuth } from "better-auth/plugins";
|
||||
import { getDb } from "@groombook/db";
|
||||
|
||||
const OIDC_ISSUER = process.env.OIDC_ISSUER;
|
||||
const OIDC_INTERNAL_BASE = process.env.OIDC_INTERNAL_BASE; // e.g. http://authentik-server.auth.svc.cluster.local
|
||||
const OIDC_CLIENT_ID = process.env.OIDC_CLIENT_ID;
|
||||
const OIDC_CLIENT_SECRET = process.env.OIDC_CLIENT_SECRET;
|
||||
const BETTER_AUTH_SECRET = process.env.BETTER_AUTH_SECRET;
|
||||
const BETTER_AUTH_URL = process.env.BETTER_AUTH_URL ?? "http://localhost:3000";
|
||||
|
||||
if (!BETTER_AUTH_SECRET && process.env.AUTH_DISABLED !== "true") {
|
||||
throw new Error(
|
||||
"[FATAL] BETTER_AUTH_SECRET environment variable is required when auth is enabled"
|
||||
);
|
||||
}
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: drizzleAdapter(getDb(), {
|
||||
provider: "pg",
|
||||
}),
|
||||
secret: BETTER_AUTH_SECRET,
|
||||
baseURL: BETTER_AUTH_URL,
|
||||
plugins: [
|
||||
genericOAuth({
|
||||
config: [
|
||||
{
|
||||
providerId: "authentik",
|
||||
clientId: OIDC_CLIENT_ID ?? "",
|
||||
clientSecret: OIDC_CLIENT_SECRET ?? "",
|
||||
// When OIDC_INTERNAL_BASE is set, use explicit URLs to avoid hairpin NAT:
|
||||
// - authorizationUrl: external (browser redirect, no server-side fetch)
|
||||
// - tokenUrl/userInfoUrl: internal (server-to-server, avoids hairpin)
|
||||
// When not set, fall back to discoveryUrl for local dev.
|
||||
...(OIDC_INTERNAL_BASE
|
||||
? {
|
||||
authorizationUrl: `${new URL(OIDC_ISSUER!).origin}/application/o/authorize/`,
|
||||
tokenUrl: `${OIDC_INTERNAL_BASE}/application/o/token/`,
|
||||
userInfoUrl: `${OIDC_INTERNAL_BASE}/application/o/userinfo/`,
|
||||
}
|
||||
: {
|
||||
discoveryUrl: OIDC_ISSUER
|
||||
? `${OIDC_ISSUER}/.well-known/openid-configuration`
|
||||
: undefined,
|
||||
}),
|
||||
scopes: ["openid", "profile", "email"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
session: {
|
||||
expiresIn: 60 * 60 * 24 * 7, // 7 days
|
||||
updateAge: 60 * 60 * 24, // 1 day
|
||||
cookieCache: {
|
||||
enabled: true,
|
||||
maxAge: 5 * 60, // 5 minutes
|
||||
},
|
||||
},
|
||||
trustedOrigins: [process.env.CORS_ORIGIN ?? "http://localhost:5173"],
|
||||
});
|
||||
@@ -1,34 +1,18 @@
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
import { createRemoteJWKSet, jwtVerify } from "jose";
|
||||
import { auth } from "../lib/auth.js";
|
||||
|
||||
// Authentik OIDC configuration — loaded from env at startup
|
||||
const OIDC_ISSUER = process.env.OIDC_ISSUER;
|
||||
const OIDC_AUDIENCE = process.env.OIDC_AUDIENCE;
|
||||
|
||||
let jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
|
||||
|
||||
function getJwks() {
|
||||
if (!OIDC_ISSUER) throw new Error("OIDC_ISSUER is not set");
|
||||
if (!jwks) {
|
||||
jwks = createRemoteJWKSet(
|
||||
new URL(`${OIDC_ISSUER}/application/o/groombook/jwks/`)
|
||||
);
|
||||
}
|
||||
return jwks;
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
// Guard: refuse to start with AUTH_DISABLED in production (fixes #22).
|
||||
// Guard: refuse to start with AUTH_DISABLED in production.
|
||||
if (process.env.AUTH_DISABLED === "true") {
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
console.error(
|
||||
"[FATAL] AUTH_DISABLED=true is not allowed in production. " +
|
||||
"Remove AUTH_DISABLED from your environment and configure OIDC_ISSUER."
|
||||
"Remove AUTH_DISABLED from your environment and configure Better-Auth."
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -39,30 +23,33 @@ if (process.env.AUTH_DISABLED === "true") {
|
||||
}
|
||||
|
||||
export const authMiddleware: MiddlewareHandler = async (c, next) => {
|
||||
if (process.env.AUTH_DISABLED === "true") {
|
||||
const devUserId = c.req.header("X-Dev-User-Id");
|
||||
const sub = devUserId ?? "dev-user";
|
||||
c.set("jwtPayload", { sub } as JwtPayload);
|
||||
// Better-Auth's own routes handle their own auth (OAuth callbacks, session mgmt)
|
||||
if (c.req.path.startsWith("/api/auth/")) {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
const authorization = c.req.header("Authorization");
|
||||
if (!authorization?.startsWith("Bearer ")) {
|
||||
if (process.env.AUTH_DISABLED === "true") {
|
||||
const devUserId = c.req.header("X-Dev-User-Id");
|
||||
const sub = devUserId ?? "dev-user";
|
||||
c.set("jwtPayload", { sub } as { sub: string });
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
const session = await auth.api.getSession({
|
||||
headers: c.req.raw.headers,
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const token = authorization.slice(7);
|
||||
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, getJwks(), {
|
||||
issuer: OIDC_ISSUER,
|
||||
audience: OIDC_AUDIENCE,
|
||||
});
|
||||
|
||||
c.set("jwtPayload", payload as JwtPayload);
|
||||
await next();
|
||||
} catch {
|
||||
return c.json({ error: "Invalid or expired token" }, 401);
|
||||
}
|
||||
// Set jwtPayload with sub = Better-Auth user ID for backward compat with resolveStaffMiddleware
|
||||
c.set("jwtPayload", {
|
||||
sub: session.user.id,
|
||||
email: session.user.email,
|
||||
name: session.user.name,
|
||||
});
|
||||
await next();
|
||||
};
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
import { eq, getDb, staff } from "@groombook/db";
|
||||
import type { JwtPayload } from "./auth.js";
|
||||
|
||||
export type StaffRole = "groomer" | "receptionist" | "manager";
|
||||
export type StaffRow = typeof staff.$inferSelect;
|
||||
|
||||
export interface AppEnv {
|
||||
Variables: {
|
||||
jwtPayload: JwtPayload;
|
||||
jwtPayload: { sub: string; email?: string; name?: string };
|
||||
staff: StaffRow;
|
||||
};
|
||||
}
|
||||
@@ -16,13 +15,19 @@ export interface AppEnv {
|
||||
* Resolves the authenticated staff record from the DB and stores it in context.
|
||||
* Must be applied after authMiddleware on all protected routes.
|
||||
*
|
||||
* Dev mode (AUTH_DISABLED=true): resolves staff by X-Dev-User-Id header (treated
|
||||
* as oidcSub), or falls back to the first manager in the DB.
|
||||
* Dev mode (AUTH_DISABLED=true): resolves staff by X-Dev-User-Id header (Better-Auth
|
||||
* user ID), or falls back to the first manager in the DB.
|
||||
*/
|
||||
export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
|
||||
c,
|
||||
next
|
||||
) => {
|
||||
// Better-Auth's own routes handle their own auth — skip staff resolution
|
||||
if (c.req.path.startsWith("/api/auth/")) {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
if (process.env.AUTH_DISABLED === "true") {
|
||||
@@ -37,38 +42,59 @@ 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;
|
||||
}
|
||||
// Treat X-Dev-User-Id as the oidcSub
|
||||
// Treat X-Dev-User-Id as the Better-Auth user ID first
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(staff)
|
||||
.where(eq(staff.oidcSub, devUserId));
|
||||
if (!row) {
|
||||
.where(eq(staff.userId, devUserId));
|
||||
if (row) {
|
||||
c.set("staff", { ...row, isSuperUser: true });
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
// Fallback: if userId is null, treat X-Dev-User-Id as staff.id (dev login
|
||||
// may send the primary key for staff records that predate the userId field)
|
||||
const [fallbackRow] = await db
|
||||
.select()
|
||||
.from(staff)
|
||||
.where(eq(staff.id, devUserId));
|
||||
if (!fallbackRow) {
|
||||
return c.json(
|
||||
{ error: "Forbidden: no staff record found for X-Dev-User-Id" },
|
||||
403
|
||||
);
|
||||
}
|
||||
c.set("staff", row);
|
||||
c.set("staff", { ...fallbackRow, isSuperUser: true });
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
const jwt = c.get("jwtPayload");
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(staff)
|
||||
.where(eq(staff.userId, jwt.sub));
|
||||
if (row) {
|
||||
c.set("staff", row);
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
// Fallback: staff records that predate the userId field may still have oidcSub
|
||||
const [fallbackRow] = await db
|
||||
.select()
|
||||
.from(staff)
|
||||
.where(eq(staff.oidcSub, jwt.sub));
|
||||
if (!row) {
|
||||
if (!fallbackRow) {
|
||||
return c.json(
|
||||
{ error: "Forbidden: no staff record found for authenticated user" },
|
||||
403
|
||||
);
|
||||
}
|
||||
c.set("staff", row);
|
||||
c.set("staff", fallbackRow);
|
||||
await next();
|
||||
};
|
||||
|
||||
@@ -99,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();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod";
|
||||
import { z } from "zod/v3";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod";
|
||||
import { z } from "zod/v3";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import {
|
||||
and,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod";
|
||||
import { z } from "zod/v3";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod";
|
||||
import { z } from "zod/v3";
|
||||
import { and, eq, exists, getDb, or, clients, appointments } from "@groombook/db";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ devRouter.get("/users", async (c) => {
|
||||
const staffList = await db
|
||||
.select({
|
||||
id: staff.id,
|
||||
userId: staff.userId,
|
||||
name: staff.name,
|
||||
email: staff.email,
|
||||
role: staff.role,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod";
|
||||
import { z } from "zod/v3";
|
||||
import { desc, eq, getDb, groomingVisitLogs } from "@groombook/db";
|
||||
|
||||
export const groomingLogsRouter = new Hono();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod";
|
||||
import { z } from "zod/v3";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod";
|
||||
import { z } from "zod/v3";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod";
|
||||
import { z } from "zod/v3";
|
||||
import { and, eq, exists, getDb, or, pets, appointments } from "@groombook/db";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
import {
|
||||
|
||||
+137
-57
@@ -1,11 +1,135 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod";
|
||||
import { and, eq, getDb, appointments, impersonationSessions, waitlistEntries } from "@groombook/db";
|
||||
import { z } from "zod/v3";
|
||||
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,7 +292,7 @@ portalRouter.post("/appointments/:id/cancel", async (c) => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Client-facing waitlist routes ───────────────────────────────────────────
|
||||
// ─── Client-facing waitlist routes ────────────────────────────────────────────
|
||||
|
||||
const createWaitlistEntrySchema = z.object({
|
||||
petId: z.string().uuid(),
|
||||
@@ -366,4 +446,4 @@ portalRouter.delete("/waitlist/:id", async (c) => {
|
||||
.returning();
|
||||
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod";
|
||||
import { z } from "zod/v3";
|
||||
import { eq, getDb, services } from "@groombook/db";
|
||||
|
||||
export const servicesRouter = new Hono();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod";
|
||||
import { z } from "zod/v3";
|
||||
import { eq, getDb, businessSettings } from "@groombook/db";
|
||||
|
||||
export const settingsRouter = new Hono();
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod";
|
||||
import { z } from "zod/v3";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { and, eq, getDb, ne, staff, appointments } from "@groombook/db";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
@@ -10,5 +10,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.50.1"
|
||||
}
|
||||
},
|
||||
"license": "AGPL-3.0-only"
|
||||
}
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { test as base } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Custom test fixture that bypasses the dev login redirect for E2E tests.
|
||||
* Custom test fixture that bypasses auth for E2E tests.
|
||||
*
|
||||
* When AUTH_DISABLED=true, the app fetches /api/dev/config and redirects to
|
||||
* /login if no dev-user is in localStorage. This fixture:
|
||||
* 1. Mocks /api/dev/config to return authDisabled: false
|
||||
* 2. Seeds localStorage with a dev user as a fallback
|
||||
* When authDisabled=true, the app uses the dev login selector instead of
|
||||
* Better Auth signIn.social(). This fixture:
|
||||
* 1. Mocks /api/dev/config to return authDisabled: true
|
||||
* 2. Seeds localStorage with a dev user so the selector auto-selects a session
|
||||
*
|
||||
* This ensures E2E tests render pages directly without the login redirect.
|
||||
* This ensures E2E tests render pages directly without the auth redirect.
|
||||
*/
|
||||
const MOCK_DEV_USERS = {
|
||||
staff: [
|
||||
@@ -23,9 +23,9 @@ const MOCK_DEV_USERS = {
|
||||
|
||||
export const test = base.extend({
|
||||
page: async ({ page }, use) => {
|
||||
// Mock the dev config endpoint so the app skips the auth-disabled redirect
|
||||
// Mock the dev config endpoint so the app uses dev login selector (bypasses Better Auth)
|
||||
await page.route("**/api/dev/config", (route) =>
|
||||
route.fulfill({ json: { authDisabled: false } })
|
||||
route.fulfill({ json: { authDisabled: true } })
|
||||
);
|
||||
// Mock the dev users endpoint for login selector tests
|
||||
await page.route("**/api/dev/users", (route) =>
|
||||
|
||||
@@ -10,6 +10,15 @@ test.beforeEach(async ({ page }) => {
|
||||
// Reports endpoints need shaped responses (not bare []) to avoid render crashes.
|
||||
await page.route("/api/**", (route) => {
|
||||
const url = route.request().url();
|
||||
if (url.includes("/api/dev/config")) {
|
||||
return route.fulfill({ json: { authDisabled: true } });
|
||||
}
|
||||
if (url.includes("/api/dev/users")) {
|
||||
return route.fulfill({ json: { staff: [], clients: [] } });
|
||||
}
|
||||
if (url.includes("/api/branding")) {
|
||||
return route.fulfill({ json: { businessName: "GroomBook", logoUrl: null, theme: "default" } });
|
||||
}
|
||||
if (url.includes("/api/reports/summary")) {
|
||||
return route.fulfill({
|
||||
json: {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
VITE_API_URL=
|
||||
@@ -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
|
||||
@@ -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: {
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"dependencies": {
|
||||
"@groombook/types": "workspace:*",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"better-auth": "^1.0.0",
|
||||
"lucide-react": "^0.577.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@@ -36,5 +37,6 @@
|
||||
"vite": "^6.0.7",
|
||||
"vite-plugin-pwa": "^0.21.1",
|
||||
"vitest": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"license": "AGPL-3.0-only"
|
||||
}
|
||||
|
||||
+108
-11
@@ -12,11 +12,68 @@ 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";
|
||||
import { BrandingProvider, useBranding } from "./BrandingContext.js";
|
||||
import { GlobalSearch } from "./components/GlobalSearch.js";
|
||||
import { useSession, signIn } from "./lib/auth-client.js";
|
||||
|
||||
function LoginPage() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleLogin = async () => {
|
||||
setIsLoading(true);
|
||||
await signIn.social({ provider: "authentik", callbackURL: window.location.origin });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "100vh",
|
||||
fontFamily: "system-ui, sans-serif",
|
||||
background: "#f0f2f5",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: "#fff",
|
||||
borderRadius: 12,
|
||||
padding: "2rem 2.5rem",
|
||||
boxShadow: "0 4px 24px rgba(0,0,0,0.08)",
|
||||
textAlign: "center",
|
||||
minWidth: 280,
|
||||
}}
|
||||
>
|
||||
<h1 style={{ fontSize: 22, marginBottom: "0.5rem", color: "#1a202c" }}>GroomBook</h1>
|
||||
<p style={{ color: "#6b7280", marginBottom: "1.5rem", fontSize: 14 }}>
|
||||
Sign in to continue
|
||||
</p>
|
||||
<button
|
||||
onClick={handleLogin}
|
||||
disabled={isLoading}
|
||||
style={{
|
||||
padding: "0.6rem 1.5rem",
|
||||
borderRadius: 6,
|
||||
border: "none",
|
||||
background: "#4f8a6f",
|
||||
color: "#fff",
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
cursor: isLoading ? "wait" : "pointer",
|
||||
opacity: isLoading ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{isLoading ? "Redirecting…" : "Sign in with SSO"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const NAV_LINKS = [
|
||||
{ to: "/admin", label: "Appointments" },
|
||||
@@ -133,6 +190,11 @@ 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;
|
||||
const sessionLoading = authDisabled ? false : rawSessionLoading;
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/dev/config")
|
||||
@@ -141,18 +203,18 @@ export function App() {
|
||||
.catch(() => setAuthDisabled(false));
|
||||
}, []);
|
||||
|
||||
// Show login selector page
|
||||
if (location.pathname === "/login") {
|
||||
return <DevLoginSelector />;
|
||||
}
|
||||
// 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;
|
||||
|
||||
// While checking auth config, render nothing briefly
|
||||
if (authDisabled === null) return null;
|
||||
|
||||
// If auth is disabled and no dev user is selected, redirect to login selector
|
||||
if (authDisabled && !getDevUser() && location.pathname !== "/login") {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
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") {
|
||||
@@ -165,6 +227,41 @@ export function App() {
|
||||
return <BookingErrorPage />;
|
||||
}
|
||||
|
||||
// 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 (no setup check needed in dev mode)
|
||||
if (authDisabled && location.pathname === "/login") {
|
||||
return <DevLoginSelector />;
|
||||
}
|
||||
|
||||
// Dev mode: use dev login selector (no setup check needed in dev mode)
|
||||
if (authDisabled && !getDevUser()) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
// 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") ? (
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, within, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { App } from "../App.js";
|
||||
import { App } from "../App";
|
||||
|
||||
|
||||
// Mock fetch to return appropriate responses based on URL
|
||||
beforeEach(() => {
|
||||
@@ -44,6 +45,32 @@ async function renderApp(route = "/admin") {
|
||||
}
|
||||
|
||||
describe("App navigation", () => {
|
||||
// Use authDisabled=true (dev mode) so nav renders without needing Better Auth useSession() mock
|
||||
beforeEach(() => {
|
||||
localStorage.setItem("dev-user", JSON.stringify({ type: "staff", id: "s1", name: "Sarah" }));
|
||||
global.fetch = vi.fn((url: string) => {
|
||||
if (url === "/api/dev/config") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ authDisabled: true }),
|
||||
} as Response);
|
||||
}
|
||||
if (url === "/api/branding") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
businessName: "GroomBook",
|
||||
primaryColor: "#4f8a6f",
|
||||
accentColor: "#8b7355",
|
||||
logoBase64: null,
|
||||
logoMimeType: null,
|
||||
}),
|
||||
} as Response);
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => [] } as Response);
|
||||
}) as unknown as typeof fetch;
|
||||
});
|
||||
|
||||
it("renders the Groom Book brand", async () => {
|
||||
const nav = await renderApp();
|
||||
expect(
|
||||
@@ -124,6 +151,12 @@ describe("Dev login selector", () => {
|
||||
}),
|
||||
} as Response);
|
||||
}
|
||||
if (url === "/api/auth/get-session") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ user: null }),
|
||||
} as Response);
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => [] } as Response);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: import.meta.env.VITE_API_URL ?? "",
|
||||
});
|
||||
|
||||
export const { signIn, signOut, useSession } = authClient;
|
||||
@@ -9,6 +9,9 @@ const originalFetch = window.fetch;
|
||||
* Intentionally mutates window.fetch — this is dev-only (AUTH_DISABLED=true).
|
||||
*/
|
||||
export function installDevFetchInterceptor() {
|
||||
// In production, Better-Auth handles auth via cookies — no interception needed
|
||||
if (!import.meta.env.DEV) return;
|
||||
|
||||
window.fetch = function (input: RequestInfo | URL, init?: RequestInit) {
|
||||
const user = getDevUser();
|
||||
if (!user) return originalFetch(input, init);
|
||||
|
||||
@@ -131,9 +131,18 @@ export function AppointmentsPage() {
|
||||
setError(null);
|
||||
Promise.all([
|
||||
loadAppointments(),
|
||||
fetch("/api/clients").then((r) => r.json() as Promise<Client[]>).then(setClients),
|
||||
fetch("/api/services").then((r) => r.json() as Promise<Service[]>).then(setServices),
|
||||
fetch("/api/staff").then((r) => r.json() as Promise<Staff[]>).then(setStaff),
|
||||
fetch("/api/clients").then((r) => {
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return r.json() as Promise<Client[]>;
|
||||
}).then(setClients),
|
||||
fetch("/api/services").then((r) => {
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return r.json() as Promise<Service[]>;
|
||||
}).then(setServices),
|
||||
fetch("/api/staff").then((r) => {
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return r.json() as Promise<Staff[]>;
|
||||
}).then(setStaff),
|
||||
])
|
||||
.catch((e: unknown) => setError(e instanceof Error ? e.message : "Unknown error"))
|
||||
.finally(() => setLoading(false));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import type { Service } from "@groombook/types";
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
@@ -107,6 +108,7 @@ export function BookPage() {
|
||||
|
||||
// Step 2 — date & time
|
||||
const [date, setDate] = useState(todayIso());
|
||||
const [dateError, setDateError] = useState<string | null>(null);
|
||||
const [slots, setSlots] = useState<string[]>([]);
|
||||
const [slotsLoading, setSlotsLoading] = useState(false);
|
||||
const [selectedSlot, setSelectedSlot] = useState<string | null>(null);
|
||||
@@ -125,6 +127,28 @@ export function BookPage() {
|
||||
});
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
// Pre-fill form from URL params (e.g., ?clientName=Jane&clientEmail=jane@example.com)
|
||||
const [searchParams] = useSearchParams();
|
||||
useEffect(() => {
|
||||
const clientName = searchParams.get("clientName");
|
||||
const clientEmail = searchParams.get("clientEmail");
|
||||
const clientPhone = searchParams.get("clientPhone");
|
||||
const petName = searchParams.get("petName");
|
||||
const petSpecies = searchParams.get("petSpecies");
|
||||
const petBreed = searchParams.get("petBreed");
|
||||
if (clientName || clientEmail || clientPhone || petName || petSpecies || petBreed) {
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
...(clientName && { clientName }),
|
||||
...(clientEmail && { clientEmail }),
|
||||
...(clientPhone && { clientPhone }),
|
||||
...(petName && { petName }),
|
||||
...(petSpecies && { petSpecies }),
|
||||
...(petBreed && { petBreed }),
|
||||
}));
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
// Step 4 — result
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [result, setResult] = useState<BookingResult | null>(null);
|
||||
@@ -328,8 +352,21 @@ export function BookPage() {
|
||||
value={date}
|
||||
min={todayIso()}
|
||||
style={{ ...input, width: "auto" }}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
// HTML5 date input enforces yyyy-MM-dd; empty value means invalid format
|
||||
if (!val) {
|
||||
setDateError("Please enter a valid date (YYYY-MM-DD).");
|
||||
setDate("");
|
||||
} else {
|
||||
setDateError(null);
|
||||
setDate(val);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{dateError && (
|
||||
<p style={{ color: "#dc2626", fontSize: 12, marginTop: 4 }}>{dateError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: "1.25rem" }}>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
|
||||
|
||||
interface StaffUser {
|
||||
id: string;
|
||||
userId: string | null;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
@@ -66,7 +67,7 @@ export function DevLoginSelector() {
|
||||
{staff.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => selectUser("staff", s.id, s.name)}
|
||||
onClick={() => selectUser("staff", s.userId ?? s.id, s.name)}
|
||||
style={userButtonStyle}
|
||||
>
|
||||
<div style={{ fontWeight: 600, fontSize: 14 }}>{s.name}</div>
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
export { SetupWizard } from "./SetupWizard.jsx";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
Settings, LogOut, Shield,
|
||||
} from "lucide-react";
|
||||
import { Dashboard } from "./sections/Dashboard.js";
|
||||
import { AppointmentsSection } from "./sections/Appointments.js";
|
||||
import { AppointmentsSection, RescheduleFlow } from "./sections/Appointments.js";
|
||||
import { PetProfiles } from "./sections/PetProfiles.js";
|
||||
import { ReportCards } from "./sections/ReportCards.js";
|
||||
import { BillingPayments } from "./sections/BillingPayments.js";
|
||||
@@ -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";
|
||||
|
||||
@@ -33,8 +32,11 @@ export function CustomerPortal() {
|
||||
const [activeSection, setActiveSection] = useState<Section>("dashboard");
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||
const [showAuditLog, setShowAuditLog] = useState(false);
|
||||
const [showReschedule, setShowReschedule] = useState(false);
|
||||
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();
|
||||
|
||||
@@ -55,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 });
|
||||
@@ -107,27 +114,37 @@ export function CustomerPortal() {
|
||||
}
|
||||
};
|
||||
|
||||
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} />;
|
||||
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"
|
||||
@@ -158,6 +175,15 @@ export function CustomerPortal() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{showReschedule && rescheduleAppointment && (
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
<RescheduleFlow
|
||||
appointment={rescheduleAppointment as any}
|
||||
onClose={() => { setShowReschedule(false); setRescheduleAppointment(null); }}
|
||||
sessionId={session?.id ?? null}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile Header */}
|
||||
<header className="md:hidden flex items-center justify-between px-4 py-3 bg-white border-b border-stone-200">
|
||||
<button
|
||||
@@ -171,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>
|
||||
|
||||
@@ -258,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>
|
||||
|
||||
@@ -1,12 +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 (
|
||||
@@ -31,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">
|
||||
@@ -111,10 +174,67 @@ 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);
|
||||
|
||||
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
|
||||
// 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); }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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}
|
||||
@@ -125,7 +245,10 @@ function ManagePets({ readOnly }: { readOnly: boolean }) {
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div className="flex gap-2">
|
||||
<button className="px-3 py-1.5 border border-stone-200 rounded-lg text-xs text-stone-600 hover:bg-stone-50">
|
||||
<button
|
||||
onClick={() => setEditingPetId(pet.id)}
|
||||
className="px-3 py-1.5 border border-stone-200 rounded-lg text-xs text-stone-600 hover:bg-stone-50"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button className="p-1.5 border border-stone-200 rounded-lg text-stone-400 hover:text-amber-600 hover:border-amber-200">
|
||||
@@ -136,7 +259,10 @@ function ManagePets({ readOnly }: { readOnly: boolean }) {
|
||||
</div>
|
||||
))}
|
||||
{!readOnly && (
|
||||
<button className="w-full flex items-center justify-center gap-2 py-3 border-2 border-dashed border-stone-300 rounded-2xl text-sm text-stone-500 hover:border-(--color-accent) hover:text-(--color-accent-dark) transition-colors">
|
||||
<button
|
||||
onClick={() => setShowAddForm(true)}
|
||||
className="w-full flex items-center justify-center gap-2 py-3 border-2 border-dashed border-stone-300 rounded-2xl text-sm text-stone-500 hover:border-(--color-accent) hover:text-(--color-accent-dark) transition-colors"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Add New Pet
|
||||
</button>
|
||||
@@ -147,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
@@ -1,51 +1,129 @@
|
||||
import { useState } from "react";
|
||||
import { CreditCard, Download, DollarSign, Package, Zap, Plus, Trash2 } from "lucide-react";
|
||||
import { INVOICES, SAVED_PAYMENT_METHODS, PREPAID_PACKAGES, Invoice } from "../mockData.js";
|
||||
import { useState, useEffect } from "react";
|
||||
import { CreditCard, Download, DollarSign, Package, Zap } from "lucide-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",
|
||||
pending: "bg-yellow-100 text-yellow-700",
|
||||
failed: "bg-red-100 text-red-700",
|
||||
refunded: "bg-gray-100 text-gray-700",
|
||||
};
|
||||
|
||||
export function BillingPayments({ readOnly }: Props) {
|
||||
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);
|
||||
const [tab, setTab] = useState<"invoices" | "payment" | "packages">("invoices");
|
||||
const [autopay, setAutopay] = useState(false);
|
||||
const [showTipModal, setShowTipModal] = useState(false);
|
||||
const [showPaymentModal, setShowPaymentModal] = useState(false);
|
||||
|
||||
const outstanding = INVOICES.filter(i => i.status === "outstanding");
|
||||
const totalOutstanding = outstanding.reduce((sum, i) => sum + i.amount, 0);
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
if (!sessionId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
const pending = invoices.filter((i) => i.status === "pending");
|
||||
const totalPending = pending.reduce((sum, i) => sum + i.totalCents, 0);
|
||||
|
||||
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 && (
|
||||
{totalPending > 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>
|
||||
<p className="text-3xl font-bold text-stone-800">{formatCents(totalPending)}</p>
|
||||
<p className="text-xs text-stone-400 mt-0.5">
|
||||
{pending.length} unpaid invoice{pending.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
|
||||
onClick={() => setShowPaymentModal(true)}
|
||||
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>
|
||||
<button
|
||||
onClick={() => setShowPaymentModal(true)}
|
||||
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>
|
||||
)}
|
||||
@@ -61,7 +139,9 @@ export function BillingPayments({ readOnly }: Props) {
|
||||
key={id}
|
||||
onClick={() => setTab(id)}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium ${
|
||||
tab === id ? "bg-(--color-accent-light) text-(--color-accent-dark)" : "text-stone-500 hover:bg-stone-50"
|
||||
tab === id
|
||||
? "bg-(--color-accent-light) text-(--color-accent-dark)"
|
||||
: "text-stone-500 hover:bg-stone-50"
|
||||
}`}
|
||||
>
|
||||
<Icon size={14} />
|
||||
@@ -78,23 +158,35 @@ export function BillingPayments({ readOnly }: Props) {
|
||||
<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">Description</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 => (
|
||||
{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" })}
|
||||
{new Date(inv.date).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-stone-600">
|
||||
{inv.description || `Invoice ${inv.id.slice(0, 8)}`}
|
||||
</td>
|
||||
<td className="px-5 py-3 font-medium text-stone-800">
|
||||
{formatCents(inv.totalCents)}
|
||||
</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
|
||||
className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
STATUS_STYLES[inv.status] || "bg-gray-100 text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{inv.status.charAt(0).toUpperCase() + inv.status.slice(1)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
@@ -113,37 +205,33 @@ export function BillingPayments({ readOnly }: Props) {
|
||||
{/* 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">
|
||||
<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" />
|
||||
{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 border-stone-200 rounded-lg bg-white"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-6 bg-gray-200 rounded flex items-center justify-center text-xs">
|
||||
{method.brand.toUpperCase()}
|
||||
</div>
|
||||
<span className="text-stone-700">**** {method.last4}</span>
|
||||
<span className="text-stone-500">
|
||||
{method.expiryMonth}/{method.expiryYear}
|
||||
</span>
|
||||
</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>
|
||||
</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 className="text-sm text-blue-600 hover:underline">
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Autopay */}
|
||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||
@@ -154,18 +242,28 @@ export function BillingPayments({ readOnly }: Props) {
|
||||
</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>
|
||||
<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"}`}
|
||||
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"}`} />
|
||||
<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>
|
||||
<span className="text-xs text-stone-400">
|
||||
{autopay ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -175,40 +273,29 @@ export function BillingPayments({ readOnly }: Props) {
|
||||
{/* 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}%` }}
|
||||
/>
|
||||
{packages.length === 0 ? (
|
||||
<p className="text-gray-500 italic">No packages purchased</p>
|
||||
) : (
|
||||
packages.map((pkg, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-stone-800">{pkg.name}</span>
|
||||
<span className="text-stone-600">{pkg.remaining} remaining</span>
|
||||
</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)} />
|
||||
)}
|
||||
|
||||
{/* Payment Modal */}
|
||||
{showPaymentModal && !readOnly && (
|
||||
<PaymentModal
|
||||
outstanding={outstanding}
|
||||
totalOutstanding={totalOutstanding}
|
||||
pending={pending}
|
||||
totalPending={totalPending}
|
||||
onClose={() => setShowPaymentModal(false)}
|
||||
/>
|
||||
)}
|
||||
@@ -216,11 +303,27 @@ export function BillingPayments({ readOnly }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
function PaymentModal({ outstanding, totalOutstanding: _totalOutstanding, onClose }: { outstanding: Invoice[]; totalOutstanding: number; onClose: () => void }) {
|
||||
const [selectedInvoices, setSelectedInvoices] = useState<Set<string>>(new Set(outstanding.map(i => i.id)));
|
||||
function PaymentModal({
|
||||
pending,
|
||||
totalPending: _totalPending,
|
||||
onClose,
|
||||
}: {
|
||||
pending: Invoice[];
|
||||
totalPending: number;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [selectedInvoices, setSelectedInvoices] = useState<Set<string>>(
|
||||
new Set(pending.map((i) => i.id))
|
||||
);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [isComplete, setIsComplete] = useState(false);
|
||||
|
||||
const formatCents = (cents: number) =>
|
||||
new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(cents / 100);
|
||||
|
||||
const toggleInvoice = (id: string) => {
|
||||
const next = new Set(selectedInvoices);
|
||||
if (next.has(id)) {
|
||||
@@ -233,26 +336,45 @@ function PaymentModal({ outstanding, totalOutstanding: _totalOutstanding, onClos
|
||||
|
||||
const handlePay = async () => {
|
||||
setIsProcessing(true);
|
||||
// Simulate payment processing
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
setIsProcessing(false);
|
||||
setIsComplete(true);
|
||||
};
|
||||
|
||||
const selectedTotal = outstanding.filter(i => selectedInvoices.has(i.id)).reduce((sum, i) => sum + i.amount, 0);
|
||||
const selectedTotal = pending
|
||||
.filter((i) => selectedInvoices.has(i.id))
|
||||
.reduce((sum, i) => sum + i.totalCents, 0);
|
||||
|
||||
if (isComplete) {
|
||||
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-md w-full p-8 text-center">
|
||||
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
<svg
|
||||
className="w-8 h-8 text-green-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="font-semibold text-stone-800 text-lg mb-2">Payment Successful</h2>
|
||||
<p className="text-stone-500 text-sm mb-6">Your payment of ${selectedTotal.toFixed(2)} has been processed. A receipt has been sent to your email.</p>
|
||||
<button onClick={onClose} className="w-full px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium">
|
||||
<h2 className="font-semibold text-stone-800 text-lg mb-2">
|
||||
Payment Successful
|
||||
</h2>
|
||||
<p className="text-stone-500 text-sm mb-6">
|
||||
Your payment of {formatCents(selectedTotal)} has been processed. A
|
||||
receipt has been sent to your email.
|
||||
</p>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-full px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
@@ -264,10 +386,25 @@ function PaymentModal({ outstanding, totalOutstanding: _totalOutstanding, onClos
|
||||
<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-md w-full p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="font-semibold text-stone-800 text-lg">Pay Outstanding Balance</h2>
|
||||
<button onClick={onClose} className="text-stone-400 hover:text-stone-600">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
<h2 className="font-semibold text-stone-800 text-lg">
|
||||
Pay Outstanding Balance
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-stone-400 hover:text-stone-600"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -275,11 +412,13 @@ function PaymentModal({ outstanding, totalOutstanding: _totalOutstanding, onClos
|
||||
<p className="text-sm text-stone-500 mb-4">Select invoices to pay:</p>
|
||||
|
||||
<div className="space-y-3 mb-6">
|
||||
{outstanding.map(inv => (
|
||||
{pending.map((inv) => (
|
||||
<label
|
||||
key={inv.id}
|
||||
className={`flex items-center justify-between p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||
selectedInvoices.has(inv.id) ? "border-(--color-accent) bg-(--color-accent-lighter)" : "border-stone-200 hover:border-stone-300"
|
||||
selectedInvoices.has(inv.id)
|
||||
? "border-(--color-accent) bg-(--color-accent-lighter)"
|
||||
: "border-stone-200 hover:border-stone-300"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -290,11 +429,17 @@ function PaymentModal({ outstanding, totalOutstanding: _totalOutstanding, onClos
|
||||
className="w-4 h-4 rounded border-stone-300 text-(--color-accent) focus:ring-(--color-accent)"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-stone-800">{inv.items.join(", ")}</p>
|
||||
<p className="text-xs text-stone-500">{new Date(inv.date).toLocaleDateString()}</p>
|
||||
<p className="text-sm font-medium text-stone-800">
|
||||
{inv.description || `Invoice ${inv.id.slice(0, 8)}`}
|
||||
</p>
|
||||
<p className="text-xs text-stone-500">
|
||||
{new Date(inv.date).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-stone-800">${inv.amount.toFixed(2)}</span>
|
||||
<span className="text-sm font-medium text-stone-800">
|
||||
{formatCents(inv.totalCents)}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
@@ -302,7 +447,9 @@ function PaymentModal({ outstanding, totalOutstanding: _totalOutstanding, onClos
|
||||
<div className="border-t border-stone-200 pt-4 mb-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-stone-600">Total</span>
|
||||
<span className="text-lg font-bold text-stone-800">${selectedTotal.toFixed(2)}</span>
|
||||
<span className="text-lg font-bold text-stone-800">
|
||||
{formatCents(selectedTotal)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -326,50 +473,4 @@ function PaymentModal({ outstanding, totalOutstanding: _totalOutstanding, onClos
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export default BillingPayments;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,9 +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;
|
||||
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 {
|
||||
@@ -15,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 }: 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 */}
|
||||
@@ -53,11 +224,16 @@ export function Dashboard({ onNavigate, readOnly }: 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">
|
||||
@@ -71,13 +247,18 @@ export function Dashboard({ onNavigate, readOnly }: 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 className="text-sm px-3 py-1.5 border border-stone-200 rounded-lg text-stone-600 hover:bg-stone-50">
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
<button className="text-sm px-3 py-1.5 border border-stone-200 rounded-lg text-stone-600 hover:bg-stone-50">
|
||||
@@ -94,8 +275,8 @@ export function Dashboard({ onNavigate, readOnly }: 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}
|
||||
@@ -104,59 +285,63 @@ export function Dashboard({ onNavigate, readOnly }: 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
|
||||
@@ -167,29 +352,51 @@ export function Dashboard({ onNavigate, readOnly }: 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useState } from "react";
|
||||
import { X, Save } from "lucide-react";
|
||||
import type { Pet } from "../mockData.js";
|
||||
|
||||
interface Props {
|
||||
pet?: Pet;
|
||||
onSave: (pet: Pet) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function PetForm({ pet, onSave, onCancel }: Props) {
|
||||
const [name, setName] = useState(pet?.name ?? "");
|
||||
const [breed, setBreed] = useState(pet?.breed ?? "");
|
||||
const [weight, setWeight] = useState(pet?.weight ?? 0);
|
||||
const [notes, setNotes] = useState(pet?.allergies ?? "");
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!pet) return;
|
||||
onSave({ ...pet, name, breed, weight, allergies: notes });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-stone-200 p-6 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-lg font-semibold text-stone-800">{pet ? "Edit Pet" : "Add Pet"}</h2>
|
||||
<button onClick={onCancel} className="p-2 hover:bg-stone-50 rounded-lg">
|
||||
<X size={16} className="text-stone-400" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-600 mb-1">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
className="w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-600 mb-1">Breed</label>
|
||||
<input
|
||||
type="text"
|
||||
value={breed}
|
||||
onChange={e => setBreed(e.target.value)}
|
||||
className="w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-600 mb-1">Weight (lbs)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={weight}
|
||||
onChange={e => setWeight(Number(e.target.value))}
|
||||
className="w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-600 mb-1">Notes</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="flex-1 px-4 py-2 border border-stone-200 rounded-lg text-sm text-stone-600 hover:bg-stone-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 flex items-center justify-center gap-1.5 px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover)"
|
||||
>
|
||||
<Save size={14} />
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +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);
|
||||
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
|
||||
// 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>
|
||||
@@ -43,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 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">
|
||||
@@ -67,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
|
||||
@@ -85,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>
|
||||
@@ -108,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
|
||||
@@ -125,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.
|
||||
@@ -143,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
|
||||
@@ -156,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 ? (
|
||||
@@ -219,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>
|
||||
))
|
||||
|
||||
@@ -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,36 +225,32 @@ 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)">
|
||||
<button
|
||||
onClick={() => {
|
||||
// TODO: Pre-select the service from report card (serviceId/serviceName) once BookPage supports service pre-selection via URL param
|
||||
const params = new URLSearchParams();
|
||||
if (card.petName) params.set("petName", card.petName);
|
||||
if (card.serviceName) params.set("serviceName", card.serviceName);
|
||||
window.location.href = `/admin/book${params.size > 0 ? `?${params.toString()}` : ""}`;
|
||||
}}
|
||||
className="px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover)"
|
||||
>
|
||||
Rebook Now
|
||||
</button>
|
||||
</div>
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -7,7 +7,9 @@
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"skipLibCheck": true
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"allowImportingTsExtensions": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user