diff --git a/.claude/skills/running-pawbook/SKILL.md b/.claude/skills/running-pawbook/SKILL.md new file mode 100644 index 0000000..4580533 --- /dev/null +++ b/.claude/skills/running-pawbook/SKILL.md @@ -0,0 +1,45 @@ +--- +name: running-pawbook +description: Launch and drive Pawbook locally (wrangler dev on :8787) without sending real email — seed D1, safe env overrides, demo logins for the embed widget and admin dashboard. Use when asked to run, demo, screenshot, or manually verify the app. +--- + +# Running Pawbook locally + +Cloudflare Worker (Hono) + D1 + three Vite bundles (embed widget, admin dashboard, demo host). Verified cold-start from a fresh worktree on 2026-07-07. + +## Launch + +```bash +npm install # if node_modules is missing/stale +npm run seed:local # applies sql/schema.sql + sql/seed.sql to local D1 (fresh state) +npm run build # build the Vite bundles into dist/ +npx wrangler dev --var ENVIRONMENT:development --var RESEND_API_KEY: --var RESEND_FROM: +``` + +Server: **http://localhost:8787** (landing → /admin, /demo; widget at /embed/:slug). + +**Why the `--var` overrides (do not skip):** `.dev.vars` contains a REAL `RESEND_API_KEY`, so with it active the widget's identify flow sends actual email — and the seeded addresses (`@example.com`, `.test`) are undeliverable, which 502s the login step. Blanking `RESEND_API_KEY`/`RESEND_FROM` makes `isEmailConfigured()` false, and `ENVIRONMENT=development` then shows the 6-digit login code ON SCREEN (see `server/routes/auth.ts`). Never edit or overwrite `.dev.vars` itself — `TOKEN_SECRET` must keep coming from it or every API route 503s. + +`npm run dev` also works (build --watch + wrangler dev) but reads `.dev.vars` as-is → real email mode. Prefer the explicit `wrangler dev --var` line above for demos. + +## Drive it + +**Embed widget** — `http://localhost:8787/embed/sunny-paws`: + +- Sign in as `jess@example.com` → the code appears on-screen in dev mode. (If email is live instead, read the code from D1: `npx wrangler d1 execute pawbook-db --local --command "SELECT Code FROM LoginCodes ORDER BY rowid DESC LIMIT 1"`.) +- Walks has a windowed option ("Morning Walk · 11:00–14:00") — good for exercising slot capacity; a full day renders struck-through in the month grid. +- Book: pick service → duration/option → date → pet (Bella/Mochi) → Check availability → Send request (creates a `pending` booking). + +**Admin dashboard** — `http://localhost:8787/admin`: + +- Login `admin@sunnypaws.example` / `demo1234` (committed demo seed; second tenant: `dana@happytails.test` / `demo1234`, slug `happy-tails`). +- Bookings section (first in nav): pending rows get Confirm/Decline, confirmed rows get Cancel. Cancelling frees the day in the widget's month grid. + +**Demo host page** — `http://localhost:8787/demo` shows two tenants' widgets embedded via `public/embed.js`. + +## Gotchas + +- Local D1/KV state lives under `.wrangler/` per checkout — a fresh worktree has none until `seed:local` runs. +- Re-running `seed:local` resets all data (INSERT OR REPLACE seed; schema is IF NOT EXISTS). +- Do NOT use `npm run migrate:*` against existing DBs without the baselining procedure in `migrations/README.md` (migration 0002 is destructive on re-run). +- Widget auth tokens are per-slug in sessionStorage; admin token in localStorage — a stale admin session survives reloads via `GET /api/admin/session`. diff --git a/app/admin/App.tsx b/app/admin/App.tsx index 8c3003a..7498c7c 100644 --- a/app/admin/App.tsx +++ b/app/admin/App.tsx @@ -2,6 +2,7 @@ import { type ReactNode, useCallback, useEffect, useLayoutEffect, useState } fro import { adminApi, isAuthExpired, type Customer } from '../shared-ui/api.js'; import { IconCalendar, + IconClipboardCheck, IconCode, IconPaw, IconPlug, @@ -10,14 +11,23 @@ import { IconUsers, } from '../shared-ui/icons'; import { AppsSection } from './sections/AppsSection'; +import { BookingsSection } from './sections/BookingsSection'; import { BusinessSection } from './sections/BusinessSection'; import { ClientsSection } from './sections/ClientsSection'; import { EmbedSection } from './sections/EmbedSection'; import { PetsSection } from './sections/PetsSection'; import { ServicesSection } from './sections/ServicesSection'; import { TimeOffSection } from './sections/TimeOffSection'; -import { adminFetch, type Session, type Settings } from './shared.js'; +import { + adminFetch, + type ServiceOptionForm, + type ServicePayload, + type Session, + type Settings, + type SettingsPayload, +} from './shared.js'; import './admin.css'; +import { useAsync } from '../shared-ui/useAsync'; /** * Sitter dashboard. Auth is email + password → an admin session token, held in localStorage @@ -109,30 +119,31 @@ function Login({ onLogin }: { onLogin: (s: Session) => void }) { {busy ? 'Signing in…' : 'Sign in'} {error &&

