feat: unify site theming via CSS custom properties (GH #91) #100

Merged
groombook-engineer[bot] merged 3 commits from feat/site-theming-unification-gh91 into main 2026-03-22 04:17:30 +00:00
19 changed files with 212 additions and 90 deletions
+12 -1
View File
@@ -1,4 +1,4 @@
import { createContext, useContext, useEffect, useState, useCallback } from "react";
import { createContext, useContext, useEffect, useRef, useState, useCallback } from "react";
export interface Branding {
businessName: string;
@@ -27,6 +27,7 @@ export function useBranding() {
export function BrandingProvider({ children }: { children: React.ReactNode }) {
const [branding, setBranding] = useState<Branding>(DEFAULT_BRANDING);
const metaThemeColorRef = useRef<HTMLMetaElement | null>(null);
const fetchBranding = useCallback(() => {
fetch("/api/branding")
@@ -45,6 +46,16 @@ export function BrandingProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
document.documentElement.style.setProperty("--color-primary", branding.primaryColor);
document.documentElement.style.setProperty("--color-accent", branding.accentColor);
// Keep PWA theme-color meta tag in sync with primary color
if (!metaThemeColorRef.current) {
metaThemeColorRef.current = document.querySelector<HTMLMetaElement>("meta[name='theme-color']");
if (!metaThemeColorRef.current) {
metaThemeColorRef.current = document.createElement("meta");
metaThemeColorRef.current.name = "theme-color";
document.head.appendChild(metaThemeColorRef.current);
}
}
metaThemeColorRef.current.content = branding.primaryColor;
}, [branding.primaryColor, branding.accentColor]);
return (
@@ -0,0 +1,106 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, waitFor } from "@testing-library/react";
import { BrandingProvider, useBranding } from "../BrandingContext.js";
function BrandingConsumer() {
const { branding } = useBranding();
return (
<div data-testid="branding">
<span data-testid="primary">{branding.primaryColor}</span>
<span data-testid="accent">{branding.accentColor}</span>
</div>
);
}
beforeEach(() => {
vi.restoreAllMocks();
document.documentElement.style.removeProperty("--color-primary");
document.documentElement.style.removeProperty("--color-accent");
// Remove any theme-color meta tags
document.querySelectorAll("meta[name='theme-color']").forEach((el) => el.remove());
});
describe("BrandingProvider", () => {
it("applies CSS vars to document root when branding loads", async () => {
const branding = {
businessName: "Test Salon",
primaryColor: "#123456",
accentColor: "#654321",
logoBase64: null,
logoMimeType: null,
};
global.fetch = vi.fn(() =>
Promise.resolve({ ok: true, json: async () => branding } as Response)
) as unknown as typeof fetch;
render(
<BrandingProvider>
<BrandingConsumer />
</BrandingProvider>
);
await waitFor(() => {
expect(document.documentElement.style.getPropertyValue("--color-primary")).toBe("#123456");
expect(document.documentElement.style.getPropertyValue("--color-accent")).toBe("#654321");
});
});
it("creates and updates meta[name=theme-color]", async () => {
const branding = {
businessName: "Test Salon",
primaryColor: "#abcdef",
accentColor: "#fedcba",
logoBase64: null,
logoMimeType: null,
};
global.fetch = vi.fn(() =>
Promise.resolve({ ok: true, json: async () => branding } as Response)
) as unknown as typeof fetch;
render(
<BrandingProvider>
<BrandingConsumer />
</BrandingProvider>
);
await waitFor(() => {
const meta = document.querySelector<HTMLMetaElement>("meta[name='theme-color']");
expect(meta).not.toBeNull();
expect(meta!.content).toBe("#abcdef");
});
});
it("does not create duplicate meta[name=theme-color] tags on rerender", async () => {
const branding = {
businessName: "Test Salon",
primaryColor: "#111111",
accentColor: "#222222",
logoBase64: null,
logoMimeType: null,
};
global.fetch = vi.fn(() =>
Promise.resolve({ ok: true, json: async () => branding } as Response)
) as unknown as typeof fetch;
const { rerender } = render(
<BrandingProvider>
<BrandingConsumer />
</BrandingProvider>
);
await waitFor(() => {
expect(document.querySelector("meta[name='theme-color']")).not.toBeNull();
});
rerender(
<BrandingProvider>
<BrandingConsumer />
</BrandingProvider>
);
await waitFor(() => {
const metas = document.querySelectorAll("meta[name='theme-color']");
expect(metas.length).toBe(1);
});
});
});
@@ -29,7 +29,7 @@ export function DevSessionIndicator() {
<Link
to="/login"
style={{
color: "#4f8a6f",
color: "var(--color-primary)",
textDecoration: "underline",
fontSize: 12,
}}
+7 -2
View File
@@ -2,7 +2,12 @@
:root {
--color-primary: #4f8a6f;
--color-primary-dark: color-mix(in srgb, var(--color-primary) 80%, #000);
--color-accent: #8b7355;
--color-accent-hover: color-mix(in srgb, var(--color-accent) 88%, #000);
--color-accent-dark: color-mix(in srgb, var(--color-accent) 78%, #000);
--color-accent-light: color-mix(in srgb, var(--color-accent) 18%, #fff);
--color-accent-lighter: color-mix(in srgb, var(--color-accent) 9%, #fff);
}
*, *::before, *::after {
@@ -51,8 +56,8 @@ button:active:not(:disabled) {
/* ─── Admin input / select focus states ─── */
input:focus, select:focus, textarea:focus {
outline: none;
border-color: #4f8a6f;
box-shadow: 0 0 0 3px rgba(79, 138, 111, 0.12);
border-color: var(--color-primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 12%, transparent);
}
/* ─── Admin card-like containers (borders get subtle shadow) ─── */
+3 -3
View File
@@ -291,7 +291,7 @@ export function AppointmentsPage() {
</button>
<button
onClick={() => openNewForm()}
style={{ ...btnStyle, backgroundColor: "#4f8a6f", color: "#fff", marginLeft: "auto", borderColor: "#4f8a6f" }}
style={{ ...btnStyle, backgroundColor: "var(--color-primary)", color: "#fff", marginLeft: "auto", borderColor: "var(--color-primary)" }}
>
+ New Appointment
</button>
@@ -374,7 +374,7 @@ export function AppointmentsPage() {
<div
style={{
padding: "0.4rem 0.6rem",
background: isToday ? "linear-gradient(135deg, #4f8a6f, #3d7a5f)" : "#f8fafc",
background: isToday ? "linear-gradient(135deg, var(--color-primary), var(--color-primary-dark))" : "#f8fafc",
color: isToday ? "#fff" : "#374151",
fontWeight: 600,
fontSize: 12,
@@ -594,7 +594,7 @@ export function AppointmentsPage() {
<button
type="submit"
disabled={saving}
style={{ ...btnStyle, backgroundColor: "#4f8a6f", color: "#fff", borderColor: "#4f8a6f" }}
style={{ ...btnStyle, backgroundColor: "var(--color-primary)", color: "#fff", borderColor: "var(--color-primary)" }}
>
{saving
? "Saving…"
+8 -8
View File
@@ -66,8 +66,8 @@ function StepIndicator({ step }: { step: number }) {
padding: "0.5rem 0.25rem",
fontSize: 12,
fontWeight: active ? 700 : 400,
color: active ? "#4f8a6f" : done ? "#4f8a6f" : "#9ca3af",
borderBottom: `3px solid ${active ? "#4f8a6f" : done ? "#4f8a6f" : "#e5e7eb"}`,
color: active ? "var(--color-primary)" : done ? "var(--color-primary)" : "#9ca3af",
borderBottom: `3px solid ${active ? "var(--color-primary)" : done ? "var(--color-primary)" : "#e5e7eb"}`,
}}
>
<span
@@ -78,7 +78,7 @@ function StepIndicator({ step }: { step: number }) {
width: 22,
height: 22,
borderRadius: "50%",
background: active ? "#4f8a6f" : done ? "#4f8a6f" : "#e5e7eb",
background: active ? "var(--color-primary)" : done ? "var(--color-primary)" : "#e5e7eb",
color: active || done ? "#fff" : "#6b7280",
fontSize: 12,
fontWeight: 700,
@@ -218,7 +218,7 @@ export function BookPage() {
const selectedCard: React.CSSProperties = {
...card,
border: "2px solid #4f8a6f",
border: "2px solid var(--color-primary)",
background: "#f0faf5",
};
@@ -250,7 +250,7 @@ export function BookPage() {
const primaryBtn: React.CSSProperties = {
...btn,
background: "#4f8a6f",
background: "var(--color-primary)",
color: "#fff",
};
@@ -301,7 +301,7 @@ export function BookPage() {
)}
</div>
<div style={{ textAlign: "right", flexShrink: 0, marginLeft: "1rem" }}>
<div style={{ fontWeight: 700, color: "#4f8a6f", fontSize: 15 }}>
<div style={{ fontWeight: 700, color: "var(--color-primary)", fontSize: 15 }}>
{fmtPrice(svc.basePriceCents)}
</div>
<div style={{ fontSize: 12, color: "#9ca3af" }}>{fmtDuration(svc.durationMinutes)}</div>
@@ -349,8 +349,8 @@ export function BookPage() {
style={{
padding: "0.4rem 0.85rem",
borderRadius: 6,
border: `2px solid ${selectedSlot === slot ? "#4f8a6f" : "#d1d5db"}`,
background: selectedSlot === slot ? "#4f8a6f" : "#fff",
border: `2px solid ${selectedSlot === slot ? "var(--color-primary)" : "#d1d5db"}`,
background: selectedSlot === slot ? "var(--color-primary)" : "#fff",
color: selectedSlot === slot ? "#fff" : "#374151",
fontSize: 13,
fontWeight: 500,
+3 -3
View File
@@ -367,7 +367,7 @@ export function ClientsPage() {
<h1 style={{ margin: 0, fontSize: 20 }}>Clients</h1>
<button
onClick={openNewClient}
style={{ ...btnStyle, backgroundColor: "#4f8a6f", color: "#fff", borderColor: "#4f8a6f", marginLeft: "auto", padding: "0.3rem 0.7rem" }}
style={{ ...btnStyle, backgroundColor: "var(--color-primary)", color: "#fff", borderColor: "var(--color-primary)", marginLeft: "auto", padding: "0.3rem 0.7rem" }}
>
+ New
</button>
@@ -622,7 +622,7 @@ export function ClientsPage() {
</Field>
{clientFormError && <p style={{ color: "red", margin: "0.5rem 0 0" }}>{clientFormError}</p>}
<div style={{ display: "flex", gap: "0.5rem", marginTop: "1rem" }}>
<button type="submit" disabled={savingClient} style={{ ...btnStyle, backgroundColor: "#4f8a6f", color: "#fff", borderColor: "#4f8a6f" }}>
<button type="submit" disabled={savingClient} style={{ ...btnStyle, backgroundColor: "var(--color-primary)", color: "#fff", borderColor: "var(--color-primary)" }}>
{savingClient ? "Saving…" : editingClient ? "Save Changes" : "Create Client"}
</button>
<button type="button" onClick={() => setShowClientForm(false)} style={btnStyle}>Cancel</button>
@@ -761,7 +761,7 @@ export function ClientsPage() {
</Field>
{logFormError && <p style={{ color: "red", margin: "0.5rem 0 0" }}>{logFormError}</p>}
<div style={{ display: "flex", gap: "0.5rem", marginTop: "1rem" }}>
<button type="submit" disabled={savingLog} style={{ ...btnStyle, backgroundColor: "#4f8a6f", color: "#fff", borderColor: "#4f8a6f" }}>
<button type="submit" disabled={savingLog} style={{ ...btnStyle, backgroundColor: "var(--color-primary)", color: "#fff", borderColor: "var(--color-primary)" }}>
{savingLog ? "Saving…" : "Save Visit Log"}
</button>
<button type="button" onClick={() => setShowLogForm(false)} style={btnStyle}>Cancel</button>
+2 -2
View File
@@ -287,7 +287,7 @@ function NewGroupBookingForm({
<button
type="submit"
disabled={saving}
style={{ ...btnStyle, backgroundColor: "#4f8a6f", color: "#fff", borderColor: "#4f8a6f" }}
style={{ ...btnStyle, backgroundColor: "var(--color-primary)", color: "#fff", borderColor: "var(--color-primary)" }}
>
{saving ? "Booking…" : "Create Group Booking"}
</button>
@@ -471,7 +471,7 @@ export function GroupBookingPage() {
</select>
<button
onClick={() => setShowCreate(true)}
style={{ ...btnStyle, marginLeft: "auto", backgroundColor: "#4f8a6f", color: "#fff", borderColor: "#4f8a6f" }}
style={{ ...btnStyle, marginLeft: "auto", backgroundColor: "var(--color-primary)", color: "#fff", borderColor: "var(--color-primary)" }}
>
+ New Group Booking
</button>
+2 -2
View File
@@ -129,7 +129,7 @@ function CreateFromAppointmentForm({
<button
type="submit"
disabled={saving || !selectedApptId}
style={{ ...btnStyle, backgroundColor: "#4f8a6f", color: "#fff", borderColor: "#4f8a6f" }}
style={{ ...btnStyle, backgroundColor: "var(--color-primary)", color: "#fff", borderColor: "var(--color-primary)" }}
>
{saving ? "Creating…" : "Create Invoice"}
</button>
@@ -540,7 +540,7 @@ export function InvoicesPage() {
</select>
<button
onClick={() => setShowCreate(true)}
style={{ ...btnStyle, backgroundColor: "#4f8a6f", color: "#fff", borderColor: "#4f8a6f", marginLeft: "auto" }}
style={{ ...btnStyle, backgroundColor: "var(--color-primary)", color: "#fff", borderColor: "var(--color-primary)", marginLeft: "auto" }}
>
+ Create Invoice
</button>
+1 -1
View File
@@ -270,7 +270,7 @@ export function ReportsPage() {
<option value="month">Month</option>
</select>
</label>
<button onClick={loadAll} style={{ ...btnStyle, background: "#4f8a6f", color: "#fff", borderColor: "#4f8a6f" }}>
<button onClick={loadAll} style={{ ...btnStyle, background: "var(--color-primary)", color: "#fff", borderColor: "var(--color-primary)" }}>
{loading ? "Loading…" : "Refresh"}
</button>
<div style={{ marginLeft: "auto", display: "flex", gap: "0.5rem" }}>
+2 -2
View File
@@ -119,7 +119,7 @@ export function ServicesPage() {
<h1 style={{ margin: 0 }}>Services</h1>
<button
onClick={openNew}
style={{ ...btnStyle, backgroundColor: "#4f8a6f", color: "#fff", borderColor: "#4f8a6f", marginLeft: "auto" }}
style={{ ...btnStyle, backgroundColor: "var(--color-primary)", color: "#fff", borderColor: "var(--color-primary)", marginLeft: "auto" }}
>
+ Add Service
</button>
@@ -232,7 +232,7 @@ export function ServicesPage() {
<button
type="submit"
disabled={saving}
style={{ ...btnStyle, backgroundColor: "#4f8a6f", color: "#fff", borderColor: "#4f8a6f" }}
style={{ ...btnStyle, backgroundColor: "var(--color-primary)", color: "#fff", borderColor: "var(--color-primary)" }}
>
{saving ? "Saving…" : editing ? "Save Changes" : "Create Service"}
</button>
+2 -2
View File
@@ -78,7 +78,7 @@ export function StaffPage() {
<div style={{ fontFamily: "system-ui, sans-serif" }}>
<div style={{ display: "flex", alignItems: "center", gap: "1rem", marginBottom: "1rem" }}>
<h1 style={{ margin: 0 }}>Staff</h1>
<button onClick={openNew} style={{ ...btnStyle, backgroundColor: "#4f8a6f", color: "#fff", borderColor: "#4f8a6f", marginLeft: "auto" }}>
<button onClick={openNew} style={{ ...btnStyle, backgroundColor: "var(--color-primary)", color: "#fff", borderColor: "var(--color-primary)", marginLeft: "auto" }}>
+ Add Staff
</button>
</div>
@@ -145,7 +145,7 @@ export function StaffPage() {
</div>
{formError && <p style={{ color: "red", margin: "0.5rem 0 0" }}>{formError}</p>}
<div style={{ display: "flex", gap: "0.5rem", marginTop: "1rem" }}>
<button type="submit" disabled={saving} style={{ ...btnStyle, backgroundColor: "#4f8a6f", color: "#fff", borderColor: "#4f8a6f" }}>
<button type="submit" disabled={saving} style={{ ...btnStyle, backgroundColor: "var(--color-primary)", color: "#fff", borderColor: "var(--color-primary)" }}>
{saving ? "Saving…" : editing ? "Save Changes" : "Add Staff"}
</button>
<button type="button" onClick={() => setShowForm(false)} style={btnStyle}>Cancel</button>
@@ -22,7 +22,7 @@ export function AccountSettings({ readOnly }: Props) {
key={id}
onClick={() => setTab(id)}
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium ${
tab === id ? "bg-[#f0ebe4] text-[#6b5a42]" : "text-stone-500 hover:bg-stone-50"
tab === id ? "bg-(--color-accent-light) text-(--color-accent-dark)" : "text-stone-500 hover:bg-stone-50"
}`}
>
<Icon size={14} />
@@ -69,7 +69,7 @@ function PersonalInfo({ readOnly }: { readOnly: boolean }) {
</div>
))}
{!readOnly && (
<button className="px-4 py-2 bg-[#8b7355] text-white rounded-lg text-sm font-medium hover:bg-[#7a6549]">
<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>
)}
@@ -103,7 +103,7 @@ function PasswordChange({ readOnly }: { readOnly: boolean }) {
<label className="block text-sm font-medium text-stone-700 mb-1">Confirm New Password</label>
<input type="password" className="w-full border border-stone-300 rounded-lg px-3 py-2 text-sm" />
</div>
<button className="px-4 py-2 bg-[#8b7355] text-white rounded-lg text-sm font-medium hover:bg-[#7a6549]">
<button className="px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover)">
Update Password
</button>
</div>
@@ -116,7 +116,7 @@ function ManagePets({ readOnly }: { readOnly: boolean }) {
<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-[#f0ebe4] flex items-center justify-center text-3xl">
<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">
@@ -136,7 +136,7 @@ function ManagePets({ readOnly }: { readOnly: boolean }) {
</div>
))}
{!readOnly && (
<button className="w-full flex items-center justify-center gap-2 py-3 border-2 border-dashed border-stone-300 rounded-2xl text-sm text-stone-500 hover:border-[#8b7355] hover:text-[#6b5a42] transition-colors">
<button className="w-full flex items-center justify-center gap-2 py-3 border-2 border-dashed border-stone-300 rounded-2xl text-sm text-stone-500 hover:border-(--color-accent) hover:text-(--color-accent-dark) transition-colors">
<Plus size={16} />
Add New Pet
</button>
@@ -165,7 +165,7 @@ function Agreements() {
{new Date(agr.dateSigned).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
</td>
<td className="px-5 py-3">
<button className="text-sm text-[#6b5a42] font-medium hover:underline">View</button>
<button className="text-sm text-(--color-accent-dark) font-medium hover:underline">View</button>
</td>
</tr>
))}
+17 -17
View File
@@ -30,13 +30,13 @@ export function AppointmentsSection({ readOnly }: Props) {
<div className="flex gap-2">
<button
onClick={() => setTab("upcoming")}
className={`px-4 py-2 rounded-lg text-sm font-medium ${tab === "upcoming" ? "bg-[#f0ebe4] text-[#6b5a42]" : "text-stone-500 hover:bg-stone-50"}`}
className={`px-4 py-2 rounded-lg text-sm font-medium ${tab === "upcoming" ? "bg-(--color-accent-light) text-(--color-accent-dark)" : "text-stone-500 hover:bg-stone-50"}`}
>
Upcoming ({UPCOMING_APPOINTMENTS.length})
</button>
<button
onClick={() => setTab("past")}
className={`px-4 py-2 rounded-lg text-sm font-medium ${tab === "past" ? "bg-[#f0ebe4] text-[#6b5a42]" : "text-stone-500 hover:bg-stone-50"}`}
className={`px-4 py-2 rounded-lg text-sm font-medium ${tab === "past" ? "bg-(--color-accent-light) text-(--color-accent-dark)" : "text-stone-500 hover:bg-stone-50"}`}
>
Past ({PAST_APPOINTMENTS.length})
</button>
@@ -44,7 +44,7 @@ export function AppointmentsSection({ readOnly }: Props) {
{!readOnly && (
<button
onClick={() => setShowBooking(true)}
className="flex items-center gap-1.5 px-4 py-2 bg-[#8b7355] text-white rounded-lg text-sm font-medium hover:bg-[#7a6549]"
className="flex items-center gap-1.5 px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover)"
>
<Plus size={16} />
Book New
@@ -101,7 +101,7 @@ function AppointmentCard({
return (
<div className="bg-white rounded-xl border border-stone-200 shadow-sm overflow-hidden">
<button onClick={onToggle} className="w-full flex items-center gap-4 p-4 text-left hover:bg-stone-50">
<div className="w-10 h-10 rounded-lg bg-[#f0ebe4] flex items-center justify-center text-lg shrink-0">
<div className="w-10 h-10 rounded-lg bg-(--color-accent-light) flex items-center justify-center text-lg shrink-0">
{PETS.find(p => p.id === appt.petId)?.photo || "🐾"}
</div>
<div className="flex-1 min-w-0">
@@ -150,7 +150,7 @@ function AppointmentCard({
)}
{appt.reportCardId && (
<div className="mt-2">
<span className="text-xs text-[#6b5a42] font-medium cursor-pointer hover:underline">
<span className="text-xs text-(--color-accent-dark) font-medium cursor-pointer hover:underline">
View Report Card
</span>
</div>
@@ -190,7 +190,7 @@ function BookingFlow({ onClose, readOnly }: { onClose: () => void; readOnly: boo
{/* Step Indicator */}
<div className="flex items-center gap-1 px-5 pt-4">
{[1, 2, 3, 4, 5].map(s => (
<div key={s} className={`flex-1 h-1.5 rounded-full ${s <= step ? "bg-[#8b7355]" : "bg-stone-200"}`} />
<div key={s} className={`flex-1 h-1.5 rounded-full ${s <= step ? "bg-(--color-accent)" : "bg-stone-200"}`} />
))}
</div>
@@ -202,7 +202,7 @@ function BookingFlow({ onClose, readOnly }: { onClose: () => void; readOnly: boo
<p className="text-sm text-stone-500 mb-4">
{selectedPet?.name} with {selectedGroomer?.name || "First Available"} on {formatDate(selectedDate)} at {selectedTime}
</p>
<button onClick={onClose} className="px-4 py-2 bg-[#8b7355] text-white rounded-lg text-sm font-medium">
<button onClick={onClose} className="px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium">
Done
</button>
</div>
@@ -218,7 +218,7 @@ function BookingFlow({ onClose, readOnly }: { onClose: () => void; readOnly: boo
key={pet.id}
onClick={() => { setSelectedPet(pet); setStep(2); }}
className={`w-full flex items-center gap-3 p-3 rounded-xl border text-left transition-colors ${
selectedPet?.id === pet.id ? "border-[#8b7355] bg-[#faf5ef]" : "border-stone-200 hover:border-stone-300"
selectedPet?.id === pet.id ? "border-(--color-accent) bg-(--color-accent-lighter)" : "border-stone-200 hover:border-stone-300"
}`}
>
<span className="text-2xl">{pet.photo}</span>
@@ -246,7 +246,7 @@ function BookingFlow({ onClose, readOnly }: { onClose: () => void; readOnly: boo
);
}}
className={`w-full flex items-center justify-between p-3 rounded-xl border text-left ${
selectedServices.find(s => s.id === svc.id) ? "border-[#8b7355] bg-[#faf5ef]" : "border-stone-200 hover:border-stone-300"
selectedServices.find(s => s.id === svc.id) ? "border-(--color-accent) bg-(--color-accent-lighter)" : "border-stone-200 hover:border-stone-300"
}`}
>
<div>
@@ -273,7 +273,7 @@ function BookingFlow({ onClose, readOnly }: { onClose: () => void; readOnly: boo
);
}}
className={`w-full flex items-center justify-between p-2.5 rounded-lg border text-left text-sm ${
selectedAddOns.find(s => s.id === svc.id) ? "border-[#8b7355] bg-[#faf5ef]" : "border-stone-200 hover:border-stone-300"
selectedAddOns.find(s => s.id === svc.id) ? "border-(--color-accent) bg-(--color-accent-lighter)" : "border-stone-200 hover:border-stone-300"
}`}
>
<div>
@@ -291,7 +291,7 @@ function BookingFlow({ onClose, readOnly }: { onClose: () => void; readOnly: boo
<button
onClick={() => setStep(3)}
disabled={selectedServices.length === 0}
className="flex-1 px-4 py-2 bg-[#8b7355] text-white rounded-lg text-sm font-medium disabled:opacity-50"
className="flex-1 px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium disabled:opacity-50"
>
Next
</button>
@@ -307,7 +307,7 @@ function BookingFlow({ onClose, readOnly }: { onClose: () => void; readOnly: boo
<button
onClick={() => { setSelectedGroomer(null); setStep(4); }}
className={`w-full flex items-center gap-3 p-3 rounded-xl border text-left ${
selectedGroomer === null ? "border-[#8b7355] bg-[#faf5ef]" : "border-stone-200 hover:border-stone-300"
selectedGroomer === null ? "border-(--color-accent) bg-(--color-accent-lighter)" : "border-stone-200 hover:border-stone-300"
}`}
>
<div className="w-10 h-10 rounded-full bg-stone-100 flex items-center justify-center">
@@ -323,10 +323,10 @@ function BookingFlow({ onClose, readOnly }: { onClose: () => void; readOnly: boo
key={g.id}
onClick={() => { setSelectedGroomer(g); setStep(4); }}
className={`w-full flex items-center gap-3 p-3 rounded-xl border text-left ${
selectedGroomer?.id === g.id ? "border-[#8b7355] bg-[#faf5ef]" : "border-stone-200 hover:border-stone-300"
selectedGroomer?.id === g.id ? "border-(--color-accent) bg-(--color-accent-lighter)" : "border-stone-200 hover:border-stone-300"
}`}
>
<div className="w-10 h-10 rounded-full bg-[#f0ebe4] flex items-center justify-center text-xl">
<div className="w-10 h-10 rounded-full bg-(--color-accent-light) flex items-center justify-center text-xl">
{g.avatar}
</div>
<div>
@@ -358,7 +358,7 @@ function BookingFlow({ onClose, readOnly }: { onClose: () => void; readOnly: boo
key={time}
onClick={() => setSelectedTime(time)}
className={`px-3 py-2 rounded-lg text-sm border ${
selectedTime === time ? "border-[#8b7355] bg-[#faf5ef] font-medium" : "border-stone-200 hover:border-stone-300"
selectedTime === time ? "border-(--color-accent) bg-(--color-accent-lighter) font-medium" : "border-stone-200 hover:border-stone-300"
}`}
>
{time}
@@ -387,7 +387,7 @@ function BookingFlow({ onClose, readOnly }: { onClose: () => void; readOnly: boo
<button
onClick={() => setStep(5)}
disabled={!selectedDate || !selectedTime}
className="flex-1 px-4 py-2 bg-[#8b7355] text-white rounded-lg text-sm font-medium disabled:opacity-50"
className="flex-1 px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium disabled:opacity-50"
>
Next
</button>
@@ -426,7 +426,7 @@ function BookingFlow({ onClose, readOnly }: { onClose: () => void; readOnly: boo
<button onClick={() => setStep(4)} className="flex-1 px-4 py-2 border border-stone-200 rounded-lg text-sm">Back</button>
<button
onClick={() => setConfirmed(true)}
className="flex-1 px-4 py-2 bg-[#8b7355] text-white rounded-lg text-sm font-medium hover:bg-[#7a6549]"
className="flex-1 px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover)"
>
Confirm Booking
</button>
@@ -38,7 +38,7 @@ export function BillingPayments({ readOnly }: Props) {
>
Add Tip
</button>
<button className="px-6 py-2 bg-[#8b7355] text-white rounded-lg text-sm font-medium hover:bg-[#7a6549]">
<button className="px-6 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover)">
Pay Now
</button>
</div>
@@ -57,7 +57,7 @@ export function BillingPayments({ readOnly }: Props) {
key={id}
onClick={() => setTab(id)}
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium ${
tab === id ? "bg-[#f0ebe4] text-[#6b5a42]" : "text-stone-500 hover:bg-stone-50"
tab === id ? "bg-(--color-accent-light) text-(--color-accent-dark)" : "text-stone-500 hover:bg-stone-50"
}`}
>
<Icon size={14} />
@@ -134,7 +134,7 @@ export function BillingPayments({ readOnly }: Props) {
</div>
))}
{!readOnly && (
<button className="flex items-center gap-2 text-sm text-[#6b5a42] font-medium hover:underline mt-2">
<button className="flex items-center gap-2 text-sm text-(--color-accent-dark) font-medium hover:underline mt-2">
<Plus size={14} />
Add Payment Method
</button>
@@ -145,8 +145,8 @@ export function BillingPayments({ readOnly }: Props) {
<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-[#f0ebe4] flex items-center justify-center">
<Zap size={18} className="text-[#8b7355]" />
<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>
@@ -156,7 +156,7 @@ export function BillingPayments({ readOnly }: Props) {
{!readOnly ? (
<button
onClick={() => setAutopay(!autopay)}
className={`w-12 h-6 rounded-full transition-colors ${autopay ? "bg-[#8b7355]" : "bg-stone-300"}`}
className={`w-12 h-6 rounded-full transition-colors ${autopay ? "bg-(--color-accent)" : "bg-stone-300"}`}
>
<div className={`w-5 h-5 bg-white rounded-full shadow transition-transform ${autopay ? "translate-x-6" : "translate-x-0.5"}`} />
</button>
@@ -174,7 +174,7 @@ export function BillingPayments({ readOnly }: Props) {
{PREPAID_PACKAGES.map(pkg => (
<div key={pkg.id} className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
<div className="flex items-center gap-3 mb-3">
<Package size={20} className="text-[#8b7355]" />
<Package size={20} className="text-(--color-accent)" />
<h3 className="font-medium text-stone-800">{pkg.name}</h3>
</div>
<div className="flex items-center gap-4 mb-3">
@@ -184,7 +184,7 @@ export function BillingPayments({ readOnly }: Props) {
</div>
<div className="flex-1 bg-stone-100 rounded-full h-3 overflow-hidden">
<div
className="bg-[#8b7355] h-full rounded-full"
className="bg-(--color-accent) h-full rounded-full"
style={{ width: `${((pkg.totalCredits - pkg.usedCredits) / pkg.totalCredits) * 100}%` }}
/>
</div>
@@ -218,7 +218,7 @@ function TipModal({ onClose }: { onClose: () => void }) {
key={pct}
onClick={() => { setTipPercent(pct); setCustomTip(""); }}
className={`flex-1 py-2 rounded-lg text-sm font-medium border ${
tipPercent === pct ? "border-[#8b7355] bg-[#faf5ef] text-[#6b5a42]" : "border-stone-200 text-stone-600"
tipPercent === pct ? "border-(--color-accent) bg-(--color-accent-lighter) text-(--color-accent-dark)" : "border-stone-200 text-stone-600"
}`}
>
{pct}%
@@ -227,7 +227,7 @@ function TipModal({ onClose }: { onClose: () => void }) {
<button
onClick={() => { setTipPercent(null); }}
className={`flex-1 py-2 rounded-lg text-sm font-medium border ${
tipPercent === null ? "border-[#8b7355] bg-[#faf5ef] text-[#6b5a42]" : "border-stone-200 text-stone-600"
tipPercent === null ? "border-(--color-accent) bg-(--color-accent-lighter) text-(--color-accent-dark)" : "border-stone-200 text-stone-600"
}`}
>
Custom
@@ -244,7 +244,7 @@ function TipModal({ onClose }: { onClose: () => void }) {
)}
<div className="flex gap-2">
<button onClick={onClose} className="flex-1 px-4 py-2 border border-stone-200 rounded-lg text-sm">Cancel</button>
<button onClick={onClose} className="flex-1 px-4 py-2 bg-[#8b7355] text-white rounded-lg text-sm font-medium">Add Tip</button>
<button onClick={onClose} className="flex-1 px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium">Add Tip</button>
</div>
</div>
</div>
@@ -16,7 +16,7 @@ export function Communication({ readOnly }: Props) {
<button
onClick={() => setTab("messages")}
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium ${
tab === "messages" ? "bg-[#f0ebe4] text-[#6b5a42]" : "text-stone-500 hover:bg-stone-50"
tab === "messages" ? "bg-(--color-accent-light) text-(--color-accent-dark)" : "text-stone-500 hover:bg-stone-50"
}`}
>
Messages
@@ -24,7 +24,7 @@ export function Communication({ readOnly }: Props) {
<button
onClick={() => setTab("notifications")}
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium ${
tab === "notifications" ? "bg-[#f0ebe4] text-[#6b5a42]" : "text-stone-500 hover:bg-stone-50"
tab === "notifications" ? "bg-(--color-accent-light) text-(--color-accent-dark)" : "text-stone-500 hover:bg-stone-50"
}`}
>
<Bell size={14} />
@@ -68,7 +68,7 @@ function MessageThread({ readOnly }: { readOnly: boolean }) {
<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-[#8b7355] text-white rounded-br-md"
? "bg-(--color-accent) text-white rounded-br-md"
: "bg-stone-100 text-stone-800 rounded-bl-md"
}`}>
<p className="text-sm">{msg.text}</p>
@@ -95,12 +95,12 @@ function MessageThread({ readOnly }: { readOnly: boolean }) {
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-[#8b7355]/30 focus:border-[#8b7355]"
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-[#8b7355] text-white rounded-lg hover:bg-[#7a6549] disabled:opacity-50"
className="px-4 py-2 bg-(--color-accent) text-white rounded-lg hover:bg-(--color-accent-hover) disabled:opacity-50"
>
<Send size={16} />
</button>
@@ -177,7 +177,7 @@ function NotificationPreferences({ readOnly }: { readOnly: boolean }) {
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-[#8b7355]" : "bg-stone-300"
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 ${
+8 -8
View File
@@ -42,7 +42,7 @@ export function Dashboard({ onNavigate, readOnly }: Props) {
{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-[#6b5a42]">
<div className="flex items-center gap-2 text-sm font-medium text-(--color-accent-dark)">
<Calendar size={16} />
Next Appointment
</div>
@@ -71,7 +71,7 @@ export function Dashboard({ onNavigate, readOnly }: Props) {
</div>
</div>
<div className="text-center sm:text-right">
<div className="text-3xl font-bold text-[#6b5a42]">{daysUntil(nextAppt.date)}</div>
<div className="text-3xl font-bold text-(--color-accent-dark)">{daysUntil(nextAppt.date)}</div>
<div className="text-xs text-stone-500">days away</div>
</div>
</div>
@@ -103,7 +103,7 @@ export function Dashboard({ onNavigate, readOnly }: Props) {
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-[#f0ebe4] flex items-center justify-center text-2xl">
<div className="w-12 h-12 rounded-full bg-(--color-accent-light) flex items-center justify-center text-2xl">
{pet.photo}
</div>
<div>
@@ -128,14 +128,14 @@ export function Dashboard({ onNavigate, readOnly }: Props) {
{/* Loyalty Card */}
<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-[#6b5a42] mb-3">
<div className="flex items-center gap-2 text-sm font-medium text-(--color-accent-dark) mb-3">
<Star size={16} />
Loyalty Rewards
</div>
<p className="text-2xl font-bold text-stone-800">{LOYALTY.points} <span className="text-sm font-normal text-stone-500">pts</span></p>
<div className="mt-2 bg-stone-100 rounded-full h-2 overflow-hidden">
<div
className="bg-[#8b7355] h-full rounded-full transition-all"
className="bg-(--color-accent) h-full rounded-full transition-all"
style={{ width: `${(LOYALTY.points / LOYALTY.nextRewardAt) * 100}%` }}
/>
</div>
@@ -161,7 +161,7 @@ export function Dashboard({ onNavigate, readOnly }: Props) {
{!readOnly && (
<button
onClick={() => onNavigate("billing")}
className="px-4 py-2 bg-[#8b7355] text-white rounded-lg text-sm font-medium hover:bg-[#7a6549]"
className="px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover)"
>
Pay Now
</button>
@@ -176,7 +176,7 @@ export function Dashboard({ onNavigate, readOnly }: Props) {
<div className="space-y-2.5">
{recentEvents.map(evt => (
<div key={evt.id} className="flex items-center gap-3 text-sm">
<div className={`w-2 h-2 rounded-full shrink-0 ${evt.type === "payment" ? "bg-green-400" : "bg-[#8b7355]"}`} />
<div className={`w-2 h-2 rounded-full shrink-0 ${evt.type === "payment" ? "bg-green-400" : "bg-(--color-accent)"}`} />
<span className="text-stone-600 flex-1">{evt.text}</span>
<span className="text-xs text-stone-400">{formatDate(evt.date)}</span>
</div>
@@ -184,7 +184,7 @@ export function Dashboard({ onNavigate, readOnly }: Props) {
</div>
<button
onClick={() => onNavigate("appointments")}
className="flex items-center gap-1 text-sm text-[#6b5a42] font-medium mt-3 hover:text-[#8b7355]"
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>
+7 -7
View File
@@ -30,7 +30,7 @@ export function PetProfiles({ readOnly }: Props) {
key={p.id}
onClick={() => { setSelectedPetId(p.id); setActiveTab("info"); }}
className={`flex items-center gap-3 px-4 py-3 rounded-xl border transition-colors ${
p.id === selectedPetId ? "border-[#8b7355] bg-[#faf5ef]" : "border-stone-200 bg-white hover:border-stone-300"
p.id === selectedPetId ? "border-(--color-accent) bg-(--color-accent-lighter)" : "border-stone-200 bg-white hover:border-stone-300"
}`}
>
<span className="text-2xl">{p.photo}</span>
@@ -45,7 +45,7 @@ export function PetProfiles({ readOnly }: Props) {
{/* Profile Header */}
<div className="bg-white rounded-2xl border border-stone-200 p-5 shadow-sm">
<div className="flex items-center gap-4">
<div className="w-20 h-20 rounded-2xl bg-[#f0ebe4] flex items-center justify-center text-4xl">
<div className="w-20 h-20 rounded-2xl bg-(--color-accent-light) flex items-center justify-center text-4xl">
{pet.photo}
</div>
<div className="flex-1">
@@ -74,7 +74,7 @@ export function PetProfiles({ readOnly }: Props) {
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-[#f0ebe4] text-[#6b5a42]" : "text-stone-500 hover:text-stone-700"
activeTab === id ? "bg-(--color-accent-light) text-(--color-accent-dark)" : "text-stone-500 hover:text-stone-700"
}`}
>
<Icon size={14} />
@@ -114,7 +114,7 @@ function BasicInfoTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
<InfoRow label="Sex" value={pet.sex === "male" ? "Male" : "Female"} />
<InfoRow label="Spayed/Neutered" value={pet.spayedNeutered ? "Yes" : "No"} />
{!readOnly && (
<button className="mt-4 text-sm text-[#6b5a42] font-medium hover:underline">
<button className="mt-4 text-sm text-(--color-accent-dark) font-medium hover:underline">
Upload Photo
</button>
)}
@@ -148,7 +148,7 @@ function GroomingTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
<InfoRow label="Sensitive Areas" value={pet.sensitiveAreas} />
<InfoRow label="Standing Instructions" value={pet.standingInstructions} />
{!readOnly && (
<button className="mt-4 text-sm text-[#6b5a42] font-medium hover:underline">
<button className="mt-4 text-sm text-(--color-accent-dark) font-medium hover:underline">
Upload Reference Photo
</button>
)}
@@ -189,7 +189,7 @@ function VaccinationsTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
{vax.documentUploaded ? (
<span className="text-green-600 text-xs">Uploaded</span>
) : !readOnly ? (
<button className="flex items-center gap-1 text-xs text-[#6b5a42] hover:underline">
<button className="flex items-center gap-1 text-xs text-(--color-accent-dark) hover:underline">
<Upload size={12} />
Upload
</button>
@@ -226,7 +226,7 @@ function HistoryTab({ petHistory }: { petHistory: typeof PAST_APPOINTMENTS }) {
{new Date(appt.date).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
</span>
{appt.reportCardId && (
<span className="text-xs text-[#6b5a42] font-medium">Report </span>
<span className="text-xs text-(--color-accent-dark) font-medium">Report </span>
)}
</div>
))
+8 -8
View File
@@ -33,7 +33,7 @@ export function ReportCards() {
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-[#f0ebe4] flex items-center justify-center text-[#8b7355]">
<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">
@@ -70,13 +70,13 @@ function ReportCardDetail({ card, onBack }: { card: ReportCard; onBack: () => vo
return (
<div className="space-y-6">
<button onClick={onBack} className="text-sm text-[#6b5a42] font-medium hover:underline">
<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-[#f0ebe4] to-[#e8e0d5] p-6">
<div className="bg-gradient-to-r from-(--color-accent-lighter) to-(--color-accent-light) p-6">
<div className="flex items-center justify-between mb-1">
<h2 className="text-xl font-semibold text-stone-800">{card.petName}'s Grooming Report</h2>
<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">
@@ -101,9 +101,9 @@ function ReportCardDetail({ card, onBack }: { card: ReportCard; onBack: () => vo
</div>
<p className="text-sm text-stone-600">{card.beforeDescription}</p>
</div>
<div className="rounded-xl bg-[#faf5ef] p-4">
<p className="text-xs font-medium text-[#8b7355] uppercase mb-2">After</p>
<div className="w-full h-32 bg-[#f0ebe4] rounded-lg flex items-center justify-center text-[#8b7355] text-sm mb-2">
<div className="rounded-xl bg-(--color-accent-lighter) p-4">
<p className="text-xs font-medium text-(--color-accent) uppercase mb-2">After</p>
<div className="w-full h-32 bg-(--color-accent-light) rounded-lg flex items-center justify-center text-(--color-accent) text-sm mb-2">
Photo placeholder
</div>
<p className="text-sm text-stone-700">{card.afterDescription}</p>
@@ -148,7 +148,7 @@ function ReportCardDetail({ card, onBack }: { card: ReportCard; onBack: () => vo
)}
{/* Groomer's Note */}
<div className="bg-[#faf5ef] rounded-xl p-4">
<div className="bg-(--color-accent-lighter) rounded-xl p-4">
<h3 className="font-medium text-stone-800 mb-2">A Note from {card.groomerName}</h3>
<p className="text-sm text-stone-700 italic leading-relaxed">"{card.groomerNote}"</p>
</div>
@@ -161,7 +161,7 @@ function ReportCardDetail({ card, onBack }: { card: ReportCard; onBack: () => vo
{new Date(card.nextRecommendedDate).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}
</p>
</div>
<button className="px-4 py-2 bg-[#8b7355] text-white rounded-lg text-sm font-medium hover:bg-[#7a6549]">
<button 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>