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:
Barkley Trimsworth
2026-03-29 19:56:14 +00:00
parent 9caa03723c
commit 38f435375f
89 changed files with 7064 additions and 916 deletions
+1
View File
@@ -0,0 +1 @@
VITE_API_URL=
+7
View File
@@ -0,0 +1,7 @@
# Ignore untracked .js files containing JSX (build artifacts)
src/__tests__/*.js
src/portal/sections/*.js
src/portal/*.js
src/pages/*.js
src/components/*.js
src/lib/*.js
+7
View File
@@ -1,6 +1,13 @@
import tseslint from "typescript-eslint";
export default tseslint.config(
{
ignores: [
// Untracked .js files containing JSX (build artifacts)
"src/**/*.js",
"src/**/*.jsx",
],
},
...tseslint.configs.recommended,
{
rules: {
+3 -1
View File
@@ -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
View File
@@ -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") ? (
+34 -1
View File
@@ -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;
+17 -17
View File
@@ -1,32 +1,32 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import type { Appointment } from "../portal/mockData.js";
import { parseTimeTo24Hour, isUpcoming, CustomerNotesSection, ConfirmationSection } from "../portal/sections/Appointments.js";
import { parseTimeTo24Hour, isUpcoming, CustomerNotesSection, ConfirmationSection } from "../portal/sections/Appointments.tsx";
const UPCOMING_APPT: Appointment = {
const UPCOMING_APPT = {
id: "appt-1",
petId: "pet-1",
petName: "Buddy",
groomerId: "groomer-1",
groomerName: "Sarah",
services: ["Bath & Brush"],
serviceId: "service-1",
addOns: [],
date: "2027-01-01",
time: "10:00 AM",
duration: 60,
price: 50,
status: "confirmed",
status: "confirmed" as const,
notes: "",
customerNotes: "",
confirmationStatus: "pending",
confirmationStatus: "pending" as const,
};
const PAST_APPT: Appointment = {
const PAST_APPT = {
...UPCOMING_APPT,
id: "appt-2",
date: "2025-01-01",
time: "10:00 AM",
status: "completed",
status: "completed" as const,
};
describe("parseTimeTo24Hour", () => {
@@ -78,7 +78,7 @@ describe("CustomerNotesSection", () => {
expect(screen.getByRole("button", { name: /Save Notes/i })).toBeInTheDocument();
});
it("sends X-Impersonation-Session-Id header when session exists", async () => {
it("sends Authorization header when session exists", async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({ id: "appt-1", customerNotes: "Updated", updatedAt: new Date().toISOString() }),
@@ -93,14 +93,14 @@ describe("CustomerNotesSection", () => {
"/api/portal/appointments/appt-1/notes",
expect.objectContaining({
headers: expect.objectContaining({
"X-Impersonation-Session-Id": "test-session-id",
"Authorization": "Bearer test-session-id",
}),
})
);
});
});
it("does not send X-Impersonation-Session-Id header when sessionId is null", async () => {
it("does not send Authorization header when sessionId is null", async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({ id: "appt-1", customerNotes: "Updated", updatedAt: new Date().toISOString() }),
@@ -115,7 +115,7 @@ describe("CustomerNotesSection", () => {
"/api/portal/appointments/appt-1/notes",
expect.objectContaining({
headers: expect.not.objectContaining({
"X-Impersonation-Session-Id": expect.anything(),
"Authorization": expect.anything(),
}),
})
);
@@ -212,7 +212,7 @@ describe("ConfirmationSection", () => {
it("renders confirmed badge when confirmationStatus is confirmed", () => {
render(<ConfirmationSection appointment={{ ...UPCOMING_APPT, confirmationStatus: "confirmed" }} sessionId="test-session-id" />);
expect(screen.getByText("Confirmed")).toBeInTheDocument();
expect(screen.getByText("Confirmed")).toBeInTheDocument();
});
it("renders cancelled badge when confirmationStatus is cancelled", () => {
@@ -251,11 +251,11 @@ describe("ConfirmationSection", () => {
);
});
await waitFor(() => {
expect(screen.getByText("Confirmed")).toBeInTheDocument();
expect(screen.getByText("Confirmed")).toBeInTheDocument();
});
});
it("sends X-Impersonation-Session-Id header when session exists", async () => {
it("sends Authorization header when session exists", async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({ id: "appt-1", confirmationStatus: "confirmed" }),
@@ -269,14 +269,14 @@ describe("ConfirmationSection", () => {
"/api/portal/appointments/appt-1/confirm",
expect.objectContaining({
headers: expect.objectContaining({
"X-Impersonation-Session-Id": "test-session-id",
"Authorization": "Bearer test-session-id",
}),
})
);
});
});
it("does not send X-Impersonation-Session-Id header when sessionId is null", async () => {
it("does not send Authorization header when sessionId is null", async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({ id: "appt-1", confirmationStatus: "confirmed" }),
@@ -290,7 +290,7 @@ describe("ConfirmationSection", () => {
"/api/portal/appointments/appt-1/confirm",
expect.objectContaining({
headers: expect.not.objectContaining({
"X-Impersonation-Session-Id": expect.anything(),
"Authorization": expect.anything(),
}),
})
);
+7
View File
@@ -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;
+3
View File
@@ -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);
+12 -3
View File
@@ -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));
+38 -1
View File
@@ -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" }}>
+2 -1
View File
@@ -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>
+1
View File
@@ -0,0 +1 @@
export { SetupWizard } from "./SetupWizard.jsx";
+227
View File
@@ -0,0 +1,227 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useBranding } from "../BrandingContext.js";
const STEPS = [
{ title: "Welcome", description: "Welcome to GroomBook! Let's get your business set up." },
{ title: "Business Name", description: "What is the name of your business?" },
{ title: "Super User", description: "You will be designated as a Super User with full administrative access." },
{ title: "Add Another Admin", description: "Consider adding a second Super User as a backup. This is optional but recommended." },
{ title: "All Set!", description: "Your GroomBook instance is ready to use." },
];
export function SetupWizard() {
const navigate = useNavigate();
const { refresh: refreshBranding } = useBranding();
const [step, setStep] = useState(0);
const [businessName, setBusinessName] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const current = STEPS[step];
const isLast = step === STEPS.length - 1;
const canGoBack = step > 0 && step < STEPS.length - 1;
const canGoNext = step < STEPS.length - 1 && (step !== 1 || businessName.trim().length > 0);
const handleNext = async () => {
if (step === STEPS.length - 1) {
// Done - redirect to admin
navigate("/admin");
return;
}
if (step === 1 && businessName.trim()) {
// Step 2 (index 1) -> Step 3 (index 2): submit setup
setLoading(true);
setError(null);
try {
const res = await fetch("/api/setup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ businessName: businessName.trim() }),
});
if (!res.ok) {
const data = await res.json();
setError(data.error || "Setup failed. Please try again.");
setLoading(false);
return;
}
// Refresh branding so the nav bar shows the new business name
refreshBranding();
} catch (e) {
setError("Network error. Please try again.");
setLoading(false);
return;
}
setLoading(false);
}
setStep((s) => s + 1);
};
const handleBack = () => {
if (step > 0) setStep((s) => s - 1);
};
return (
<div style={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#f0f2f5",
fontFamily: "system-ui, sans-serif",
}}>
<div style={{
background: "#fff",
borderRadius: 12,
boxShadow: "0 4px 24px rgba(0,0,0,0.10)",
padding: "2.5rem 3rem",
maxWidth: 480,
width: "100%",
}}>
{/* Progress dots */}
<div style={{ display: "flex", gap: 6, marginBottom: "2rem", justifyContent: "center" }}>
{STEPS.map((_, i) => (
<div
key={i}
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: i === step ? "#4f8a6f" : i < step ? "#4f8a6f" : "#e2e8f0",
opacity: i === step ? 1 : i < step ? 0.5 : 1,
transition: "background 0.2s",
}}
/>
))}
</div>
{/* Step indicator */}
<p style={{ margin: "0 0 0.5rem", fontSize: 13, color: "#6b7280", fontWeight: 500 }}>
Step {step + 1} of {STEPS.length}
</p>
{/* Title */}
<h2 style={{ margin: "0 0 0.75rem", fontSize: 22, fontWeight: 700, color: "#1a202c" }}>
{current.title}
</h2>
{/* Description */}
<p style={{ margin: "0 0 1.5rem", fontSize: 15, color: "#4b5563", lineHeight: 1.6 }}>
{current.description}
</p>
{/* Step 2: Business name input */}
{step === 1 && (
<input
type="text"
placeholder="e.g. Happy Paws Grooming"
value={businessName}
onChange={(e) => setBusinessName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && canGoNext && handleNext()}
autoFocus
style={{
width: "100%",
padding: "0.6rem 0.85rem",
borderRadius: 8,
border: "1px solid #d1d5db",
fontSize: 15,
outline: "none",
boxSizing: "border-box",
marginBottom: error ? "0.5rem" : 0,
}}
/>
)}
{/* Step 3: Info about super user */}
{step === 2 && (
<div style={{
background: "#f0fdf4",
border: "1px solid #bbf7d0",
borderRadius: 8,
padding: "0.85rem 1rem",
fontSize: 14,
color: "#166534",
marginBottom: "1rem",
}}>
As a Super User, you can manage all settings, staff, and appointments.
</div>
)}
{/* Step 4: Info about second admin */}
{step === 3 && (
<div style={{
background: "#fffbeb",
border: "1px solid #fde68a",
borderRadius: 8,
padding: "0.85rem 1rem",
fontSize: 14,
color: "#92400e",
}}>
You can add additional Super Users from the Staff management page after setup.
</div>
)}
{/* Error message */}
{error && (
<p style={{
margin: "0.5rem 0 0",
fontSize: 13,
color: "#dc2626",
background: "#fef2f2",
border: "1px solid #fecaca",
borderRadius: 6,
padding: "0.5rem 0.75rem",
}}>
{error}
</p>
)}
{/* Navigation buttons */}
<div style={{
display: "flex",
gap: "0.75rem",
marginTop: step === 3 ? "1.5rem" : "1.25rem",
justifyContent: step === 0 ? "flex-end" : "space-between",
}}>
{canGoBack && (
<button
onClick={handleBack}
disabled={loading}
style={{
padding: "0.55rem 1.1rem",
borderRadius: 8,
border: "1px solid #d1d5db",
background: "#fff",
color: "#374151",
fontSize: 14,
fontWeight: 500,
cursor: loading ? "not-allowed" : "pointer",
opacity: loading ? 0.6 : 1,
}}
>
Back
</button>
)}
<button
onClick={handleNext}
disabled={!canGoNext || loading}
style={{
padding: "0.55rem 1.25rem",
borderRadius: 8,
border: "none",
background: canGoNext && !loading ? "#4f8a6f" : "#9ca3af",
color: "#fff",
fontSize: 14,
fontWeight: 600,
cursor: canGoNext && !loading ? "pointer" : "not-allowed",
opacity: loading ? 0.7 : 1,
marginLeft: canGoBack ? 0 : "auto",
}}
>
{loading ? "Setting up..." : isLast ? "Go to Dashboard" : step === 1 ? "Continue" : "Next"}
</button>
</div>
</div>
</div>
);
}
+36 -10
View File
@@ -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>
+144 -39
View File
@@ -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
+259 -158
View File
@@ -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;
+70 -27
View File
@@ -1,7 +1,28 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { Send, Check, CheckCheck, Bell, Mail, Smartphone, Megaphone, FileText, CreditCard } from "lucide-react";
import { MESSAGES, BUSINESS_NAME } from "../mockData.js";
import type { Message } from "../mockData.js";
interface Message {
id: string;
sender: "customer" | "business";
senderName: string;
text: string;
timestamp: string;
read: boolean;
}
interface NotificationCategory {
email: boolean;
sms: boolean;
push: boolean;
}
interface NotificationPreferences {
appointmentReminders: NotificationCategory;
vaccinationAlerts: NotificationCategory;
promotional: NotificationCategory;
reportCards: NotificationCategory;
invoiceReceipts: NotificationCategory;
}
interface Props {
readOnly: boolean;
@@ -39,15 +60,31 @@ export function Communication({ readOnly }: Props) {
}
function MessageThread({ readOnly }: { readOnly: boolean }) {
const [messages, setMessages] = useState<Message[]>(MESSAGES);
const [messages, setMessages] = useState<Message[]>([]);
const [newMessage, setNewMessage] = useState("");
const [businessName, setBusinessName] = useState<string>("Business");
useEffect(() => {
async function fetchBranding() {
try {
const response = await fetch("/api/branding");
if (response.ok) {
const data = await response.json();
setBusinessName(data.businessName || data.name || "Business");
}
} catch {
setBusinessName("Business");
}
}
fetchBranding();
}, []);
const handleSend = () => {
if (!newMessage.trim() || readOnly) return;
const msg: Message = {
id: `m-${Date.now()}`,
sender: "customer",
senderName: "Sarah",
senderName: "You",
text: newMessage.trim(),
timestamp: new Date().toISOString(),
read: false,
@@ -59,32 +96,36 @@ function MessageThread({ readOnly }: { readOnly: boolean }) {
return (
<div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden flex flex-col" style={{ height: "500px" }}>
<div className="px-5 py-3 border-b border-stone-200 bg-stone-50">
<p className="text-sm font-medium text-stone-800">{BUSINESS_NAME}</p>
<p className="text-sm font-medium text-stone-800">{businessName}</p>
<p className="text-xs text-stone-400">Usually replies within a few hours</p>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{messages.map(msg => (
<div key={msg.id} className={`flex ${msg.sender === "customer" ? "justify-end" : "justify-start"}`}>
<div className={`max-w-[80%] rounded-2xl px-4 py-2.5 ${
msg.sender === "customer"
? "bg-(--color-accent) text-white rounded-br-md"
: "bg-stone-100 text-stone-800 rounded-bl-md"
}`}>
<p className="text-sm">{msg.text}</p>
<div className={`flex items-center gap-1 mt-1 ${msg.sender === "customer" ? "justify-end" : ""}`}>
<span className={`text-xs ${msg.sender === "customer" ? "text-white/60" : "text-stone-400"}`}>
{new Date(msg.timestamp).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}
</span>
{msg.sender === "customer" && (
msg.read
? <CheckCheck size={12} className="text-white/60" />
: <Check size={12} className="text-white/60" />
)}
{messages.length === 0 ? (
<p className="text-stone-400 text-center text-sm italic">No messages yet</p>
) : (
messages.map(msg => (
<div key={msg.id} className={`flex ${msg.sender === "customer" ? "justify-end" : "justify-start"}`}>
<div className={`max-w-[80%] rounded-2xl px-4 py-2.5 ${
msg.sender === "customer"
? "bg-(--color-accent) text-white rounded-br-md"
: "bg-stone-100 text-stone-800 rounded-bl-md"
}`}>
<p className="text-sm">{msg.text}</p>
<div className={`flex items-center gap-1 mt-1 ${msg.sender === "customer" ? "justify-end" : ""}`}>
<span className={`text-xs ${msg.sender === "customer" ? "text-white/60" : "text-stone-400"}`}>
{new Date(msg.timestamp).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}
</span>
{msg.sender === "customer" && (
msg.read
? <CheckCheck size={12} className="text-white/60" />
: <Check size={12} className="text-white/60" />
)}
</div>
</div>
</div>
</div>
))}
))
)}
</div>
{!readOnly && (
@@ -111,7 +152,7 @@ function MessageThread({ readOnly }: { readOnly: boolean }) {
}
function NotificationPreferences({ readOnly }: { readOnly: boolean }) {
const [prefs, setPrefs] = useState({
const [prefs, setPrefs] = useState<NotificationPreferences>({
appointmentReminders: { email: true, sms: true, push: true },
vaccinationAlerts: { email: true, sms: false, push: true },
promotional: { email: false, sms: false, push: false },
@@ -119,7 +160,7 @@ function NotificationPreferences({ readOnly }: { readOnly: boolean }) {
invoiceReceipts: { email: true, sms: false, push: false },
});
type PrefKey = keyof typeof prefs;
type PrefKey = keyof NotificationPreferences;
type ChannelKey = "email" | "sms" | "push";
const toggle = (category: PrefKey, channel: ChannelKey) => {
@@ -194,3 +235,5 @@ function NotificationPreferences({ readOnly }: { readOnly: boolean }) {
</div>
);
}
export default Communication;
+270 -63
View File
@@ -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>
);
}
}
+87
View File
@@ -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>
);
}
+172 -109
View File
@@ -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>
))
+134 -45
View File
@@ -1,9 +1,8 @@
import { useState } from "react";
import { FileText, Share2, Calendar, Smile, Meh, AlertCircle, ChevronRight } from "lucide-react";
import { REPORT_CARDS } from "../mockData.js";
import type { ReportCard } from "../mockData.js";
import { useState, useEffect } from "react";
import { FileText, Share2, Calendar, Smile, Meh, ChevronRight, Loader2 } from "lucide-react";
type MoodKey = "calm" | "cooperative" | "anxious" | "wiggly";
const MOOD_CONFIG: Record<MoodKey, { icon: typeof Smile; label: string; color: string; bg: string }> = {
calm: { icon: Smile, label: "Calm & Relaxed", color: "text-green-700", bg: "bg-green-100" },
cooperative: { icon: Smile, label: "Cooperative", color: "text-blue-700", bg: "bg-blue-100" },
@@ -11,8 +10,87 @@ const MOOD_CONFIG: Record<MoodKey, { icon: typeof Smile; label: string; color: s
wiggly: { icon: Meh, label: "Wiggly", color: "text-purple-700", bg: "bg-purple-100" },
};
interface Appointment {
id: string;
petId: string;
serviceId: string;
groomerId: string | null;
date: string;
time: string;
status: string;
petName?: string;
serviceName?: string;
groomerName?: string;
reportCardId?: string;
}
export function ReportCards() {
const [selectedCard, setSelectedCard] = useState<ReportCard | null>(null);
const [appointments, setAppointments] = useState<Appointment[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedCard, setSelectedCard] = useState<Appointment | null>(null);
useEffect(() => {
const fetchReportCards = async () => {
try {
const response = await fetch("/api/portal/appointments");
if (response.ok) {
const data = await response.json();
const allAppointments: Appointment[] = data.appointments || data || [];
const reportCardAppointments = allAppointments.filter(
(appt) => appt.reportCardId
);
setAppointments(reportCardAppointments);
} else {
setError("Failed to load report cards.");
}
} catch {
setError("Failed to load report cards. Please try again.");
} finally {
setIsLoading(false);
}
};
fetchReportCards();
}, []);
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="animate-spin text-stone-400" size={24} />
<span className="ml-3 text-stone-500">Loading report cards...</span>
</div>
);
}
if (error) {
return (
<div className="text-center py-12">
<p className="text-red-600 mb-4">{error}</p>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-stone-100 text-stone-700 rounded-md hover:bg-stone-200"
>
Retry
</button>
</div>
);
}
if (appointments.length === 0) {
return (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-stone-100 flex items-center justify-center">
<FileText size={24} className="text-stone-400" />
</div>
<h3 className="text-lg font-medium text-stone-800 mb-1">No Report Cards Yet</h3>
<p className="text-sm text-stone-500">
Report cards from your grooming visits will appear here after your appointments.
</p>
</div>
);
}
if (selectedCard) {
return <ReportCardDetail card={selectedCard} onBack={() => setSelectedCard(null)} />;
@@ -23,8 +101,9 @@ export function ReportCards() {
<p className="text-sm text-stone-500">Grooming report cards from your recent visits</p>
<div className="space-y-4">
{REPORT_CARDS.map(card => {
const mood = MOOD_CONFIG[card.behaviorMood];
{appointments.map((card) => {
const moodKey: MoodKey = "cooperative";
const mood = MOOD_CONFIG[moodKey];
const MoodIcon = mood.icon;
return (
<button
@@ -38,16 +117,20 @@ export function ReportCards() {
</div>
<div className="flex-1">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-stone-800">{card.petName}'s Report Card</h3>
<h3 className="font-semibold text-stone-800">{card.petName || "Pet"}'s Report Card</h3>
<ChevronRight size={16} className="text-stone-400" />
</div>
<p className="text-sm text-stone-500 mt-0.5">
{card.servicesPerformed.join(", ")} with {card.groomerName}
{card.serviceName || "Grooming"} with {card.groomerName || "your groomer"}
</p>
<div className="flex items-center gap-3 mt-2">
<span className="flex items-center gap-1 text-xs text-stone-400">
<Calendar size={12} />
{new Date(card.date).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
{new Date(card.date).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
</span>
<span className={`flex items-center gap-1 text-xs px-2 py-0.5 rounded-full ${mood.bg} ${mood.color}`}>
<MoodIcon size={12} />
@@ -64,28 +147,40 @@ export function ReportCards() {
);
}
function ReportCardDetail({ card, onBack }: { card: ReportCard; onBack: () => void }) {
const mood = MOOD_CONFIG[card.behaviorMood];
function ReportCardDetail({ card, onBack }: { card: Appointment; onBack: () => void }) {
const moodKey: MoodKey = "cooperative";
const mood = MOOD_CONFIG[moodKey];
const MoodIcon = mood.icon;
return (
<div className="space-y-6">
<button onClick={onBack} className="text-sm text-(--color-accent-dark) font-medium hover:underline">
← Back to Report Cards
<button
onClick={onBack}
className="text-sm text-(--color-accent-dark) font-medium hover:underline"
>
Back to Report Cards
</button>
<div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden">
{/* Header */}
<div className="bg-gradient-to-r from-(--color-accent-lighter) to-(--color-accent-light) p-6">
<div className="flex items-center justify-between mb-1">
<h2 className="text-xl font-semibold text-stone-800">{card.petName}'s Grooming Report</h2>
<h2 className="text-xl font-semibold text-stone-800">
{card.petName || "Pet"}'s Grooming Report
</h2>
<button className="flex items-center gap-1.5 px-3 py-1.5 bg-white/80 text-stone-700 rounded-lg text-sm font-medium hover:bg-white">
<Share2 size={14} />
Share
</button>
</div>
<p className="text-sm text-stone-600">
{new Date(card.date).toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric", year: "numeric" })} · Groomer: {card.groomerName}
{new Date(card.date).toLocaleDateString("en-US", {
weekday: "long",
month: "long",
day: "numeric",
year: "numeric",
})}
{card.groomerName ? ` · Groomer: ${card.groomerName}` : ""}
</p>
</div>
@@ -99,14 +194,14 @@ function ReportCardDetail({ card, onBack }: { card: ReportCard; onBack: () => vo
<div className="w-full h-32 bg-stone-200 rounded-lg flex items-center justify-center text-stone-400 text-sm mb-2">
Photo placeholder
</div>
<p className="text-sm text-stone-600">{card.beforeDescription}</p>
<p className="text-sm text-stone-600">Before photo description not available.</p>
</div>
<div className="rounded-xl bg-(--color-accent-lighter) p-4">
<p className="text-xs font-medium text-(--color-accent) uppercase mb-2">After</p>
<div className="w-full h-32 bg-(--color-accent-light) rounded-lg flex items-center justify-center text-(--color-accent) text-sm mb-2">
Photo placeholder
</div>
<p className="text-sm text-stone-700">{card.afterDescription}</p>
<p className="text-sm text-stone-700">After photo description not available.</p>
</div>
</div>
</div>
@@ -115,11 +210,9 @@ function ReportCardDetail({ card, onBack }: { card: ReportCard; onBack: () => vo
<div>
<h3 className="font-medium text-stone-800 mb-2">Services Performed</h3>
<div className="flex flex-wrap gap-2">
{card.servicesPerformed.map(s => (
<span key={s} className="px-3 py-1 bg-stone-100 rounded-full text-sm text-stone-700">
{s}
</span>
))}
<span className="px-3 py-1 bg-stone-100 rounded-full text-sm text-stone-700">
{card.serviceName || "Grooming"}
</span>
</div>
</div>
@@ -132,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>
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+3 -1
View File
@@ -7,7 +7,9 @@
"jsx": "react-jsx",
"strict": true,
"noUncheckedIndexedAccess": true,
"skipLibCheck": true
"skipLibCheck": true,
"noEmit": true,
"allowImportingTsExtensions": true
},
"include": ["src"]
}