{error}

} -

- Demo logins are in the app's DEMO_NOTES.md. -

); } -type SectionKey = 'business' | 'pets' | 'services' | 'timeoff' | 'clients' | 'apps' | 'embed'; +type SectionKey = + 'bookings' | 'business' | 'pets' | 'services' | 'timeoff' | 'clients' | 'apps' | 'embed'; const SECTIONS: { key: SectionKey; label: string; icon: typeof IconStore }[] = [ + { key: 'bookings', label: 'Bookings', icon: IconClipboardCheck }, { key: 'business', label: 'Business', icon: IconStore }, - { key: 'pets', label: 'Pets', icon: IconPaw }, + { key: 'pets', label: 'Pet types', icon: IconPaw }, { key: 'services', label: 'Services & rates', icon: IconTag }, { key: 'timeoff', label: 'Time off', icon: IconCalendar }, { key: 'clients', label: 'Clients', icon: IconUsers }, { key: 'apps', label: 'Connected apps', icon: IconPlug }, - { key: 'embed', label: 'Embed', icon: IconCode }, + { key: 'embed', label: 'Your website', icon: IconCode }, ]; /** Reads the initial section from the URL hash (e.g. `/admin#clients`) so deep links and page * refreshes land on the right section, same as the old anchor-nav did. */ function sectionFromHash(): SectionKey { const hash = window.location.hash.slice(1); - return SECTIONS.some((s) => s.key === hash) ? (hash as SectionKey) : 'business'; + // Default to Bookings — the sitter's morning question is "what needs my reply?", + // not their own settings. + return SECTIONS.some((s) => s.key === hash) ? (hash as SectionKey) : 'bookings'; } function Dashboard({ session, onSignOut }: { session: Session; onSignOut: () => void }) { @@ -142,8 +153,6 @@ function Dashboard({ session, onSignOut }: { session: Session; onSignOut: () => // decide whether the sticky save bar shows. Only the settings PUT is deferred — the // other sections apply immediately and refresh both state and snapshot together. const [savedSnapshot, setSavedSnapshot] = useState(''); - const [blockStart, setBlockStart] = useState(''); - const [blockEnd, setBlockEnd] = useState(''); const [message, setMessage] = useState(''); const [error, setError] = useState(''); // Bumped after a successful save so the embed preview remounts and pulls the fresh config. @@ -175,7 +184,12 @@ function Dashboard({ session, onSignOut }: { session: Session; onSignOut: () => // Keeps the active section in sync with browser back/forward through the hash history // entries that switching sections now creates. useEffect(() => { - const onHashChange = () => setActiveSection(sectionFromHash()); + const onHashChange = () => { + setActiveSection(sectionFromHash()); + // An error banner describes the action just attempted; carrying it into another + // section reads as a live, unexplained failure there. + setError(''); + }; window.addEventListener('hashchange', onHashChange); return () => window.removeEventListener('hashchange', onHashChange); }, []); @@ -183,7 +197,7 @@ function Dashboard({ session, onSignOut }: { session: Session; onSignOut: () => // The saved confirmation is transient; errors stay until resolved. useEffect(() => { if (!message) return; - const timer = window.setTimeout(() => setMessage(''), 4000); + const timer = window.setTimeout(() => setMessage(''), 10000); return () => window.clearTimeout(timer); }, [message]); @@ -221,70 +235,49 @@ function Dashboard({ session, onSignOut }: { session: Session; onSignOut: () => run(async () => { if (!settings) return; setMessage(''); + // Explicit per-field object literals (rather than spreading `s`/`o`) checked against + // `ServicePayload`/`ServiceOptionForm` — annotated so a field added to the shared + // option/question/constraint shapes fails to compile here instead of quietly not + // reaching the wire (see e.g. the startTime/endTime/capacity fields). + const payload: SettingsPayload = { + displayName: settings.displayName, + accentColor: settings.accentColor, + maxBoardingPets: settings.maxBoardingPets, + maxHouseSitsPerDay: settings.maxHouseSitsPerDay, + maxStayNights: settings.maxStayNights, + timezone: settings.timezone, + contactEmail: settings.contactEmail, + contactPhone: settings.contactPhone, + petTypes: settings.petTypes.filter((p) => p.enabled).map((p) => p.petType), + services: settings.services.map((s): ServicePayload => ({ + type: s.type, + enabled: s.enabled, + options: s.options.map((o): ServiceOptionForm => ({ + optionKey: o.optionKey, + label: o.label, + durationMinutes: s.hasDuration ? o.durationMinutes : null, + rate: o.rate, + startTime: o.startTime, + endTime: o.endTime, + capacity: o.capacity, + })), + questions: s.questions, + minNights: s.minNights, + maxNights: s.maxNights, + minPetCount: s.minPetCount, + maxPetCount: s.maxPetCount, + })), + }; await adminFetch(token, `/api/${slug}/admin/settings`, { method: 'PUT', - body: JSON.stringify({ - displayName: settings.displayName, - accentColor: settings.accentColor, - maxBoardingPets: settings.maxBoardingPets, - maxHouseSitsPerDay: settings.maxHouseSitsPerDay, - maxStayNights: settings.maxStayNights, - timezone: settings.timezone, - petTypes: settings.petTypes.filter((p) => p.enabled).map((p) => p.petType), - services: settings.services.map((s) => ({ - type: s.type, - enabled: s.enabled, - options: s.options.map((o) => ({ - optionKey: o.optionKey, - label: o.label, - durationMinutes: s.hasDuration ? o.durationMinutes : null, - rate: o.rate, - startTime: o.startTime, - endTime: o.endTime, - capacity: o.capacity, - })), - questions: s.questions, - minNights: s.minNights, - maxNights: s.maxNights, - minPetCount: s.minPetCount, - maxPetCount: s.maxPetCount, - })), - }), + body: JSON.stringify(payload), }); setMessage('Saved! Your widget updates on its next load.'); setSavedSnapshot(JSON.stringify(settings)); setPreviewKey((k) => k + 1); }); - const addBlock = () => - run(async () => { - await adminFetch(token, `/api/${slug}/admin/blocked`, { - method: 'POST', - body: JSON.stringify({ startDate: blockStart, endDate: blockEnd }), - }); - setBlockStart(''); - setBlockEnd(''); - await refresh(); - }); - - const removeBlock = (id: string) => - run(async () => { - await adminFetch(token, `/api/${slug}/admin/blocked/${id}`, { method: 'DELETE' }); - await refresh(); - }); - - const connect = (capability: string) => - run(async () => { - await adminFetch(token, `/api/${slug}/admin/providers/${capability}/connect`, { - method: 'POST', - }); - await refresh(); - }); - const connectCalendar = () => - // NOTE: The route and this client call are still calendar-specific because there is exactly one - // OAuth provider. When a second is added, generalize adminApi.calendar + the - // /providers/calendar/... routes to accept a `capability` param. Deliberate YAGNI boundary. run(async () => { const { url } = await adminApi.calendar.start(slug, token); const popup = window.open(url, 'pawbook-gcal', 'width=520,height=640'); @@ -304,50 +297,19 @@ function Dashboard({ session, onSignOut }: { session: Session; onSignOut: () => await refresh(); }); - const [customers, setCustomers] = useState([]); - const [custEmail, setCustEmail] = useState(''); - const [custName, setCustName] = useState(''); - - const loadCustomers = useCallback( - () => adminApi.customers.list(slug, token).then(({ customers: list }) => list), - [slug, token], - ); - - useEffect(() => { - let active = true; - loadCustomers() - .then((list) => { - if (active) setCustomers(list); - }) - .catch((e) => { - if (active) handle(e); - }); - return () => { - active = false; - }; - }, [loadCustomers, handle]); - - const withCustomerRefresh = (fn: () => Promise) => - run(async () => { - await fn(); - setCustomers(await loadCustomers()); - }); - - const addCustomer = () => - withCustomerRefresh(async () => { - await adminApi.customers.add(slug, token, custEmail.trim().toLowerCase(), custName.trim()); - setCustEmail(''); - setCustName(''); - }); - - const removeCustomer = (id: string) => - withCustomerRefresh(() => adminApi.customers.remove(slug, token, id)); - - const addPet = (endUserId: string, name: string, petType: string) => - withCustomerRefresh(() => adminApi.customers.addPet(slug, token, endUserId, name, petType)); + const loadCustomers = useCallback(async (): Promise => { + try { + const { customers: list } = await adminApi.customers.list(slug, token); + return list; + } catch (e) { + // Route through the shared handler (error banner / sign-out on expired auth), but still + // reject so useAsync keeps the last-known list instead of blanking it under the banner. + handle(e); + throw e; + } + }, [slug, token, handle]); - const removePet = (endUserId: string, petId: string) => - withCustomerRefresh(() => adminApi.customers.removePet(slug, token, endUserId, petId)); + const { data: customers, reload: reloadCustomers } = useAsync(loadCustomers); // Initial settings load: setState only inside the promise callback (react-hooks rule). useEffect(() => { @@ -365,40 +327,38 @@ function Dashboard({ session, onSignOut }: { session: Session; onSignOut: () => const enabledPetTypes = settings.petTypes.filter((p) => p.enabled).map((p) => p.petType); const panels: Record = { + bookings: ( + setError('')} /> + ), business: , pets: , services: , timeoff: ( setError('')} /> ), clients: ( setError('')} /> ), apps: ( void refresh()} diff --git a/app/admin/admin.css b/app/admin/admin.css index 3a0bf49..10d1612 100644 --- a/app/admin/admin.css +++ b/app/admin/admin.css @@ -361,6 +361,13 @@ body { border-color: color-mix(in srgb, var(--warn) 25%, transparent); } +/* Cancelled (a real booking, killed) reads stronger than declined (never accepted). */ +.pb-chip-bad { + color: var(--bad); + background: color-mix(in srgb, var(--bad) 10%, transparent); + border-color: color-mix(in srgb, var(--bad) 25%, transparent); +} + /* Add-a-customer / block-a-range input rows. */ .pb-row { display: flex; diff --git a/app/admin/sections/AppsSection.tsx b/app/admin/sections/AppsSection.tsx index 91aecb1..e5a5f30 100644 --- a/app/admin/sections/AppsSection.tsx +++ b/app/admin/sections/AppsSection.tsx @@ -61,63 +61,50 @@ function CalendarIdField({ } export function AppsSection({ - providers, + calendar, slug, token, - connect, connectCalendar, disconnectCalendar, onCalendarSaved, handleError, }: { - providers: Settings['providers']; + calendar: Settings['calendar']; slug: string; token: string; - connect: (capability: string) => Promise; connectCalendar: () => Promise; disconnectCalendar: () => Promise; onCalendarSaved: () => void; handleError: (e: unknown) => void; }) { + const connected = calendar.status === 'connected'; return ( <>

Connected apps

    - {providers.map((p) => ( -
  • - {p.label}{' '} - - {p.status === 'connected' ? 'Connected' : 'Not connected'} - {' '} - {p.authMode === 'oauth' ? ( - p.status === 'connected' ? ( - <> - - - - ) : ( - - ) - ) : ( - p.status === 'disconnected' && ( - - ) - )} -
  • - ))} +
  • + Google Calendar{' '} + + {connected ? 'Connected' : 'Not connected'} + {' '} + {connected ? ( + <> + + + + ) : ( + + )} +
-

- Google Calendar is fully connected; the others are previews for now. -

); } diff --git a/app/admin/sections/BookingsSection.tsx b/app/admin/sections/BookingsSection.tsx new file mode 100644 index 0000000..2a85805 --- /dev/null +++ b/app/admin/sections/BookingsSection.tsx @@ -0,0 +1,155 @@ +import { useEffect, useState } from 'react'; +import { adminApi, type AdminBooking } from '../../shared-ui/api.js'; +import { IconClipboardCheck } from '../../shared-ui/icons'; +import type { Session } from '../shared.js'; + +/** Renders the dates for one row: single date (+ time, for timed services) or a range. */ +function formatWhen(b: AdminBooking): string { + const range = b.endDate ? `${b.startDate} – ${b.endDate}` : b.startDate; + return b.startTime ? `${range} at ${b.startTime}` : range; +} + +const byStartDate = (a: AdminBooking, b: AdminBooking) => a.startDate.localeCompare(b.startDate); + +function chipClass(status: string): string { + if (status === 'confirmed') return ' pb-chip-ok'; + if (status === 'cancelled') return ' pb-chip-bad'; + if (status === 'declined') return ' pb-chip-warn'; + return ''; +} + +export function BookingsSection({ + session, + handleError, + clearError, +}: { + session: Session; + handleError: (e: unknown) => void; + clearError: () => void; +}) { + const [bookings, setBookings] = useState(null); + const [busyId, setBusyId] = useState(null); + const [message, setMessage] = useState(''); + + const load = () => + adminApi.bookings.list(session.slug, session.token).then(({ bookings: list }) => list); + + useEffect(() => { + let active = true; + load() + .then((list) => active && setBookings(list)) + .catch((e) => active && handleError(e)); + return () => { + active = false; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [session]); + + const setStatus = async (b: AdminBooking, status: 'confirmed' | 'declined' | 'cancelled') => { + if (busyId) return; + if ( + status === 'cancelled' && + !window.confirm( + `Cancel ${b.customerName || b.customerEmail || 'this client'}'s ${b.type} booking (${formatWhen(b)})? This can't be undone.`, + ) + ) + return; + clearError(); + setMessage(''); + setBusyId(b.id); + try { + const { notified } = await adminApi.bookings.setStatus( + session.slug, + session.token, + b.id, + status, + ); + const who = b.customerName || b.customerEmail || 'the client'; + const verb = + status === 'confirmed' ? 'Confirmed' : status === 'declined' ? 'Declined' : 'Cancelled'; + setMessage( + `${verb} ${who}'s ${b.type} ${status === 'cancelled' ? 'booking' : 'request'}. ` + + (notified + ? `We emailed ${who} the update.` + : `${who} couldn't be emailed automatically (email sending isn't set up), so let them know directly.`), + ); + setBookings(await load()); + } catch (e) { + handleError(e); + } finally { + setBusyId(null); + } + }; + + const actionsFor = (b: AdminBooking) => ( + + {b.status === 'pending' && ( + <> + + + + )} + {b.status === 'confirmed' && ( + + )} + + ); + + const row = (b: AdminBooking) => ( +
  • + + {b.customerName || b.customerEmail || 'Unknown customer'} — {b.type} +
    + {formatWhen(b)} · {b.petCount} pet{b.petCount === 1 ? '' : 's'} + {b.estCost != null ? ` · $${b.estCost}` : ''}{' '} + {b.status} +
    + {actionsFor(b)} +
  • + ); + + const pending = (bookings ?? []).filter((b) => b.status === 'pending').sort(byStartDate); + const rest = (bookings ?? []).filter((b) => b.status !== 'pending').sort(byStartDate); + + return ( + <> +

    + Bookings +

    + {/* Fixed to the viewport bottom (reusing the save bar's styling) so it can't scroll out + of view or slide under the sticky header — it carries the "was the client told?" info. */} + {message && ( +
    +

    {message}

    + +
    + )} + {bookings === null ? ( +

    Loading…

    + ) : bookings.length === 0 ? ( +

    No bookings yet.

    + ) : ( + <> +

    + {pending.length === 0 + ? 'No requests waiting for a reply' + : `Needs your reply (${pending.length})`} +

    + {pending.length > 0 &&
      {pending.map(row)}
    } + {rest.length > 0 && ( + <> +

    Everything else

    +
      {rest.map(row)}
    + + )} + + )} + + ); +} diff --git a/app/admin/sections/BusinessSection.tsx b/app/admin/sections/BusinessSection.tsx index d4b94d0..d4c7d3a 100644 --- a/app/admin/sections/BusinessSection.tsx +++ b/app/admin/sections/BusinessSection.tsx @@ -1,6 +1,7 @@ import { DEFAULT_TIMEZONE } from '../../../src/shared/index.js'; import { IconStore } from '../../shared-ui/icons'; import type { SettingsSectionProps } from '../shared.js'; +import { NullableNumberField } from './fields.js'; const TIMEZONES: string[] = typeof Intl.supportedValuesOf === 'function' @@ -17,29 +18,6 @@ const TIMEZONES: string[] = 'Australia/Sydney', ]; -/** A nullable capacity/limit input: blank ⇒ null (no limit), a number ⇒ that value. */ -export function NullableNumberField({ - label, - value, - onChange, -}: { - label: string; - value: number | null; - onChange: (value: number | null) => void; -}) { - return ( - - ); -} - export function BusinessSection({ settings, setSettings }: SettingsSectionProps) { return ( <> @@ -61,6 +39,25 @@ export function BusinessSection({ settings, setSettings }: SettingsSectionProps) onChange={(e) => setSettings({ ...settings, accentColor: e.target.value })} /> + + +

    Shown to your clients on the booking page so they can reach you.

    void; + slug: string; + token: string; + onAdded: () => void; + onError: (e: unknown) => void; + clearError: () => void; }) { const [name, setName] = useState(''); const [petType, setPetType] = useState(enabledPetTypes[0]); + const [notes, setNotes] = useState(''); + const [busy, setBusy] = useState(false); + + const add = async () => { + if (!name.trim() || busy) return; + clearError(); + setBusy(true); + try { + await adminApi.customers.addPet(slug, token, customer.id, name.trim(), petType, notes.trim()); + setName(''); + setNotes(''); + onAdded(); + } catch (e) { + onError(e); + } finally { + setBusy(false); + } + }; + return (
    setName(e.target.value)} /> @@ -23,15 +51,13 @@ function PetAdder({ ))} -
    ); @@ -39,27 +65,62 @@ function PetAdder({ export function ClientsSection({ customers, - custEmail, - custName, - setCustEmail, - setCustName, - addCustomer, - removeCustomer, - addPet, - removePet, enabledPetTypes, + slug, + token, + onCustomersChanged, + handleError, + clearError, }: { customers: Customer[]; - custEmail: string; - custName: string; - setCustEmail: (value: string) => void; - setCustName: (value: string) => void; - addCustomer: () => Promise; - removeCustomer: (id: string) => Promise; - addPet: (endUserId: string, name: string, petType: string) => Promise; - removePet: (endUserId: string, petId: string) => Promise; enabledPetTypes: string[]; + slug: string; + token: string; + onCustomersChanged: () => void; + handleError: (e: unknown) => void; + clearError: () => void; }) { + const [custEmail, setCustEmail] = useState(''); + const [custName, setCustName] = useState(''); + const [custPhone, setCustPhone] = useState(''); + const [busy, setBusy] = useState(false); + + /** Matches the old Dashboard run() semantics: clear the error banner at the START of each + * action (so a stale error from an earlier failure doesn't outlive a later action), run the + * mutation, refresh the list on success, and route failures through the shared handler. */ + const mutate = async (fn: () => Promise) => { + if (busy) return; + clearError(); + setBusy(true); + try { + await fn(); + onCustomersChanged(); + } catch (e) { + handleError(e); + } finally { + setBusy(false); + } + }; + + const addCustomer = () => + mutate(async () => { + await adminApi.customers.add( + slug, + token, + custEmail.trim().toLowerCase(), + custName.trim(), + custPhone.trim(), + ); + setCustEmail(''); + setCustName(''); + setCustPhone(''); + }); + + const removeCustomer = (id: string) => mutate(() => adminApi.customers.remove(slug, token, id)); + + const removePet = (endUserId: string, petId: string) => + mutate(() => adminApi.customers.removePet(slug, token, endUserId, petId)); + return ( <>

    @@ -81,7 +142,15 @@ export function ClientsSection({ value={custName} onChange={(e) => setCustName(e.target.value)} /> - + setCustPhone(e.target.value)} + /> +
      {customers.map((cust) => ( @@ -89,25 +158,39 @@ export function ClientsSection({
      {cust.email} - {cust.name ? ` (${cust.name})` : ''}{' '} + {cust.name ? ` (${cust.name})` : ''} + {cust.phone ? ` · ${cust.phone}` : ''}{' '} {cust.status.charAt(0).toUpperCase() + cust.status.slice(1)} - +
        {cust.pets.map((p) => (
      • {p.name} {p.petType} - + {p.notes ? — {p.notes} : null} +
      • ))}
      {enabledPetTypes.length > 0 && ( - + )} ))} diff --git a/app/admin/sections/EmbedSection.tsx b/app/admin/sections/EmbedSection.tsx index 9aa25a6..b96f1ce 100644 --- a/app/admin/sections/EmbedSection.tsx +++ b/app/admin/sections/EmbedSection.tsx @@ -1,7 +1,28 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useState } from 'react'; import { IconCode } from '../../shared-ui/icons'; import { adminFetch, type Session } from '../shared.js'; +function CopyableSnippet({ value }: { value: string }) { + const [copied, setCopied] = useState(false); + const copy = async () => { + try { + await navigator.clipboard.writeText(value); + setCopied(true); + window.setTimeout(() => setCopied(false), 5000); + } catch { + /* clipboard denied — the textarea still selects on focus for manual copy */ + } + }; + return ( +
      +