feat: extract groombook/web from monorepo
- Copy apps/web/ with all src, components, pages, portal - Inline packages/types/ as local packages/types module - Add tsconfig path aliases for @groombook/types - Port Dockerfile and CI workflow - Image name: ghcr.io/groombook/web Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -0,0 +1,347 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { User, Lock, PawPrint, FileCheck, Plus, Archive } from "lucide-react";
|
||||
import { PetForm } from "./PetForm.js";
|
||||
import { authClient } from "../../lib/auth-client.js";
|
||||
|
||||
interface Props {
|
||||
sessionId: string | null;
|
||||
readOnly: boolean;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{([
|
||||
{ id: "personal" as const, label: "Personal Info", icon: User },
|
||||
{ id: "password" as const, label: "Password", icon: Lock },
|
||||
{ id: "pets" as const, label: "Manage Pets", icon: PawPrint },
|
||||
{ id: "agreements" as const, label: "Agreements", icon: FileCheck },
|
||||
]).map(({ id, label, icon: Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => setTab(id)}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium ${
|
||||
tab === id ? "bg-(--color-accent-light) text-(--color-accent-dark)" : "text-stone-500 hover:bg-stone-50"
|
||||
}`}
|
||||
>
|
||||
<Icon size={14} />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === "personal" && <PersonalInfo sessionId={sessionId} readOnly={readOnly} />}
|
||||
{tab === "password" && <PasswordChange readOnly={readOnly} />}
|
||||
{tab === "pets" && <ManagePets sessionId={sessionId} readOnly={readOnly} />}
|
||||
{tab === "agreements" && <Agreements />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PersonalInfo({ sessionId, readOnly }: { sessionId: string | null; readOnly: boolean }) {
|
||||
const [form, setForm] = useState({
|
||||
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", {
|
||||
headers: { "X-Impersonation-Session-Id": sessionId ?? "" },
|
||||
});
|
||||
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">
|
||||
<h3 className="font-medium text-stone-800 mb-4">Personal Information</h3>
|
||||
<div className="space-y-4 max-w-md">
|
||||
{([
|
||||
{ key: "name" as const, label: "Full Name", type: "text" },
|
||||
{ key: "email" as const, label: "Email", type: "email" },
|
||||
{ key: "phone" as const, label: "Phone", type: "tel" },
|
||||
{ key: "address" as const, label: "Address", type: "text" },
|
||||
]).map(({ key, label, type }) => (
|
||||
<div key={key}>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-1">{label}</label>
|
||||
<input
|
||||
type={type}
|
||||
value={form[key]}
|
||||
onChange={e => !readOnly && setForm({ ...form, [key]: e.target.value })}
|
||||
disabled={readOnly}
|
||||
className="w-full border border-stone-300 rounded-lg px-3 py-2 text-sm disabled:bg-stone-50 disabled:text-stone-500"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{!readOnly && (
|
||||
<button className="px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover)">
|
||||
Save Changes
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PasswordChange({ readOnly }: { readOnly: boolean }) {
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const passwordsMatch = newPassword === confirmPassword;
|
||||
const canSubmit = newPassword.length > 0 && passwordsMatch && !loading;
|
||||
|
||||
if (readOnly) {
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||
<p className="text-sm text-stone-500">Password changes are not available during staff impersonation.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!canSubmit) return;
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError("Passwords do not match.");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await (authClient as any).changePassword({
|
||||
currentPassword,
|
||||
newPassword,
|
||||
});
|
||||
if (result.error) {
|
||||
setError(result.error.message ?? "Failed to change password.");
|
||||
} else {
|
||||
setSuccess(true);
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
setTimeout(() => setSuccess(false), 4000);
|
||||
}
|
||||
} catch {
|
||||
setError("An unexpected error occurred.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||
<h3 className="font-medium text-stone-800 mb-4">Change Password</h3>
|
||||
<div className="space-y-4 max-w-md">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-1">Current Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
className="w-full border border-stone-300 rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-1">New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
className="w-full border border-stone-300 rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-1">Confirm New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="w-full border border-stone-300 rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
{success && <p className="text-sm text-green-600">Password updated successfully.</p>}
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
className="px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover) disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? "Updating..." : "Update Password"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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", {
|
||||
headers: { "X-Impersonation-Session-Id": sessionId ?? "" },
|
||||
});
|
||||
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}
|
||||
onSave={() => { setEditingPetId(null); setShowAddForm(false); }}
|
||||
onCancel={() => { setEditingPetId(null); setShowAddForm(false); }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{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}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-stone-800">{pet.name}</p>
|
||||
<p className="text-sm text-stone-500">{pet.breed} · {pet.weight} lbs</p>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div className="flex gap-2">
|
||||
<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">
|
||||
<Archive size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{!readOnly && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Agreements() {
|
||||
return (
|
||||
<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
@@ -0,0 +1,578 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { loadStripe } from "@stripe/stripe-js";
|
||||
import { Elements, PaymentElement, useStripe, useElements } from "@stripe/react-stripe-js";
|
||||
import { CreditCard, DollarSign, Package, Zap } from "lucide-react";
|
||||
|
||||
interface Invoice {
|
||||
id: string;
|
||||
status: "pending" | "paid" | "failed" | "refunded";
|
||||
totalCents: number;
|
||||
date: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface PaymentMethod {
|
||||
id: string;
|
||||
brand: string;
|
||||
last4: string;
|
||||
expiryMonth: number;
|
||||
expiryYear: number;
|
||||
}
|
||||
|
||||
interface BillingPaymentsProps {
|
||||
sessionId: string | null;
|
||||
readOnly: boolean;
|
||||
}
|
||||
|
||||
function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
||||
const [invoices, setInvoices] = useState<Invoice[]>([]);
|
||||
const [paymentMethods, setPaymentMethods] = useState<PaymentMethod[]>([]);
|
||||
const [packages] = useState<{ name: string; remaining: number }[]>([]);
|
||||
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 [showPaymentModal, setShowPaymentModal] = useState(false);
|
||||
const [publishableKey, setPublishableKey] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
if (!sessionId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const [configRes, invoicesRes, methodsRes] = await Promise.all([
|
||||
fetch("/api/portal/config", {
|
||||
headers: { "X-Impersonation-Session-Id": sessionId },
|
||||
}),
|
||||
fetch("/api/portal/invoices", {
|
||||
headers: { "X-Impersonation-Session-Id": sessionId },
|
||||
}),
|
||||
fetch("/api/portal/payment-methods", {
|
||||
headers: { "X-Impersonation-Session-Id": sessionId },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!configRes.ok) throw new Error("Failed to fetch config");
|
||||
const configData = await configRes.json();
|
||||
setPublishableKey(configData.stripePublishableKey ?? "");
|
||||
|
||||
const invoicesData = await invoicesRes.json();
|
||||
setInvoices(Array.isArray(invoicesData) ? invoicesData : invoicesData.invoices || []);
|
||||
|
||||
if (methodsRes.ok) {
|
||||
const methodsData = await methodsRes.json();
|
||||
setPaymentMethods(
|
||||
(methodsData ?? []).map((m: { id: string; card: { brand: string; last4: string; exp_month: number; exp_year: number } }) => ({
|
||||
id: m.id,
|
||||
brand: m.card?.brand ?? "unknown",
|
||||
last4: m.card?.last4 ?? "****",
|
||||
expiryMonth: m.card?.exp_month ?? 0,
|
||||
expiryYear: m.card?.exp_year ?? 0,
|
||||
}))
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "An error occurred");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchData();
|
||||
}, [sessionId]);
|
||||
|
||||
const formatCents = (cents: number) =>
|
||||
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 className="h-24 bg-gray-200 rounded" />
|
||||
<div className="h-24 bg-gray-200 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="text-red-600">Error: {error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{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">{formatCents(totalPending)}</p>
|
||||
<p className="text-xs text-stone-400 mt-0.5">
|
||||
{pending.length} unpaid invoice{pending.length > 1 ? "s" : ""}
|
||||
</p>
|
||||
</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>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{([
|
||||
{ id: "invoices" as const, label: "Invoices", icon: DollarSign },
|
||||
{ id: "payment" as const, label: "Payment Methods", icon: CreditCard },
|
||||
{ id: "packages" as const, label: "Packages", icon: Package },
|
||||
]).map(({ id, label, icon: Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => setTab(id)}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium ${
|
||||
tab === id
|
||||
? "bg-(--color-accent-light) text-(--color-accent-dark)"
|
||||
: "text-stone-500 hover:bg-stone-50"
|
||||
}`}
|
||||
>
|
||||
<Icon size={14} />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === "invoices" && (
|
||||
<div className="bg-white rounded-2xl border border-stone-200 shadow-sm overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-stone-400 border-b border-stone-100">
|
||||
<th className="px-5 py-3 font-medium">Date</th>
|
||||
<th className="px-5 py-3 font-medium">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" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{invoices.map((inv) => (
|
||||
<tr key={inv.id} className="border-b border-stone-50 hover:bg-stone-50/50">
|
||||
<td className="px-5 py-3 text-stone-700">
|
||||
{new Date(inv.date).toLocaleDateString("en-US", {
|
||||
month: "short", day: "numeric", year: "numeric",
|
||||
})}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-stone-600">
|
||||
{inv.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">
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
inv.status === "paid"
|
||||
? "bg-green-100 text-green-700"
|
||||
: inv.status === "pending"
|
||||
? "bg-yellow-100 text-yellow-700"
|
||||
: inv.status === "failed"
|
||||
? "bg-red-100 text-red-700"
|
||||
: "bg-gray-100 text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{inv.status.charAt(0).toUpperCase() + inv.status.slice(1)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<button className="text-stone-400 hover:text-stone-600">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "payment" && (
|
||||
<div className="space-y-4">
|
||||
{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.id}
|
||||
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>
|
||||
{!readOnly && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
const res = await fetch(`/api/portal/payment-methods/${method.id}`, {
|
||||
method: "DELETE",
|
||||
headers: { "X-Impersonation-Session-Id": sessionId ?? "" },
|
||||
});
|
||||
if (res.ok) {
|
||||
setPaymentMethods((prev) => prev.filter((m) => m.id !== method.id));
|
||||
}
|
||||
}}
|
||||
className="text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-(--color-accent-light) flex items-center justify-center">
|
||||
<Zap size={18} className="text-(--color-accent)" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-stone-800">Autopay</p>
|
||||
<p className="text-xs text-stone-500">Automatically charge after each appointment</p>
|
||||
</div>
|
||||
</div>
|
||||
{!readOnly ? (
|
||||
<button
|
||||
onClick={() => setAutopay(!autopay)}
|
||||
className={`w-12 h-6 rounded-full transition-colors ${
|
||||
autopay ? "bg-(--color-accent)" : "bg-stone-300"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-5 h-5 bg-white rounded-full shadow transition-transform ${
|
||||
autopay ? "translate-x-6" : "translate-x-0.5"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-xs text-stone-400">
|
||||
{autopay ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "packages" && (
|
||||
<div className="space-y-4">
|
||||
{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>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showPaymentModal && publishableKey && (
|
||||
<PaymentModalWrapper
|
||||
key={Date.now()}
|
||||
sessionId={sessionId ?? ""}
|
||||
publishableKey={publishableKey}
|
||||
pending={pending}
|
||||
onClose={() => setShowPaymentModal(false)}
|
||||
onSuccess={() => {
|
||||
setInvoices((prev) =>
|
||||
prev.map((inv) =>
|
||||
pending.some((p) => p.id === inv.id) ? { ...inv, status: "paid" as const } : inv
|
||||
)
|
||||
);
|
||||
setShowPaymentModal(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PaymentModalWrapperProps {
|
||||
sessionId: string;
|
||||
publishableKey: string;
|
||||
pending: Invoice[];
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
function PaymentModalWrapper({ sessionId, publishableKey, pending, onClose, onSuccess }: PaymentModalWrapperProps) {
|
||||
const [stripePromise] = useState(() =>
|
||||
publishableKey ? loadStripe(publishableKey) : Promise.resolve(null)
|
||||
);
|
||||
|
||||
return (
|
||||
<Elements stripe={stripePromise} options={{ mode: "payment", amount: pending.reduce((s, i) => s + i.totalCents, 0), currency: "usd" }}>
|
||||
<PaymentModal sessionId={sessionId} pending={pending} onClose={onClose} onSuccess={onSuccess} />
|
||||
</Elements>
|
||||
);
|
||||
}
|
||||
|
||||
interface PaymentModalProps {
|
||||
sessionId: string;
|
||||
pending: Invoice[];
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalProps) {
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
const [selectedInvoices, setSelectedInvoices] = useState<Set<string>>(new Set(pending.map((i) => i.id)));
|
||||
const [saveCard, setSaveCard] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [isComplete, setIsComplete] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const completeModalRef = useRef<HTMLDivElement>(null);
|
||||
const paymentModalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Focus trap + Escape-to-close for both inline modals
|
||||
useEffect(() => {
|
||||
const modalRef = isComplete ? completeModalRef.current : paymentModalRef.current;
|
||||
if (!modalRef) return;
|
||||
|
||||
const previouslyFocused = document.activeElement as HTMLElement;
|
||||
const focusableSelectors = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
||||
const focusableElements = modalRef.querySelectorAll<HTMLElement>(focusableSelectors);
|
||||
const firstFocusable = focusableElements[0];
|
||||
firstFocusable?.focus();
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (e.key !== "Tab" || !modalRef) return;
|
||||
const focusables = modalRef.querySelectorAll<HTMLElement>(focusableSelectors);
|
||||
const first = focusables[0];
|
||||
const last = focusables[focusables.length - 1];
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last?.focus();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first?.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
previouslyFocused?.focus();
|
||||
};
|
||||
}, [isComplete, onClose]);
|
||||
|
||||
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)) next.delete(id);
|
||||
else next.add(id);
|
||||
setSelectedInvoices(next);
|
||||
};
|
||||
|
||||
const selectedTotal = pending.filter((i) => selectedInvoices.has(i.id)).reduce((sum, i) => sum + i.totalCents, 0);
|
||||
|
||||
const handlePay = async () => {
|
||||
if (!stripe || !elements) return;
|
||||
setIsProcessing(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const isMulti = selectedInvoices.size > 1;
|
||||
const endpoint = isMulti ? "/api/portal/invoices/pay-multiple" : `/api/portal/invoices/${[...selectedInvoices][0]}/pay`;
|
||||
const body = isMulti ? { invoiceIds: [...selectedInvoices] } : {};
|
||||
|
||||
const res = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Impersonation-Session-Id": sessionId,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error ?? "Failed to initialize payment");
|
||||
}
|
||||
|
||||
const { clientSecret } = await res.json();
|
||||
|
||||
const { error: stripeError } = await stripe.confirmPayment({
|
||||
elements,
|
||||
clientSecret,
|
||||
confirmParams: saveCard
|
||||
? { setup_future_usage: "off_session" }
|
||||
: undefined,
|
||||
redirect: "if_required",
|
||||
});
|
||||
|
||||
if (stripeError) {
|
||||
setError(stripeError.message ?? "Payment failed");
|
||||
setIsProcessing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsComplete(true);
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "An unexpected error occurred");
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isComplete) {
|
||||
return (
|
||||
<div role="dialog" aria-modal="true" className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div ref={completeModalRef} 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>
|
||||
</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 {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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div role="dialog" aria-modal="true" className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div ref={paymentModalRef} 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" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-stone-500 mb-4">Select invoices to pay:</p>
|
||||
|
||||
<div className="space-y-3 mb-6">
|
||||
{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"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedInvoices.has(inv.id)}
|
||||
onChange={() => toggleInvoice(inv.id)}
|
||||
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.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">{formatCents(inv.totalCents)}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-stone-200 pt-4 mb-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<span className="text-sm text-stone-600">Total</span>
|
||||
<span className="text-lg font-bold text-stone-800">{formatCents(selectedTotal)}</span>
|
||||
</div>
|
||||
|
||||
<PaymentElement />
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 mb-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={saveCard}
|
||||
onChange={(e) => setSaveCard(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-stone-300 text-(--color-accent) focus:ring-(--color-accent)"
|
||||
/>
|
||||
<span className="text-sm text-stone-600">Save card for future payments</span>
|
||||
</label>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2 border border-stone-200 rounded-lg text-sm font-medium text-stone-600 hover:bg-stone-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePay}
|
||||
disabled={selectedInvoices.size === 0 || isProcessing || !stripe}
|
||||
className="flex-1 px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover) disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isProcessing ? "Processing..." : "Pay Now"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BillingPayments(props: BillingPaymentsProps) {
|
||||
return <BillingPaymentsInner {...props} />;
|
||||
}
|
||||
|
||||
export default BillingPayments;
|
||||
@@ -0,0 +1,239 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Send, Check, CheckCheck, Bell, Mail, Smartphone, Megaphone, FileText, CreditCard } from "lucide-react";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function Communication({ readOnly }: Props) {
|
||||
const [tab, setTab] = useState<"messages" | "notifications">("messages");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setTab("messages")}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium ${
|
||||
tab === "messages" ? "bg-(--color-accent-light) text-(--color-accent-dark)" : "text-stone-500 hover:bg-stone-50"
|
||||
}`}
|
||||
>
|
||||
Messages
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab("notifications")}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium ${
|
||||
tab === "notifications" ? "bg-(--color-accent-light) text-(--color-accent-dark)" : "text-stone-500 hover:bg-stone-50"
|
||||
}`}
|
||||
>
|
||||
<Bell size={14} />
|
||||
Notification Preferences
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tab === "messages" && <MessageThread readOnly={readOnly} />}
|
||||
{tab === "notifications" && <NotificationPreferences readOnly={readOnly} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageThread({ readOnly }: { readOnly: boolean }) {
|
||||
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: "You",
|
||||
text: newMessage.trim(),
|
||||
timestamp: new Date().toISOString(),
|
||||
read: false,
|
||||
};
|
||||
setMessages([...messages, msg]);
|
||||
setNewMessage("");
|
||||
};
|
||||
|
||||
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">{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.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>
|
||||
|
||||
{!readOnly && (
|
||||
<div className="border-t border-stone-200 p-3 flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newMessage}
|
||||
onChange={e => setNewMessage(e.target.value)}
|
||||
onKeyDown={e => e.key === "Enter" && handleSend()}
|
||||
placeholder="Type a message..."
|
||||
className="flex-1 border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)/30 focus:border-(--color-accent)"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!newMessage.trim()}
|
||||
className="px-4 py-2 bg-(--color-accent) text-white rounded-lg hover:bg-(--color-accent-hover) disabled:opacity-50"
|
||||
>
|
||||
<Send size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationPreferences({ readOnly }: { readOnly: boolean }) {
|
||||
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 },
|
||||
reportCards: { email: true, sms: false, push: true },
|
||||
invoiceReceipts: { email: true, sms: false, push: false },
|
||||
});
|
||||
|
||||
type PrefKey = keyof NotificationPreferences;
|
||||
type ChannelKey = "email" | "sms" | "push";
|
||||
|
||||
const toggle = (category: PrefKey, channel: ChannelKey) => {
|
||||
if (readOnly) return;
|
||||
setPrefs(prev => ({
|
||||
...prev,
|
||||
[category]: {
|
||||
...prev[category],
|
||||
[channel]: !prev[category][channel],
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const categories: { key: PrefKey; label: string; desc: string; icon: typeof Bell }[] = [
|
||||
{ key: "appointmentReminders", label: "Appointment Reminders", desc: "Upcoming appointment notifications", icon: Bell },
|
||||
{ key: "vaccinationAlerts", label: "Vaccination Alerts", desc: "Expiration and renewal reminders", icon: FileText },
|
||||
{ key: "promotional", label: "Promotions & Offers", desc: "Deals and seasonal specials", icon: Megaphone },
|
||||
{ key: "reportCards", label: "Report Cards", desc: "Grooming report card delivery", icon: FileText },
|
||||
{ key: "invoiceReceipts", label: "Invoice & Receipts", desc: "Payment confirmations", icon: CreditCard },
|
||||
];
|
||||
|
||||
const channels: { key: ChannelKey; label: string; icon: typeof Mail }[] = [
|
||||
{ key: "email", label: "Email", icon: Mail },
|
||||
{ key: "sms", label: "SMS", icon: Smartphone },
|
||||
{ key: "push", label: "Push", icon: Bell },
|
||||
];
|
||||
|
||||
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="border-b border-stone-100">
|
||||
<th className="text-left px-5 py-3 text-xs text-stone-400 font-medium">Category</th>
|
||||
{channels.map(ch => (
|
||||
<th key={ch.key} className="px-5 py-3 text-xs text-stone-400 font-medium text-center">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<ch.icon size={12} />
|
||||
{ch.label}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{categories.map(cat => (
|
||||
<tr key={cat.key} className="border-b border-stone-50">
|
||||
<td className="px-5 py-3">
|
||||
<p className="font-medium text-stone-800">{cat.label}</p>
|
||||
<p className="text-xs text-stone-400">{cat.desc}</p>
|
||||
</td>
|
||||
{channels.map(ch => (
|
||||
<td key={ch.key} className="px-5 py-3 text-center">
|
||||
<button
|
||||
onClick={() => toggle(cat.key, ch.key)}
|
||||
disabled={readOnly}
|
||||
className={`w-10 h-5 rounded-full transition-colors inline-block ${
|
||||
prefs[cat.key][ch.key] ? "bg-(--color-accent)" : "bg-stone-300"
|
||||
} ${readOnly ? "cursor-not-allowed opacity-60" : ""}`}
|
||||
>
|
||||
<div className={`w-4 h-4 bg-white rounded-full shadow transition-transform ${
|
||||
prefs[cat.key][ch.key] ? "translate-x-5" : "translate-x-0.5"
|
||||
}`} />
|
||||
</button>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Communication;
|
||||
@@ -0,0 +1,405 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { Calendar, Clock, PawPrint, CreditCard, Star, ChevronRight, AlertTriangle } from "lucide-react";
|
||||
import { getDevUser } from "../../pages/DevLoginSelector";
|
||||
|
||||
interface DashboardProps {
|
||||
sessionId: string | null;
|
||||
clientName: string;
|
||||
onNavigate: (section: "appointments" | "pets" | "billing" | "reports") => void;
|
||||
readOnly: boolean;
|
||||
onReschedule: (appointmentId: string) => void;
|
||||
/** True when a sessionId param was in the URL and the session is still loading */
|
||||
isImpersonating?: boolean;
|
||||
}
|
||||
|
||||
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 {
|
||||
const now = new Date();
|
||||
now.setHours(0, 0, 0, 0);
|
||||
const target = new Date(dateStr);
|
||||
target.setHours(0, 0, 0, 0);
|
||||
return Math.ceil((target.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString("en-US", {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export function Dashboard({
|
||||
sessionId,
|
||||
clientName,
|
||||
onNavigate,
|
||||
readOnly,
|
||||
onReschedule,
|
||||
isImpersonating,
|
||||
}: 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-Impersonation-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>
|
||||
);
|
||||
}
|
||||
|
||||
// Don't redirect to /login if we have a dev user — dev sessions may not have
|
||||
// sessionId set immediately after creation (session?.id may be null due to
|
||||
// timing or API response issues). Dev users are stored in localStorage and
|
||||
// verified via the dev-session flow, so they should see the portal.
|
||||
if (!sessionId && !isImpersonating && !getDevUser()) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
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, {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 */}
|
||||
{nextAppt && (
|
||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-(--color-accent-dark)">
|
||||
<Calendar size={16} />
|
||||
Next Appointment
|
||||
</div>
|
||||
<span className="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded-full font-medium">
|
||||
{nextAppt.status}
|
||||
</span>
|
||||
</div>
|
||||
<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}
|
||||
{nextAppt.groomerName && ` with ${nextAppt.groomerName}`}
|
||||
{nextAppt.staffName && ` with ${nextAppt.staffName}`}
|
||||
</p>
|
||||
<p className="text-stone-600 text-sm mt-1">
|
||||
{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">
|
||||
<Calendar size={14} />
|
||||
{formatDate(nextAppt.date)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock size={14} />
|
||||
{nextAppt.time}
|
||||
</span>
|
||||
</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-xs text-stone-500">days away</div>
|
||||
</div>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div className="flex gap-2 mt-4">
|
||||
<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">
|
||||
Cancel
|
||||
</button>
|
||||
<button className="text-sm px-3 py-1.5 border border-stone-200 rounded-lg text-stone-600 hover:bg-stone-50">
|
||||
Add Notes
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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 petAlerts = pet.healthAlerts || [];
|
||||
return (
|
||||
<button
|
||||
key={pet.id}
|
||||
onClick={() => onNavigate("pets")}
|
||||
className="bg-white rounded-2xl border border-stone-200 p-4 shadow-sm text-left hover:border-stone-300 transition-colors"
|
||||
>
|
||||
<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.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.species}
|
||||
{pet.weight && ` · ${pet.weight} lbs`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{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} />
|
||||
{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 health records current
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pending Balance & Recent Activity */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* 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 mb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-stone-500 mb-1">
|
||||
<CreditCard size={16} />
|
||||
Pending Invoices
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-stone-800">
|
||||
{formatCurrency(pendingBalance)}
|
||||
</p>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<button
|
||||
onClick={() => onNavigate("billing")}
|
||||
className="px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover)"
|
||||
>
|
||||
Pay Now
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useState } from "react";
|
||||
import { X, Save } from "lucide-react";
|
||||
import type { Pet } from "../mockData.js";
|
||||
|
||||
interface Props {
|
||||
pet?: Pet;
|
||||
onSave: (pet: Pet) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function PetForm({ pet, onSave, onCancel }: Props) {
|
||||
const [name, setName] = useState(pet?.name ?? "");
|
||||
const [breed, setBreed] = useState(pet?.breed ?? "");
|
||||
const [weight, setWeight] = useState(pet?.weight ?? 0);
|
||||
const [notes, setNotes] = useState(pet?.allergies ?? "");
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!pet) return;
|
||||
onSave({ ...pet, name, breed, weight, allergies: notes });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-stone-200 p-6 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-lg font-semibold text-stone-800">{pet ? "Edit Pet" : "Add Pet"}</h2>
|
||||
<button onClick={onCancel} className="p-2 hover:bg-stone-50 rounded-lg">
|
||||
<X size={16} className="text-stone-400" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-600 mb-1">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
className="w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-600 mb-1">Breed</label>
|
||||
<input
|
||||
type="text"
|
||||
value={breed}
|
||||
onChange={e => setBreed(e.target.value)}
|
||||
className="w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-600 mb-1">Weight (lbs)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={weight}
|
||||
onChange={e => setWeight(Number(e.target.value))}
|
||||
className="w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-600 mb-1">Notes</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="flex-1 px-4 py-2 border border-stone-200 rounded-lg text-sm text-stone-600 hover:bg-stone-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 flex items-center justify-center gap-1.5 px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover)"
|
||||
>
|
||||
<Save size={14} />
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
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 {
|
||||
appointments: Appointment[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
sessionId: string | null;
|
||||
readOnly: boolean;
|
||||
}
|
||||
|
||||
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({ sessionId, readOnly }: Props) {
|
||||
const [pets, setPets] = useState<Pet[]>([]);
|
||||
const [appointments, setAppointments] = useState<AppointmentsResponse>({ appointments: [] });
|
||||
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);
|
||||
|
||||
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.appointments.filter(a => a.pet?.id === selectedPetId && new Date(a.startTime) <= new Date());
|
||||
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 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 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.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>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Profile Header */}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 bg-white rounded-xl border border-stone-200 p-1 overflow-x-auto">
|
||||
{([
|
||||
{ id: "info", label: "Basic Info", icon: PawPrint },
|
||||
{ id: "medical", label: "Medical", icon: Heart },
|
||||
{ id: "grooming", label: "Grooming", icon: Scissors },
|
||||
{ id: "history", label: "History", icon: Clock },
|
||||
] as const).map(({ id, label, icon: Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => setActiveTab(id)}
|
||||
className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium whitespace-nowrap ${
|
||||
activeTab === id ? "bg-(--color-accent-light) text-(--color-accent-dark)" : "text-stone-500 hover:text-stone-700"
|
||||
}`}
|
||||
>
|
||||
<Icon size={14} />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center py-2.5 border-b border-stone-100 last:border-0">
|
||||
<span className="text-sm text-stone-500 sm:w-40 shrink-0">{label}</span>
|
||||
<span className="text-sm text-stone-800">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BasicInfoTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
|
||||
return (
|
||||
<div>
|
||||
<InfoRow label="Name" value={pet.name} />
|
||||
<InfoRow label="Breed" value={pet.breed || "Unknown"} />
|
||||
<InfoRow label="Weight" value={`${pet.weight} lbs`} />
|
||||
<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
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MedicalTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
|
||||
return (
|
||||
<div>
|
||||
<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.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GroomingTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
|
||||
return (
|
||||
<div>
|
||||
<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
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HistoryTab({ petHistory }: { petHistory: Appointment[] }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{petHistory.length === 0 ? (
|
||||
<p className="text-sm text-stone-400 text-center py-4">No history yet</p>
|
||||
) : (
|
||||
petHistory.map(appt => (
|
||||
<div key={appt.id} className="flex items-center gap-3 py-2 border-b border-stone-50 last:border-0">
|
||||
<div className="w-8 h-8 rounded-lg bg-stone-100 flex items-center justify-center text-xs text-stone-500">
|
||||
<Scissors size={14} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<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.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>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { useState, useEffect, useRef } 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" },
|
||||
anxious: { icon: Meh, label: "Anxious", color: "text-amber-700", bg: "bg-amber-100" },
|
||||
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;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
sessionId: string | null;
|
||||
}
|
||||
|
||||
export function ReportCards({ sessionId }: Props) {
|
||||
const [appointments, setAppointments] = useState<Appointment[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedCard, setSelectedCard] = useState<Appointment | null>(null);
|
||||
|
||||
const fetchReportCardsRef = useRef<() => Promise<void>>(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch("/api/portal/appointments", {
|
||||
headers: { "X-Impersonation-Session-Id": sessionId ?? "" },
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
void fetchReportCardsRef.current();
|
||||
}, [sessionId]);
|
||||
|
||||
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={() => { void fetchReportCardsRef.current(); }}
|
||||
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)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<p className="text-sm text-stone-500">Grooming report cards from your recent visits</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{appointments.map((card) => {
|
||||
const moodKey: MoodKey = "cooperative";
|
||||
const mood = MOOD_CONFIG[moodKey];
|
||||
const MoodIcon = mood.icon;
|
||||
return (
|
||||
<button
|
||||
key={card.id}
|
||||
onClick={() => setSelectedCard(card)}
|
||||
className="w-full bg-white rounded-2xl border border-stone-200 p-5 shadow-sm text-left hover:border-stone-300 transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-14 h-14 rounded-xl bg-(--color-accent-light) flex items-center justify-center text-(--color-accent)">
|
||||
<FileText size={24} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<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.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",
|
||||
})}
|
||||
</span>
|
||||
<span className={`flex items-center gap-1 text-xs px-2 py-0.5 rounded-full ${mood.bg} ${mood.color}`}>
|
||||
<MoodIcon size={12} />
|
||||
{mood.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<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 || "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",
|
||||
})}
|
||||
{card.groomerName ? ` · Groomer: ${card.groomerName}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Before & After */}
|
||||
<div>
|
||||
<h3 className="font-medium text-stone-800 mb-3">Before & After</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="rounded-xl bg-stone-50 p-4">
|
||||
<p className="text-xs font-medium text-stone-400 uppercase mb-2">Before</p>
|
||||
<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">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">After photo description not available.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Services */}
|
||||
<div>
|
||||
<h3 className="font-medium text-stone-800 mb-2">Services Performed</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="px-3 py-1 bg-stone-100 rounded-full text-sm text-stone-700">
|
||||
{card.serviceName || "Grooming"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Behavior */}
|
||||
<div>
|
||||
<h3 className="font-medium text-stone-800 mb-2">Behavior & Mood</h3>
|
||||
<div className={`inline-flex items-center gap-2 px-4 py-2 rounded-xl ${mood.bg}`}>
|
||||
<MoodIcon size={20} className={mood.color} />
|
||||
<span className={`font-medium ${mood.color}`}>{mood.label}</span>
|
||||
</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 || "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">Book your next visit</p>
|
||||
<p className="text-xs text-stone-500">Schedule your next grooming appointment</p>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user