From 4d2decfa812067dc9eb0d573b10642a147d5b791 Mon Sep 17 00:00:00 2001 From: Brad Burch Date: Tue, 7 Jul 2026 22:11:43 -0400 Subject: [PATCH] Make the admin dashboard usable by non-technical sitters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes from two rounds of simulated non-technical-user testing (setup and daily-operations personas), re-verified against the fixed build: - Time off: the UI now treats the typed range as inclusive ("First/Last day off, both days are included") and converts to the DB's exclusive convention at the boundary — blocking Aug 10-17 no longer silently leaves Aug 17 bookable. - Service options: duration-derived option keys auto-dedupe server-side, so two same-length options ("30 minutes" and "Puppy Check-in") save fine; only genuinely duplicate names error, and the message says so. - Booking triage: dashboard lands on Bookings with a "Needs your reply (N)" group sorted by date; confirm/decline/cancel show a fixed bottom status bar including whether the client was emailed; cancelling a confirmed booking asks first; declined requests are stored (Declined flag) and displayed distinctly from cancelled bookings. - Booking status emails: confirm/decline/cancel notify the customer via Resend when email is configured; the response reports notified so the dashboard is honest when it isn't. - New sitter-facing data (migration 0008, all additive): business contact email/phone (shown in the widget), client phone numbers, and per-pet care notes. - Plain language: embed page gets copy buttons and numbered Squarespace/Wix steps; "Embed" nav renamed "Your website", "Pets" to "Pet types"; Services page explains how to add a new offering; stale error banners clear on section switch; login screen drops the DEMO_NOTES.md reference. - Seed: demo bookings belong to the demo customer (no more "Unknown customer"), plus pending requests so a fresh install demos triage. Co-Authored-By: Claude Fable 5 --- app/admin/App.tsx | 22 ++-- app/admin/admin.css | 7 ++ app/admin/sections/BookingsSection.tsx | 146 +++++++++++++++-------- app/admin/sections/BusinessSection.tsx | 19 +++ app/admin/sections/ClientsSection.tsx | 29 ++++- app/admin/sections/EmbedSection.tsx | 46 ++++++- app/admin/sections/PetsSection.tsx | 3 + app/admin/sections/ServicesSection.tsx | 7 +- app/admin/sections/TimeOffSection.tsx | 30 +++-- app/admin/shared.ts | 4 + app/embed/App.tsx | 21 ++++ app/shared-ui/api.ts | 29 +++-- migrations/0008_contact_and_notes.sql | 9 ++ server/__tests__/admin-bookings.test.ts | 34 +++++- server/__tests__/admin.test.ts | 35 +++++- server/__tests__/availability.test.ts | 2 + server/__tests__/booking-by-pets.test.ts | 12 +- server/__tests__/isolation.test.ts | 23 +++- server/db/repo.ts | 80 +++++++++---- server/lib/email.ts | 20 ++++ server/routes/admin.ts | 130 +++++++++++++++----- server/routes/bookings.ts | 2 +- server/routes/public.ts | 2 + server/types.ts | 7 ++ sql/schema.sql | 8 ++ sql/seed.sql | 29 +++-- 26 files changed, 601 insertions(+), 155 deletions(-) create mode 100644 migrations/0008_contact_and_notes.sql diff --git a/app/admin/App.tsx b/app/admin/App.tsx index b166163..7498c7c 100644 --- a/app/admin/App.tsx +++ b/app/admin/App.tsx @@ -119,9 +119,6 @@ function Login({ onLogin }: { onLogin: (s: Session) => void }) { {busy ? 'Signing in…' : 'Sign in'} {error &&

{error}

} -

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

); } @@ -132,19 +129,21 @@ type SectionKey = 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 }) { @@ -185,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); }, []); @@ -193,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]); @@ -242,6 +246,8 @@ function Dashboard({ session, onSignOut }: { session: Session; onSignOut: () => 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, 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/BookingsSection.tsx b/app/admin/sections/BookingsSection.tsx index 0bcfdbd..2a85805 100644 --- a/app/admin/sections/BookingsSection.tsx +++ b/app/admin/sections/BookingsSection.tsx @@ -9,6 +9,15 @@ function formatWhen(b: AdminBooking): string { 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, @@ -20,6 +29,7 @@ export function BookingsSection({ }) { 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); @@ -35,12 +45,34 @@ export function BookingsSection({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [session]); - const setStatus = async (id: string, status: 'confirmed' | 'cancelled') => { + 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(); - setBusyId(id); + setMessage(''); + setBusyId(b.id); try { - await adminApi.bookings.setStatus(session.slug, session.token, id, status); + 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); @@ -49,66 +81,74 @@ export function BookingsSection({ } }; + 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

    -

    Confirm or decline requests as they come in.

    + {/* 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.

    ) : ( -
      - {bookings.map((b) => ( -
    • - - {b.customerName || b.customerEmail || 'Unknown customer'} — {b.type} -
      - {formatWhen(b)} · {b.petCount} pet{b.petCount === 1 ? '' : 's'} - {b.estCost != null ? ` · $${b.estCost}` : ''}{' '} - - {b.status} - -
      - - {b.status === 'pending' && ( - <> - - - - )} - {b.status === 'confirmed' && ( - - )} - -
    • - ))} -
    + <> +

    + {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 9b1e9c4..d4c7d3a 100644 --- a/app/admin/sections/BusinessSection.tsx +++ b/app/admin/sections/BusinessSection.tsx @@ -39,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.

    { @@ -29,8 +30,9 @@ function PetAdder({ clearError(); setBusy(true); try { - await adminApi.customers.addPet(slug, token, customer.id, name.trim(), petType); + await adminApi.customers.addPet(slug, token, customer.id, name.trim(), petType, notes.trim()); setName(''); + setNotes(''); onAdded(); } catch (e) { onError(e); @@ -49,6 +51,11 @@ function PetAdder({ ))} + setNotes(e.target.value)} + /> @@ -75,6 +82,7 @@ export function ClientsSection({ }) { 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 @@ -96,9 +104,16 @@ export function ClientsSection({ const addCustomer = () => mutate(async () => { - await adminApi.customers.add(slug, token, custEmail.trim().toLowerCase(), custName.trim()); + 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)); @@ -127,6 +142,12 @@ export function ClientsSection({ value={custName} onChange={(e) => setCustName(e.target.value)} /> + setCustPhone(e.target.value)} + /> @@ -137,7 +158,8 @@ export function ClientsSection({
    {cust.email} - {cust.name ? ` (${cust.name})` : ''}{' '} + {cust.name ? ` (${cust.name})` : ''} + {cust.phone ? ` · ${cust.phone}` : ''}{' '} @@ -152,6 +174,7 @@ export function ClientsSection({ {cust.pets.map((p) => (
  • {p.name} {p.petType} + {p.notes ? — {p.notes} : null} diff --git a/app/admin/sections/EmbedSection.tsx b/app/admin/sections/EmbedSection.tsx index 05e1750..b96f1ce 100644 --- a/app/admin/sections/EmbedSection.tsx +++ b/app/admin/sections/EmbedSection.tsx @@ -2,6 +2,27 @@ 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 ( +
    +