fix(auth): resolve redirect loop and mount Better-Auth as sub-app (#144)
## Changes - Replace toNodeHandler with auth.handler(c.req.raw) sub-app mount for Hono compatibility - Add /api/auth/ path skip in authMiddleware and resolveStaffMiddleware - Add OIDC_INTERNAL_BASE env var for split-horizon (hairpin NAT) URL resolution - Replace render-time signIn.social() with LoginPage component (fixes redirect loop) - Change auth-client baseURL to relative (empty string) for deployed environments - Add POST /api/portal/appointments/:id/reschedule endpoint with session auth - Add RescheduleFlow modal, PetForm component, and wire Dashboard/Appointments UI ## CTO Note Auth fix is P0-critical. Portal mock data (UAT blocker) predates this PR and is tracked separately in GRO-218. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit was merged in pull request #144.
This commit is contained in:
committed by
GitHub
parent
3a31ad71c2
commit
6872342d8f
+57
-3
@@ -19,6 +19,61 @@ 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" },
|
||||
{ to: "/admin/clients", label: "Clients" },
|
||||
@@ -170,10 +225,9 @@ export function App() {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
// Production mode: if no session, redirect to Authentik sign-in
|
||||
// Production mode: if no session, show login page (avoids redirect loops)
|
||||
if (!authDisabled && !session) {
|
||||
signIn.social({ provider: "authentik" });
|
||||
return null;
|
||||
return <LoginPage />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: import.meta.env.VITE_API_URL ?? "http://localhost:3000",
|
||||
baseURL: import.meta.env.VITE_API_URL ?? "",
|
||||
});
|
||||
|
||||
export const { signIn, signOut, useSession } = authClient;
|
||||
@@ -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";
|
||||
@@ -33,6 +33,8 @@ 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 { branding } = useBranding();
|
||||
@@ -107,12 +109,17 @@ export function CustomerPortal() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleReschedule = useCallback((appointment: Record<string, unknown>) => {
|
||||
setRescheduleAppointment(appointment);
|
||||
setShowReschedule(true);
|
||||
}, []);
|
||||
|
||||
const isReadOnly = session?.status === "active";
|
||||
|
||||
const renderSection = () => {
|
||||
switch (activeSection) {
|
||||
case "dashboard":
|
||||
return <Dashboard onNavigate={handleNavClick} readOnly={!!isReadOnly} />;
|
||||
return <Dashboard onNavigate={handleNavClick} readOnly={!!isReadOnly} onReschedule={handleReschedule} />;
|
||||
case "appointments":
|
||||
return <AppointmentsSection readOnly={!!isReadOnly} sessionId={session?.id ?? null} />;
|
||||
case "pets":
|
||||
@@ -158,6 +165,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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } 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 {
|
||||
readOnly: boolean;
|
||||
@@ -112,6 +113,20 @@ function PasswordChange({ readOnly }: { readOnly: boolean }) {
|
||||
}
|
||||
|
||||
function ManagePets({ readOnly }: { readOnly: boolean }) {
|
||||
const [editingPetId, setEditingPetId] = useState<string | null>(null);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const editingPet = editingPetId ? PETS.find(p => p.id === editingPetId) ?? undefined : undefined;
|
||||
|
||||
if (editingPet || showAddForm) {
|
||||
return (
|
||||
<PetForm
|
||||
pet={editingPet ?? undefined}
|
||||
onSave={() => { setEditingPetId(null); setShowAddForm(false); }}
|
||||
onCancel={() => { setEditingPetId(null); setShowAddForm(false); }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{PETS.map(pet => (
|
||||
@@ -126,17 +141,12 @@ function ManagePets({ readOnly }: { readOnly: boolean }) {
|
||||
{!readOnly && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
disabled
|
||||
title="Pet editing coming soon"
|
||||
className="px-3 py-1.5 border border-stone-200 rounded-lg text-xs text-stone-400 cursor-not-allowed"
|
||||
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
|
||||
disabled
|
||||
title="Pet archiving coming soon"
|
||||
className="p-1.5 border border-stone-200 rounded-lg text-stone-300 cursor-not-allowed"
|
||||
>
|
||||
<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>
|
||||
@@ -145,9 +155,8 @@ function ManagePets({ readOnly }: { readOnly: boolean }) {
|
||||
))}
|
||||
{!readOnly && (
|
||||
<button
|
||||
disabled
|
||||
title="Adding pets coming soon"
|
||||
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-400 cursor-not-allowed"
|
||||
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
|
||||
|
||||
@@ -49,6 +49,8 @@ const CONFIRMATION_STATUS_COLORS: Record<string, string> = {
|
||||
|
||||
export function AppointmentsSection({ readOnly, sessionId }: Props) {
|
||||
const [showBooking, setShowBooking] = useState(false);
|
||||
const [showReschedule, setShowReschedule] = useState(false);
|
||||
const [rescheduleAppointment, setRescheduleAppointment] = useState<Appointment | null>(null);
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [tab, setTab] = useState<"upcoming" | "past">("upcoming");
|
||||
|
||||
@@ -90,6 +92,7 @@ export function AppointmentsSection({ readOnly, sessionId }: Props) {
|
||||
onToggle={() => setExpandedId(expandedId === appt.id ? null : appt.id)}
|
||||
readOnly={readOnly}
|
||||
sessionId={sessionId}
|
||||
onReschedule={(appt) => { setRescheduleAppointment(appt); setShowReschedule(true); }}
|
||||
/>
|
||||
))}
|
||||
{UPCOMING_APPOINTMENTS.length === 0 && (
|
||||
@@ -108,6 +111,7 @@ export function AppointmentsSection({ readOnly, sessionId }: Props) {
|
||||
onToggle={() => setExpandedId(expandedId === appt.id ? null : appt.id)}
|
||||
readOnly={readOnly}
|
||||
sessionId={sessionId}
|
||||
onReschedule={(appt) => { setRescheduleAppointment(appt); setShowReschedule(true); }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -119,14 +123,21 @@ export function AppointmentsSection({ readOnly, sessionId }: Props) {
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
)}
|
||||
{showReschedule && rescheduleAppointment && (
|
||||
<RescheduleFlow
|
||||
appointment={rescheduleAppointment}
|
||||
onClose={() => { setShowReschedule(false); setRescheduleAppointment(null); }}
|
||||
sessionId={sessionId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AppointmentCard({
|
||||
appointment: appt, expanded, onToggle, readOnly, sessionId,
|
||||
appointment: appt, expanded, onToggle, readOnly, sessionId, onReschedule,
|
||||
}: {
|
||||
appointment: Appointment; expanded: boolean; onToggle: () => void; readOnly: boolean; sessionId?: string | null;
|
||||
appointment: Appointment; expanded: boolean; onToggle: () => void; readOnly: boolean; sessionId?: string | null; onReschedule: (appt: Appointment) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-stone-200 shadow-sm overflow-hidden">
|
||||
@@ -176,11 +187,7 @@ function AppointmentCard({
|
||||
)}
|
||||
{appt.status !== "completed" && appt.status !== "cancelled" && !readOnly && (
|
||||
<div className="flex gap-2 mt-3">
|
||||
<button
|
||||
disabled
|
||||
title="Rescheduling coming soon"
|
||||
className="text-xs px-3 py-1.5 border border-stone-200 rounded-lg text-stone-400 cursor-not-allowed"
|
||||
>
|
||||
<button onClick={() => onReschedule(appt)} className="text-xs px-3 py-1.5 border border-stone-200 rounded-lg text-stone-600 hover:bg-stone-50">
|
||||
Reschedule
|
||||
</button>
|
||||
<CancelAppointmentButton appointment={appt} sessionId={sessionId} />
|
||||
@@ -376,6 +383,133 @@ export function CustomerNotesSection({ appointment: appt, sessionId }: { appoint
|
||||
);
|
||||
}
|
||||
|
||||
export function RescheduleFlow({
|
||||
appointment: appt,
|
||||
onClose,
|
||||
sessionId,
|
||||
}: {
|
||||
appointment: Appointment;
|
||||
onClose: () => void;
|
||||
sessionId?: string | null;
|
||||
}) {
|
||||
const [selectedDate, setSelectedDate] = useState("");
|
||||
const [selectedTime, setSelectedTime] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const availableTimes = ["9:00 AM", "10:00 AM", "11:00 AM", "1:00 PM", "2:00 PM", "3:00 PM", "4:00 PM"];
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!selectedDate || !selectedTime) return;
|
||||
|
||||
const [hoursMinutes = "", period = ""] = selectedTime.split(" ");
|
||||
const [hoursStr = "0", minutesStr = "0"] = hoursMinutes.split(":");
|
||||
let hours = parseInt(hoursStr, 10);
|
||||
const minutes = parseInt(minutesStr ?? "0", 10);
|
||||
if (period === "PM" && hours !== 12) hours += 12;
|
||||
if (period === "AM" && hours === 12) hours = 0;
|
||||
const isoTime = `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:00`;
|
||||
const startTime = new Date(`${selectedDate}T${isoTime}`).toISOString();
|
||||
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (sessionId) headers["X-Impersonation-Session-Id"] = sessionId;
|
||||
const res = await fetch(`/api/portal/appointments/${appt.id}/reschedule`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ startTime }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: "Failed to reschedule" }));
|
||||
throw new Error(err.error || `HTTP ${res.status}`);
|
||||
}
|
||||
setSuccess(true);
|
||||
setTimeout(() => { window.location.reload(); }, 1500);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to reschedule");
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
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-lg w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between p-5 border-b border-stone-200">
|
||||
<h2 className="font-semibold text-stone-800">Reschedule Appointment</h2>
|
||||
<button onClick={onClose} className="text-stone-400 hover:text-stone-600">✕</button>
|
||||
</div>
|
||||
|
||||
<div className="p-5">
|
||||
{success ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="text-4xl mb-3">✅</div>
|
||||
<h3 className="text-lg font-semibold text-stone-800 mb-1">Appointment Rescheduled!</h3>
|
||||
<p className="text-sm text-stone-500">Redirecting...</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Current appointment summary */}
|
||||
<div className="bg-stone-50 rounded-xl p-4 mb-4 text-sm">
|
||||
<p className="font-medium text-stone-800">{appt.petName} — {appt.services.join(", ")}</p>
|
||||
<p className="text-stone-500 mt-0.5">
|
||||
{formatDate(appt.date)} at {appt.time} with {appt.groomerName}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h3 className="font-medium text-stone-800 mb-3">Pick a New Date & Time</h3>
|
||||
<input
|
||||
type="date"
|
||||
value={selectedDate}
|
||||
onChange={e => setSelectedDate(e.target.value)}
|
||||
min={new Date().toISOString().split("T")[0]}
|
||||
className="w-full border border-stone-300 rounded-lg px-3 py-2 text-sm mb-3"
|
||||
/>
|
||||
{selectedDate && (
|
||||
<div className="grid grid-cols-3 gap-2 mb-4">
|
||||
{availableTimes.map(time => (
|
||||
<button
|
||||
key={time}
|
||||
onClick={() => setSelectedTime(time)}
|
||||
className={`px-3 py-2 rounded-lg text-sm border ${
|
||||
selectedTime === time
|
||||
? "border-(--color-accent) bg-(--color-accent-light) font-medium"
|
||||
: "border-stone-200 hover:border-stone-300"
|
||||
}`}
|
||||
>
|
||||
{time}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-red-500 mb-3">{error}</p>}
|
||||
|
||||
<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={handleSubmit}
|
||||
disabled={!selectedDate || !selectedTime || submitting}
|
||||
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"
|
||||
>
|
||||
{submitting ? "Rescheduling..." : "Confirm Reschedule"}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BookingFlow({ onClose, readOnly }: { onClose: () => void; readOnly: boolean }) {
|
||||
const [step, setStep] = useState(1);
|
||||
const [selectedPet, setSelectedPet] = useState<Pet | null>(null);
|
||||
|
||||
@@ -4,6 +4,8 @@ import { PETS, UPCOMING_APPOINTMENTS, PAST_APPOINTMENTS, INVOICES, LOYALTY, BUSI
|
||||
interface Props {
|
||||
onNavigate: (section: "appointments" | "pets" | "billing" | "reports") => void;
|
||||
readOnly: boolean;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onReschedule?: (appointment: any) => void;
|
||||
}
|
||||
|
||||
function daysUntil(dateStr: string): number {
|
||||
@@ -18,7 +20,7 @@ function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
export function Dashboard({ onNavigate, readOnly }: Props) {
|
||||
export function Dashboard({ onNavigate, readOnly, onReschedule }: Props) {
|
||||
const nextAppt = UPCOMING_APPOINTMENTS[0];
|
||||
const outstanding = INVOICES.filter(i => i.status === "outstanding").reduce((sum, i) => sum + i.amount, 0);
|
||||
const recentEvents = [
|
||||
@@ -78,24 +80,15 @@ export function Dashboard({ onNavigate, readOnly }: Props) {
|
||||
{!readOnly && (
|
||||
<div className="flex gap-2 mt-4">
|
||||
<button
|
||||
disabled
|
||||
title="Rescheduling coming soon"
|
||||
className="text-sm px-3 py-1.5 border border-stone-200 rounded-lg text-stone-400 cursor-not-allowed"
|
||||
onClick={() => onReschedule?.(nextAppt)}
|
||||
className="text-sm px-3 py-1.5 border border-stone-200 rounded-lg text-stone-600 hover:bg-stone-50"
|
||||
>
|
||||
Reschedule
|
||||
</button>
|
||||
<button
|
||||
disabled
|
||||
title="Cancellation coming soon"
|
||||
className="text-sm px-3 py-1.5 border border-stone-200 rounded-lg text-stone-400 cursor-not-allowed"
|
||||
>
|
||||
<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
|
||||
disabled
|
||||
title="Notes coming soon"
|
||||
className="text-sm px-3 py-1.5 border border-stone-200 rounded-lg text-stone-400 cursor-not-allowed"
|
||||
>
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ 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 { PetForm } from "./PetForm.js";
|
||||
|
||||
interface Props {
|
||||
readOnly: boolean;
|
||||
@@ -17,9 +18,21 @@ const VAX_STATUS_STYLES: Record<VaxStatus, { bg: string; text: string; icon: typ
|
||||
export function PetProfiles({ readOnly }: Props) {
|
||||
const [selectedPetId, setSelectedPetId] = useState<string>(PETS[0]?.id ?? "");
|
||||
const [activeTab, setActiveTab] = useState<"info" | "medical" | "grooming" | "vaccinations" | "history">("info");
|
||||
const [editingPetId, setEditingPetId] = useState<string | null>(null);
|
||||
|
||||
const pet = PETS.find(p => p.id === selectedPetId)!;
|
||||
const petHistory = PAST_APPOINTMENTS.filter(a => a.petId === selectedPetId);
|
||||
const editingPet = editingPetId ? PETS.find(p => p.id === editingPetId) ?? undefined : undefined;
|
||||
|
||||
if (editingPet) {
|
||||
return (
|
||||
<PetForm
|
||||
pet={editingPet}
|
||||
onSave={() => setEditingPetId(null)}
|
||||
onCancel={() => setEditingPetId(null)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -54,8 +67,8 @@ export function PetProfiles({ readOnly }: Props) {
|
||||
<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 disabled title="Pet editing coming soon" className="p-2 rounded-lg cursor-not-allowed">
|
||||
<Edit3 size={16} className="text-stone-300" />
|
||||
<button onClick={() => setEditingPetId(pet.id)} className="p-2 hover:bg-stone-50 rounded-lg">
|
||||
<Edit3 size={16} className="text-stone-400" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user