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' ? (
- <>
- void disconnectCalendar()}>Disconnect
-
- >
- ) : (
- void connectCalendar()}>Connect Google Calendar
- )
- ) : (
- p.status === 'disconnected' && (
- void connect(p.capability)}>Connect (stub)
- )
- )}
-
- ))}
+
+ Google Calendar{' '}
+
+ {connected ? 'Connected' : 'Not connected'}
+ {' '}
+ {connected ? (
+ <>
+ void disconnectCalendar()}>Disconnect
+
+ >
+ ) : (
+ void connectCalendar()}>Connect Google Calendar
+ )}
+
-
- 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' && (
+ <>
+ void setStatus(b, 'confirmed')}>
+ Confirm
+
+ void setStatus(b, 'declined')}>
+ Decline
+
+ >
+ )}
+ {b.status === 'confirmed' && (
+ void setStatus(b, 'cancelled')}>
+ Cancel
+
+ )}
+
+ );
+
+ 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}
+
setMessage('')}>OK
+
+ )}
+ {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 && }
+ {rest.length > 0 && (
+ <>
+ Everything else
+
+ >
+ )}
+ >
+ )}
+ >
+ );
+}
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 (
-
- {label} (blank = no limit)
- onChange(e.target.value === '' ? null : Number(e.target.value))}
- />
-
- );
-}
-
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 })}
/>
+
+ Contact email
+ setSettings({ ...settings, contactEmail: e.target.value || null })}
+ />
+
+
+ Contact phone
+ setSettings({ ...settings, contactPhone: e.target.value || null })}
+ />
+
+ 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({
))}
- {
- if (name.trim()) {
- onAdd(customer.id, name.trim(), petType);
- setName('');
- }
- }}
- >
- Add pet
+ setNotes(e.target.value)}
+ />
+ void add()} disabled={busy || !name.trim()}>
+ {busy ? 'Adding…' : 'Add pet'}
);
@@ -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)}
/>
- void addCustomer()}>Add customer
+ setCustPhone(e.target.value)}
+ />
+ void addCustomer()} disabled={busy}>
+ {busy ? 'Adding…' : 'Add customer'}
+
{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)}
- void removeCustomer(cust.id)}>Remove
+ void removeCustomer(cust.id)} disabled={busy}>
+ Remove
+
{cust.pets.map((p) => (
{p.name} {p.petType}
- void removePet(cust.id, p.id)}>Remove
+ {p.notes ? — {p.notes} : null}
+ void removePet(cust.id, p.id)} disabled={busy}>
+ Remove
+
))}
{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 (
+
+
+ );
+}
+
function Snippets({ session }: { session: Session }) {
const [snippets, setSnippets] = useState<{
script: string;
@@ -21,15 +42,28 @@ function Snippets({ session }: { session: Session }) {
return (
- Squarespace (Code Block, Core plan or higher) and most sites — paste this
- auto-resizing snippet:
+ You don't need to understand the code below — just copy it and paste it into your
+ website builder. Ask whoever manages your website to help if you get stuck.
+
+
+ Squarespace and most other website builders:
-
);
}
@@ -40,66 +74,11 @@ function Snippets({ session }: { session: Session }) {
* after a save to remount the frame with fresh config. The widget always fetches config
* server-side, so this never exposes anything the customer can't already see.
*
- * Sizing: the production loader auto-resizes off the widget's `pawbook:resize` postMessage because
- * it frames the widget CROSS-origin and can't read its document. This preview is SAME-origin, so it
- * measures `contentDocument` directly and watches the inner — more reliable than the
- * widget's single ping, which fires before its date inputs and fonts settle.
+ * Displays at a fixed height (640px / 70vh) with internal scrolling — simpler than the production
+ * loader which auto-resizes off the widget's `pawbook:resize` postMessage (it must resize
+ * cross-origin, lacking access to contentDocument).
*/
-function WidgetPreview({
- slug,
- reloadKey,
- active,
-}: {
- slug: string;
- reloadKey: number;
- active: boolean;
-}) {
- const [height, setHeight] = useState(520);
- const frameRef = useRef(null);
-
- // `reloadKey` remounts the iframe (fresh config after a save). Sizing off the iframe `load` event
- // is fragile — cache and StrictMode double-mount can drop it — so we briefly poll instead. Each
- // tick re-observes whenever the iframe's changes (the about:blank → /embed navigation
- // swaps it); a ResizeObserver then tracks later changes (e.g. switching widget tabs). We measure
- // the BODY's height, not documentElement.scrollHeight — the latter is floored at the iframe's own
- // height, so it reads a stale 520 while the widget is still loading.
- //
- // This section stays mounted even while its tab isn't active (`display: none`), where the body
- // has no rendered box and reads a 0 height. `active` in the deps restarts the polling window
- // fresh whenever the tab is switched TO, so it gets a real measurement instead of relying on the
- // ResizeObserver to catch a resize it may have missed while hidden. Guarded to only run while
- // active — otherwise leaving the tab would also restart it, and its first measure() would read
- // the now-hidden body's 0 height and squash the previously-correct one down to the 320px floor.
- useEffect(() => {
- if (!active) return;
- const frame = frameRef.current;
- if (!frame) return;
- let observer: ResizeObserver | null = null;
- let observed: HTMLElement | null = null;
- let ticks = 0;
- const measure = () => {
- const body = frame.contentDocument?.body;
- if (body) setHeight(Math.max(320, body.scrollHeight));
- };
- const tick = () => {
- const body = frame.contentDocument?.body; // same-origin first-party path — always readable.
- if (body && body !== observed) {
- observer?.disconnect();
- observer = new ResizeObserver(measure);
- observer.observe(body);
- observed = body;
- }
- measure();
- if (++ticks > 25) window.clearInterval(timer); // ~5s; the ResizeObserver carries on after.
- };
- const timer = window.setInterval(tick, 200);
- tick();
- return () => {
- window.clearInterval(timer);
- observer?.disconnect();
- };
- }, [reloadKey, active]);
-
+function WidgetPreview({ slug, reloadKey }: { slug: string; reloadKey: number }) {
return (
@@ -110,11 +89,10 @@ function WidgetPreview({
);
@@ -147,7 +125,7 @@ export function EmbedSection({
{everActive && (
<>
-
+
>
)}
diff --git a/app/admin/sections/PetsSection.tsx b/app/admin/sections/PetsSection.tsx
index 7a9178f..15431cd 100644
--- a/app/admin/sections/PetsSection.tsx
+++ b/app/admin/sections/PetsSection.tsx
@@ -7,6 +7,9 @@ export function PetsSection({ settings, setSettings }: SettingsSectionProps) {
Pets you care for
+
+ Which types of pets you accept. Your clients' individual pets live under Clients.
+
{settings.petTypes.map((p, i) => (
Services & rates
+
+ Tick the services you offer. To create a new offering clients can book (say, a 30-minute
+ “Puppy Check-in”), add it as an option under Walks or Check-ins with its own
+ name, length, and price.
+
{settings.services.map((s, si) => {
const setService = (next: ServiceForm) => {
const services = [...settings.services];
@@ -276,7 +281,7 @@ export function ServicesSection({ settings, setSettings }: SettingsSectionProps)
})
}
>
- Add duration
+ Add an option
)}
diff --git a/app/admin/sections/TimeOffSection.tsx b/app/admin/sections/TimeOffSection.tsx
index 8ab36af..5c4c74f 100644
--- a/app/admin/sections/TimeOffSection.tsx
+++ b/app/admin/sections/TimeOffSection.tsx
@@ -1,24 +1,65 @@
-import { formatBlockRange } from '../../../src/shared/index.js';
+import { useState } from 'react';
+import { addDays, formatBlockRange } from '../../../src/shared/index.js';
import { IconCalendar } from '../../shared-ui/icons';
import type { Settings } from '../shared.js';
+import { adminFetch } from '../shared.js';
export function TimeOffSection({
blocked,
- blockStart,
- blockEnd,
- setBlockStart,
- setBlockEnd,
- addBlock,
- removeBlock,
+ slug,
+ token,
+ onChanged,
+ handleError,
+ clearError,
}: {
blocked: Settings['blocked'];
- blockStart: string;
- blockEnd: string;
- setBlockStart: (value: string) => void;
- setBlockEnd: (value: string) => void;
- addBlock: () => Promise;
- removeBlock: (id: string) => Promise;
+ slug: string;
+ token: string;
+ onChanged: () => Promise;
+ handleError: (e: unknown) => void;
+ clearError: () => void;
}) {
+ const [blockStart, setBlockStart] = useState('');
+ const [blockEnd, setBlockEnd] = useState('');
+ const [busy, setBusy] = useState(false);
+
+ // The sitter types the FIRST and LAST day off (inclusive — how humans say "Aug 10–17");
+ // the API/DB convention is an exclusive end, so convert at this boundary.
+ const rangeValid = blockStart !== '' && blockEnd !== '' && blockEnd >= blockStart;
+
+ const addBlock = async () => {
+ if (busy || !rangeValid) return;
+ clearError();
+ setBusy(true);
+ try {
+ await adminFetch(token, `/api/${slug}/admin/blocked`, {
+ method: 'POST',
+ body: JSON.stringify({ startDate: blockStart, endDate: addDays(blockEnd, 1) }),
+ });
+ setBlockStart('');
+ setBlockEnd('');
+ await onChanged();
+ } catch (e) {
+ handleError(e);
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const removeBlock = async (id: string) => {
+ if (busy) return;
+ clearError();
+ setBusy(true);
+ try {
+ await adminFetch(token, `/api/${slug}/admin/blocked/${id}`, { method: 'DELETE' });
+ await onChanged();
+ } catch (e) {
+ handleError(e);
+ } finally {
+ setBusy(false);
+ }
+ };
+
return (
<>
@@ -34,10 +75,24 @@ export function TimeOffSection({
))}
- setBlockStart(e.target.value)} />
- setBlockEnd(e.target.value)} />
- Block range
+
+ First day off
+ setBlockStart(e.target.value)} />
+
+
+ Last day off
+ setBlockEnd(e.target.value)} />
+
+ void addBlock()} disabled={busy || !rangeValid}>
+ {busy ? 'Saving…' : 'Block these days'}
+
+
+ Both days are included — for one day off, pick the same day twice.
+ {blockStart && blockEnd && blockEnd < blockStart
+ ? ' The last day must be on or after the first day.'
+ : ''}
+
>
);
}
diff --git a/app/admin/sections/fields.tsx b/app/admin/sections/fields.tsx
new file mode 100644
index 0000000..5b36c2f
--- /dev/null
+++ b/app/admin/sections/fields.tsx
@@ -0,0 +1,22 @@
+/** 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 (
+
+ {label} (blank = no limit)
+ onChange(e.target.value === '' ? null : Number(e.target.value))}
+ />
+
+ );
+}
diff --git a/app/admin/shared.ts b/app/admin/shared.ts
index aca122b..52d274b 100644
--- a/app/admin/shared.ts
+++ b/app/admin/shared.ts
@@ -1,27 +1,13 @@
+import type { ServiceConstraints, ServiceOption, ServiceQuestion } from '../../src/shared/index.js';
import { request } from '../shared-ui/api.js';
export type Session = { token: string; slug: string; displayName: string };
-export type ServiceOptionForm = {
- optionKey?: string;
- label: string;
- durationMinutes: number | null;
- rate: number;
- startTime: string | null;
- endTime: string | null;
- capacity: number | null;
-};
-export type QuestionForm = {
- id?: string;
- label: string;
- type: 'text' | 'yesno' | 'number' | 'select';
- required: boolean;
- min?: number;
- max?: number;
- pattern?: string;
- options?: string[];
-};
-export type ServiceForm = {
+// `optionKey`/`id` are omitted-until-first-save on the client (the server derives/assigns them),
+// so both forms widen that one field to optional relative to the shared, field-complete shape.
+export type ServiceOptionForm = Omit & { optionKey?: string };
+export type QuestionForm = Omit & { id?: string };
+export type ServiceForm = ServiceConstraints & {
type: string;
label: string;
hasDuration: boolean;
@@ -30,10 +16,6 @@ export type ServiceForm = {
enabled: boolean;
options: ServiceOptionForm[];
questions: QuestionForm[];
- minNights: number | null;
- maxNights: number | null;
- minPetCount: number | null;
- maxPetCount: number | null;
};
export type Settings = {
displayName: string;
@@ -42,18 +24,16 @@ export type Settings = {
maxHouseSitsPerDay: number | null;
maxStayNights: number | null;
timezone: string | null;
+ contactEmail: string | null;
+ contactPhone: string | null;
petTypes: { petType: string; enabled: boolean }[];
services: ServiceForm[];
blocked: { id: string; startDate: string; endDate: string | null }[];
- providers: {
- capability: string;
- provider: string;
- label: string;
- authMode: 'oauth' | 'stub';
+ calendar: {
status: string;
connectedAt: string | null;
- calendarId?: string | null;
- }[];
+ calendarId: string | null;
+ };
};
/** Shared prop shape for sections that edit the staged, save-button-gated `settings` draft. */
@@ -62,6 +42,32 @@ export type SettingsSectionProps = {
setSettings: (settings: Settings) => void;
};
+/**
+ * The PUT `/admin/settings` request body (mirrors `SettingsBody`/`ServiceBody` in
+ * server/routes/admin.ts). Built from the same shared/derived field types as `Settings` so that
+ * a field added to `ServiceOption`/`ServiceQuestion`/`ServiceConstraints` — or dropped by a hand
+ * mapping in `save()` — surfaces as a compile error there instead of silently going missing on
+ * the wire.
+ */
+export type ServicePayload = ServiceConstraints & {
+ type: string;
+ enabled: boolean;
+ options: ServiceOptionForm[];
+ questions: QuestionForm[];
+};
+export type SettingsPayload = {
+ displayName: string;
+ accentColor: string;
+ maxBoardingPets: number | null;
+ maxHouseSitsPerDay: number | null;
+ maxStayNights: number | null;
+ timezone: string | null;
+ contactEmail: string | null;
+ contactPhone: string | null;
+ petTypes: string[];
+ services: ServicePayload[];
+};
+
export function adminFetch(token: string, path: string, init?: RequestInit): Promise {
return request(path, {
...init,
diff --git a/app/embed/App.tsx b/app/embed/App.tsx
index e3aa630..0b6dfb7 100644
--- a/app/embed/App.tsx
+++ b/app/embed/App.tsx
@@ -1,537 +1,21 @@
-import {
- formatShortDate,
- nightsBetween,
- validateAnswers,
- validateServiceConstraints,
-} from '../../src/shared/index.js';
import { useCallback, useEffect, useState } from 'react';
-import { Calendar } from './Calendar';
import {
api,
getToken,
isAuthExpired,
setToken,
- type Availability,
- type Booking,
type Pet,
- type ServiceQuestion,
type TenantConfig,
} from '../shared-ui/api';
+import { useAsync } from '../shared-ui/useAsync';
import './widget.css';
-import { SERVICE_ICONS } from './services';
-import { IconCheck, IconPaw } from '../shared-ui/icons';
-
-const errorMsg = (e: unknown): string => (e instanceof Error ? e.message : 'Try again.');
-
-function QuestionField({
- question,
- value,
- onChange,
-}: {
- question: ServiceQuestion;
- value: string;
- onChange: (value: string) => void;
-}) {
- const label = `${question.label}${question.required ? ' *' : ''}`;
- if (question.type === 'yesno') {
- return (
-
- {label}
- onChange(e.target.value)}>
- Choose…
- Yes
- No
-
-
- );
- }
- if (question.type === 'select') {
- return (
-
- {label}
- onChange(e.target.value)}>
- Choose…
- {(question.options ?? []).map((o) => (
-
- {o}
-
- ))}
-
-
- );
- }
- return (
-
- {label}
- onChange(e.target.value)}
- />
-
- );
-}
-
-/** Widget tenant comes from the iframe path: /embed/:slug — never from the host page. */
-const slug = window.location.pathname.split('/').filter(Boolean)[1] ?? '';
-
-type IdentifyState =
- { step: 'email' } | { step: 'code'; codeId: string; prototypeCode?: string; email: string };
-
-function Identify({ onDone }: { onDone: () => void }) {
- const [state, setState] = useState({ step: 'email' });
- const [email, setEmail] = useState('');
- const [code, setCode] = useState('');
- const [error, setError] = useState('');
- const [busy, setBusy] = useState(false);
-
- const submitEmail = async () => {
- if (busy) return;
- setError('');
- setBusy(true);
- try {
- const res = await api.identify(slug, email);
- setState({
- step: 'code',
- codeId: res.codeId,
- prototypeCode: res.prototypeCode,
- email,
- });
- } catch (e) {
- setError(errorMsg(e));
- } finally {
- setBusy(false);
- }
- };
-
- const submitCode = async () => {
- if (state.step !== 'code') return;
- if (busy) return;
- setError('');
- setBusy(true);
- try {
- const res = await api.verify(slug, state.codeId, code);
- setToken(slug, res.token);
- onDone();
- } catch (e) {
- setError(errorMsg(e));
- } finally {
- setBusy(false);
- }
- };
-
- if (state.step === 'email') {
- return (
-
-
- Your email
- setEmail(e.target.value)}
- onKeyDown={(e) => e.key === 'Enter' && void submitEmail()}
- />
-
-
- {busy ? 'Sending…' : 'Email me a code'}
-
- {error &&
{error}
}
-
- );
- }
-
- return (
-
-
- {state.prototypeCode ? (
- <>
- Your code: {state.prototypeCode}
-
- (dev mode: this would be emailed to {state.email})
- >
- ) : (
- <>
- We emailed a 6-digit code to {state.email} .
- >
- )}
-
-
setCode(e.target.value)}
- onKeyDown={(e) => e.key === 'Enter' && void submitCode()}
- />
-
- {busy ? 'Verifying…' : 'Verify'}
-
- {error &&
{error}
}
-
- );
-}
-
-function BookTab({
- config,
- pets,
- onAuthExpired,
-}: {
- config: TenantConfig;
- pets: Pet[] | null;
- onAuthExpired: () => void;
-}) {
- const [type, setType] = useState(config.services[0]?.type ?? 'boarding');
- const service = config.services.find((s) => s.type === type) ?? config.services[0];
- const [optionKey, setOptionKey] = useState(service?.options[0]?.optionKey ?? '');
- const [month, setMonth] = useState(() => new Date().toISOString().slice(0, 7));
- const [start, setStart] = useState('');
- const [end, setEnd] = useState('');
- const [selectedPets, setSelectedPets] = useState([]);
- const [answers, setAnswers] = useState>({});
- const [calReloadKey, setCalReloadKey] = useState(0);
- const [result, setResult] = useState(null);
- const [confirmation, setConfirmation] = useState('');
- const [error, setError] = useState('');
- const [submitting, setSubmitting] = useState(false);
-
- const questionsError = service ? validateAnswers(service.questions, answers) : null;
- const nights = service?.shape === 'range' && start && end ? nightsBetween(start, end) : null;
- const constraintsError = service
- ? validateServiceConstraints(
- {
- minNights: service.minNights,
- maxNights: service.maxNights,
- minPetCount: service.minPetCount,
- maxPetCount: service.maxPetCount,
- },
- { nights, petCount: selectedPets.length },
- )
- : null;
-
- const datesReady = !!start && (service?.shape !== 'range' || !!end);
-
- const resetCheck = () => {
- setResult(null);
- setConfirmation('');
- };
-
- const onServiceChange = (next: string) => {
- setType(next);
- const svc = config.services.find((s) => s.type === next);
- setOptionKey(svc?.options[0]?.optionKey ?? '');
- setStart('');
- setEnd('');
- setSelectedPets([]);
- setAnswers({});
- resetCheck();
- };
-
- const check = async () => {
- setError('');
- setConfirmation('');
- setResult(null);
- if (selectedPets.length === 0) {
- setError('Choose at least one pet.');
- return;
- }
- try {
- const params: Record = {
- type,
- option: optionKey,
- start,
- pets: String(selectedPets.length),
- };
- if (service?.shape === 'range') params.end = end;
- setResult(await api.availability(slug, params));
- } catch (e) {
- setError(errorMsg(e));
- }
- };
-
- const submit = async () => {
- if (submitting) return;
- setError('');
- const token = getToken(slug);
- if (!token) return;
- setSubmitting(true);
- try {
- const body = {
- type,
- optionKey,
- startDate: start,
- petIds: selectedPets,
- answers,
- ...(service?.shape === 'range' ? { endDate: end } : {}),
- };
- const res = await api.createBooking(slug, token, body);
- setConfirmation(
- `Request sent! Estimated cost $${res.estCost}. Track it under "My bookings".`,
- );
- setStart('');
- setEnd('');
- setSelectedPets([]);
- setAnswers({});
- setResult(null);
- setCalReloadKey((k) => k + 1);
- window.parent.postMessage({ type: 'pawbook:booked', requestId: res.id }, '*');
- } catch (e) {
- if (isAuthExpired(e)) {
- onAuthExpired();
- return;
- }
- setError(errorMsg(e));
- } finally {
- setSubmitting(false);
- }
- };
-
- if (!service) return No services available yet.
;
-
- return (
-
-
- {config.services.map((s) => {
- const Icon = SERVICE_ICONS[s.type] ?? IconPaw;
- return (
- onServiceChange(s.type)}
- >
-
-
-
- {s.label}
-
- );
- })}
-
-
- {service?.hasDuration && (
-
- Duration
- {
- setOptionKey(e.target.value);
- // The calendar's availability grid is keyed by option (capacity varies per
- // option), so a date picked under the old option may not apply to the new one.
- setStart('');
- setEnd('');
- resetCheck();
- }}
- >
- {service.options.map((o) => (
-
- {o.label}
- {o.startTime && o.endTime ? ` · ${o.startTime}–${o.endTime}` : ''} — ${o.rate}/
- {service.rateUnit}
-
- ))}
-
-
- )}
-
-
{
- setStart(v.start ?? '');
- setEnd(v.end ?? '');
- resetCheck();
- }}
- onAuthExpired={onAuthExpired}
- />
-
- {datesReady && (
-
-
- Who's coming?
- {pets === null ? (
- Loading pets…
- ) : pets.length === 0 ? (
- No pets added yet — ask your sitter to add yours.
- ) : (
-
- {pets.map((p) => {
- const on = selectedPets.includes(p.id);
- return (
-
- {
- setSelectedPets((cur) =>
- e.target.checked ? [...cur, p.id] : cur.filter((id) => id !== p.id),
- );
- resetCheck();
- }}
- />
-
-
-
- {p.name}
- {p.petType}
-
- );
- })}
-
- )}
-
- {service && service.questions.length > 0 && (
-
- A few questions
- {service.questions.map((q) => (
- setAnswers((cur) => ({ ...cur, [q.id]: value }))}
- />
- ))}
-
- )}
-
- Check availability
-
- {result &&
- (result.available ? (
-
-
- {formatShortDate(start)}
- {end ? ` – ${formatShortDate(end)}` : ''}
- {result.nights != null
- ? ` · ${result.nights} night${result.nights === 1 ? '' : 's'}`
- : ''}
-
-
- Estimated cost ${result.estCost}
-
-
- {submitting ? 'Sending…' : 'Send request'}
-
- {(questionsError || constraintsError) && (
-
{questionsError ?? constraintsError}
- )}
-
- ) : (
-
- ))}
- {error &&
{error}
}
-
- )}
- {/* Rendered outside the details panel: submitting resets the dates, which unmounts
- the panel — a confirmation inside it would vanish before it was ever seen. */}
- {confirmation && {confirmation}
}
-
- );
-}
-
-function MineTab() {
- const [bookings, setBookings] = useState(null);
- // Seed from token presence so the effect never sets state synchronously (react-hooks rule).
- const [needIdentify, setNeedIdentify] = useState(() => !getToken(slug));
- const [error, setError] = useState('');
-
- // Resolve the bookings load to a settled outcome, with NO setState — the caller applies it.
- // Keeping setState out of this function lets the effect call setState only inside a `.then`
- // callback (the shape react-hooks/set-state-in-effect blesses), not synchronously.
- const loadOutcome = useCallback(
- async (
- token: string,
- ): Promise<
- { kind: 'ok'; bookings: Booking[] } | { kind: 'reauth' } | { kind: 'error'; message: string }
- > => {
- try {
- const res = await api.myBookings(slug, token);
- return { kind: 'ok', bookings: res.bookings };
- } catch (e) {
- if (isAuthExpired(e)) {
- setToken(slug, null);
- return { kind: 'reauth' };
- }
- return {
- kind: 'error',
- message: errorMsg(e),
- };
- }
- },
- [],
- );
-
- const apply = useCallback((outcome: Awaited>) => {
- if (outcome.kind === 'ok') {
- setBookings(outcome.bookings);
- setNeedIdentify(false);
- } else if (outcome.kind === 'reauth') {
- setNeedIdentify(true);
- } else {
- setError(outcome.message);
- }
- }, []);
-
- const reload = () => {
- const token = getToken(slug);
- if (token) void loadOutcome(token).then(apply);
- else setNeedIdentify(true);
- };
-
- useEffect(() => {
- const token = getToken(slug);
- if (!token) return;
- let active = true;
- void loadOutcome(token).then((outcome) => {
- if (active) apply(outcome);
- });
- return () => {
- active = false;
- };
- }, [loadOutcome, apply]);
-
- if (needIdentify) return ;
- if (error) return {error}
;
- if (!bookings) return Loading…
;
- if (bookings.length === 0) return No bookings yet — book one above!
;
-
- return (
-
- {bookings.map((b) => (
-
-
- {b.type} {formatShortDate(b.startDate)}
- {b.endDate ? ` – ${formatShortDate(b.endDate)}` : ''} ·{' '}
- {b.pets.length > 0
- ? b.pets.join(', ')
- : `${b.petCount} pet${b.petCount === 1 ? '' : 's'}`}
- {b.estCost != null ? ` · est. $${b.estCost}` : ''}
-
- {b.status}
-
- ))}
-
- );
-}
+import { Identify } from './Identify';
+import { BookTab } from './BookTab';
+import { MineTab } from './MineTab';
+import { slug, parentOrigin } from './shared';
export default function App() {
const [config, setConfig] = useState(null);
- const [me, setMe] = useState<{ name: string | null; pets: Pet[] } | null>(null);
const [authed, setAuthed] = useState(() => !!getToken(slug));
const [error, setError] = useState('');
const [showMine, setShowMine] = useState(false);
@@ -545,7 +29,7 @@ export default function App() {
type: 'pawbook:resize',
height: document.documentElement.scrollHeight,
},
- '*',
+ parentOrigin,
);
report();
const observer = new ResizeObserver(report);
@@ -566,34 +50,53 @@ export default function App() {
// Any 401/403 means the stored token is expired or revoked: clear it and drop back to
// sign-in ("token loss must degrade to re-identify" — server/lib/token.ts). Without this
- // the booking view renders with a dead calendar that silently ignores taps.
+ // the booking view renders with a dead calendar that silently ignores taps. `me` resets to
+ // null itself once `authed` flips false and `loadMe` re-resolves — the sign-in screen this
+ // reveals doesn't read `me` anyway, so there's nothing to observe in between.
const onAuthExpired = useCallback(() => {
setToken(slug, null);
- setMe(null);
setAuthed(false);
}, []);
- useEffect(() => {
- if (!authed) return;
+ const loadMe = useCallback(async (): Promise<{ name: string | null; pets: Pet[] } | null> => {
+ if (!authed) return null;
const token = getToken(slug);
- if (!token) return;
- let active = true;
- api
- .me(slug, token)
- .then((m) => active && setMe(m))
- .catch((e: unknown) => {
- if (!active) return;
- if (isAuthExpired(e)) onAuthExpired();
- else setMe({ name: null, pets: [] });
- });
- return () => {
- active = false;
- };
+ if (!token) return null;
+ try {
+ return await api.me(slug, token);
+ } catch (e) {
+ if (isAuthExpired(e)) {
+ onAuthExpired();
+ return null;
+ }
+ return { name: null, pets: [] };
+ }
}, [authed, onAuthExpired]);
+ const { data: me } = useAsync(loadMe);
+
if (error) return {error}
;
if (!config) return Loading…
;
+ const contact =
+ config.contactEmail || config.contactPhone ? (
+
+ Questions?{' '}
+ {config.contactPhone ? (
+ <>
+ Call {config.contactPhone}
+ >
+ ) : null}
+ {config.contactPhone && config.contactEmail ? ' or ' : null}
+ {config.contactEmail ? (
+ <>
+ email {config.contactEmail}
+ >
+ ) : null}
+ .
+
+ ) : null;
+
if (!authed) {
return (
@@ -602,6 +105,7 @@ export default function App() {
Enter the email your sitter has on file and we'll send you a sign-in code.
setAuthed(true)} />
+ {contact}
);
}
@@ -625,6 +129,7 @@ export default function App() {
>
)}
+ {contact}
);
}
diff --git a/app/embed/BookTab.tsx b/app/embed/BookTab.tsx
new file mode 100644
index 0000000..2f30cc7
--- /dev/null
+++ b/app/embed/BookTab.tsx
@@ -0,0 +1,294 @@
+import {
+ formatShortDate,
+ nightsBetween,
+ validateAnswers,
+ validateServiceConstraints,
+} from '../../src/shared/index.js';
+import { useState } from 'react';
+import { Calendar } from './Calendar';
+import {
+ api,
+ getToken,
+ isAuthExpired,
+ type Availability,
+ type Pet,
+ type TenantConfig,
+} from '../shared-ui/api';
+import { SERVICE_ICONS } from './services';
+import { IconCheck, IconPaw } from '../shared-ui/icons';
+import { QuestionField } from './QuestionField';
+import { errorMsg, slug, parentOrigin } from './shared';
+
+export function BookTab({
+ config,
+ pets,
+ onAuthExpired,
+}: {
+ config: TenantConfig;
+ pets: Pet[] | null;
+ onAuthExpired: () => void;
+}) {
+ const [type, setType] = useState(config.services[0]?.type ?? 'boarding');
+ const service = config.services.find((s) => s.type === type) ?? config.services[0];
+ const [optionKey, setOptionKey] = useState(service?.options[0]?.optionKey ?? '');
+ const [month, setMonth] = useState(() => new Date().toISOString().slice(0, 7));
+ const [start, setStart] = useState('');
+ const [end, setEnd] = useState('');
+ const [selectedPets, setSelectedPets] = useState([]);
+ const [answers, setAnswers] = useState>({});
+ const [calReloadKey, setCalReloadKey] = useState(0);
+ const [result, setResult] = useState(null);
+ const [confirmation, setConfirmation] = useState('');
+ const [error, setError] = useState('');
+ const [submitting, setSubmitting] = useState(false);
+ const [checking, setChecking] = useState(false);
+
+ const questionsError = service ? validateAnswers(service.questions, answers) : null;
+ const nights = service?.shape === 'range' && start && end ? nightsBetween(start, end) : null;
+ const constraintsError = service
+ ? validateServiceConstraints(
+ {
+ minNights: service.minNights,
+ maxNights: service.maxNights,
+ minPetCount: service.minPetCount,
+ maxPetCount: service.maxPetCount,
+ },
+ { nights, petCount: selectedPets.length },
+ )
+ : null;
+
+ const datesReady = !!start && (service?.shape !== 'range' || !!end);
+
+ const resetCheck = () => {
+ setResult(null);
+ setConfirmation('');
+ };
+
+ const onServiceChange = (next: string) => {
+ setType(next);
+ const svc = config.services.find((s) => s.type === next);
+ setOptionKey(svc?.options[0]?.optionKey ?? '');
+ setStart('');
+ setEnd('');
+ setSelectedPets([]);
+ setAnswers({});
+ resetCheck();
+ };
+
+ const check = async () => {
+ if (checking) return;
+ setError('');
+ setConfirmation('');
+ setResult(null);
+ if (selectedPets.length === 0) {
+ setError('Choose at least one pet.');
+ return;
+ }
+ setChecking(true);
+ try {
+ const params: Record = {
+ type,
+ option: optionKey,
+ start,
+ pets: String(selectedPets.length),
+ };
+ if (service?.shape === 'range') params.end = end;
+ setResult(await api.availability(slug, params));
+ } catch (e) {
+ setError(errorMsg(e));
+ } finally {
+ setChecking(false);
+ }
+ };
+
+ const submit = async () => {
+ if (submitting) return;
+ setError('');
+ const token = getToken(slug);
+ if (!token) return;
+ setSubmitting(true);
+ try {
+ const body = {
+ type,
+ optionKey,
+ startDate: start,
+ petIds: selectedPets,
+ answers,
+ ...(service?.shape === 'range' ? { endDate: end } : {}),
+ };
+ const res = await api.createBooking(slug, token, body);
+ setConfirmation(
+ `Request sent! Estimated cost $${res.estCost}. Track it under "My bookings".`,
+ );
+ setStart('');
+ setEnd('');
+ setSelectedPets([]);
+ setAnswers({});
+ setResult(null);
+ setCalReloadKey((k) => k + 1);
+ window.parent.postMessage({ type: 'pawbook:booked', requestId: res.id }, parentOrigin);
+ } catch (e) {
+ if (isAuthExpired(e)) {
+ onAuthExpired();
+ return;
+ }
+ setError(errorMsg(e));
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ if (!service) return No services available yet.
;
+
+ return (
+
+
+ {config.services.map((s) => {
+ const Icon = SERVICE_ICONS[s.type] ?? IconPaw;
+ return (
+ onServiceChange(s.type)}
+ >
+
+
+
+ {s.label}
+
+ );
+ })}
+
+
+ {service?.hasDuration && (
+
+ Duration
+ {
+ setOptionKey(e.target.value);
+ // The calendar's availability grid is keyed by option (capacity varies per
+ // option), so a date picked under the old option may not apply to the new one.
+ setStart('');
+ setEnd('');
+ resetCheck();
+ }}
+ >
+ {service.options.map((o) => (
+
+ {o.label}
+ {o.startTime && o.endTime ? ` · ${o.startTime}–${o.endTime}` : ''} — ${o.rate}/
+ {service.rateUnit}
+
+ ))}
+
+
+ )}
+
+
{
+ setStart(v.start ?? '');
+ setEnd(v.end ?? '');
+ resetCheck();
+ }}
+ onAuthExpired={onAuthExpired}
+ />
+
+ {datesReady && (
+
+
+ Who's coming?
+ {pets === null ? (
+ Loading pets…
+ ) : pets.length === 0 ? (
+ No pets added yet — ask your sitter to add yours.
+ ) : (
+
+ {pets.map((p) => {
+ const on = selectedPets.includes(p.id);
+ return (
+
+ {
+ setSelectedPets((cur) =>
+ e.target.checked ? [...cur, p.id] : cur.filter((id) => id !== p.id),
+ );
+ resetCheck();
+ }}
+ />
+
+
+
+ {p.name}
+ {p.petType}
+
+ );
+ })}
+
+ )}
+
+ {service && service.questions.length > 0 && (
+
+ A few questions
+ {service.questions.map((q) => (
+ setAnswers((cur) => ({ ...cur, [q.id]: value }))}
+ />
+ ))}
+
+ )}
+
+ {checking ? 'Checking…' : 'Check availability'}
+
+ {result &&
+ (result.available ? (
+
+
+ {formatShortDate(start)}
+ {end ? ` – ${formatShortDate(end)}` : ''}
+ {result.nights != null
+ ? ` · ${result.nights} night${result.nights === 1 ? '' : 's'}`
+ : ''}
+
+
+ Estimated cost ${result.estCost}
+
+
+ {submitting ? 'Sending…' : 'Send request'}
+
+ {(questionsError || constraintsError) && (
+
{questionsError ?? constraintsError}
+ )}
+
+ ) : (
+
+ ))}
+ {error &&
{error}
}
+
+ )}
+ {/* Rendered outside the details panel: submitting resets the dates, which unmounts
+ the panel — a confirmation inside it would vanish before it was ever seen. */}
+ {confirmation && {confirmation}
}
+
+ );
+}
diff --git a/app/embed/Calendar.tsx b/app/embed/Calendar.tsx
index e06db0a..b574493 100644
--- a/app/embed/Calendar.tsx
+++ b/app/embed/Calendar.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useState } from 'react';
+import { useCallback, useEffect, useRef } from 'react';
import { api, isAuthExpired, type MonthDay } from '../shared-ui/api';
import {
monthGrid,
@@ -8,6 +8,7 @@ import {
type RangeValue,
} from '../../src/shared/index.js';
import { IconChevronLeft, IconChevronRight } from '../shared-ui/icons';
+import { useAsync } from '../shared-ui/useAsync';
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const MONTHS = [
@@ -51,57 +52,50 @@ export function Calendar({
/** Called when the month fetch is rejected as unauthenticated (expired token). */
onAuthExpired?: () => void;
}) {
- // Combine fetch result into one object keyed by deps so loading/error can be derived without
- // calling setState synchronously inside the effect body (react-hooks/set-state-in-effect rule).
- const depsKey = `${slug}|${token}|${serviceType}|${optionKey ?? ''}|${month}|${reloadKey ?? ''}`;
- const [fetchState, setFetchState] = useState<{
- fetchedKey: string;
- days: Map;
- today: string;
- error: boolean;
- }>({ fetchedKey: '', days: new Map(), today: '', error: false });
+ // Held in a ref (not a fetchMonth dep) so a parent that passes a fresh closure every render
+ // doesn't give fetchMonth a new identity each time — that would make useAsync refetch, which
+ // triggers a render, which makes a new closure, looping forever. Assigned in an effect (not
+ // during render — React forbids mutating a ref's `current` synchronously in the render body)
+ // so the ref is current before any later event/effect reads it.
+ const onAuthExpiredRef = useRef(onAuthExpired);
+ useEffect(() => {
+ onAuthExpiredRef.current = onAuthExpired;
+ });
+
+ const fetchMonth = useCallback(async () => {
+ // reloadKey doesn't change what's fetched — referencing it is what forces a fresh
+ // fetchMonth identity (and therefore a refetch) after a booking submission bumps it.
+ void reloadKey;
+ try {
+ const r = await api.monthAvailability(slug, token, serviceType, month, optionKey);
+ return { days: new Map(r.days.map((d) => [d.date, d])), today: r.today };
+ } catch (e) {
+ // An expired/invalid token must degrade to re-identify (see server/lib/token.ts) —
+ // otherwise the calendar renders with no availability and silently ignores taps.
+ if (isAuthExpired(e)) {
+ onAuthExpiredRef.current?.();
+ // Never resolve: the parent unmounts this component right after onAuthExpired()
+ // flips auth state, so this just leaves `loading` true until then (matching the old
+ // behavior of never updating fetch state once the token is known to be dead).
+ return new Promise<{ days: Map; today: string }>(() => {});
+ }
+ throw e;
+ }
+ }, [slug, token, serviceType, month, optionKey, reloadKey]);
- // Derive display state from whether the current deps match the last completed fetch.
- const loading = fetchState.fetchedKey !== depsKey;
- const loadError = !loading && fetchState.error;
- const days = loading ? new Map() : fetchState.days;
- const today = loading ? '' : fetchState.today;
+ const { data, error, loading } = useAsync(fetchMonth);
+ const loadError = !loading && !!error;
+ // Gate on loadError as well as loading: useAsync retains the last successful data on a
+ // failed fetch, but this grid should render blank + the error message (the pre-hook
+ // behavior), not a stale month's availability.
+ const showData = !loading && !loadError;
+ const days = showData ? (data?.days ?? new Map()) : new Map();
+ const today = showData ? (data?.today ?? '') : '';
const parts = month.split('-');
const year = Number(parts[0]);
const mon = Number(parts[1]);
- useEffect(() => {
- let active = true;
- api
- .monthAvailability(slug, token, serviceType, month, optionKey)
- .then((r) => {
- if (!active) return;
- setFetchState({
- fetchedKey: depsKey,
- days: new Map(r.days.map((d) => [d.date, d])),
- today: r.today,
- error: false,
- });
- })
- .catch((e: unknown) => {
- if (!active) return;
- // An expired/invalid token must degrade to re-identify (see server/lib/token.ts) —
- // otherwise the calendar renders with no availability and silently ignores taps.
- if (isAuthExpired(e)) {
- onAuthExpired?.();
- return;
- }
- setFetchState({ fetchedKey: depsKey, days: new Map(), today: '', error: true });
- });
- return () => {
- active = false;
- };
- // onAuthExpired is deliberately not a dep: parents pass a fresh closure each render,
- // and re-running this fetch when it changes would loop.
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [depsKey, slug, token, serviceType, optionKey, month]);
-
const pick = (date: string, d: MonthDay | undefined) => {
if (!d || d.status === 'unavailable' || (today && date < today)) return;
onChange(nextRangeSelection(value, date, shape));
diff --git a/app/embed/Identify.tsx b/app/embed/Identify.tsx
new file mode 100644
index 0000000..89facb0
--- /dev/null
+++ b/app/embed/Identify.tsx
@@ -0,0 +1,103 @@
+import { useState } from 'react';
+import { api, setToken } from '../shared-ui/api';
+import { errorMsg, slug } from './shared';
+
+type IdentifyState =
+ { step: 'email' } | { step: 'code'; codeId: string; prototypeCode?: string; email: string };
+
+export function Identify({ onDone }: { onDone: () => void }) {
+ const [state, setState] = useState({ step: 'email' });
+ const [email, setEmail] = useState('');
+ const [code, setCode] = useState('');
+ const [error, setError] = useState('');
+ const [busy, setBusy] = useState(false);
+
+ const submitEmail = async () => {
+ if (busy) return;
+ setError('');
+ setBusy(true);
+ try {
+ const res = await api.identify(slug, email);
+ setState({
+ step: 'code',
+ codeId: res.codeId,
+ prototypeCode: res.prototypeCode,
+ email,
+ });
+ } catch (e) {
+ setError(errorMsg(e));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const submitCode = async () => {
+ if (state.step !== 'code') return;
+ if (busy) return;
+ setError('');
+ setBusy(true);
+ try {
+ const res = await api.verify(slug, state.codeId, code);
+ setToken(slug, res.token);
+ onDone();
+ } catch (e) {
+ setError(errorMsg(e));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ if (state.step === 'email') {
+ return (
+
+
+ Your email
+ setEmail(e.target.value)}
+ onKeyDown={(e) => e.key === 'Enter' && void submitEmail()}
+ />
+
+
+ {busy ? 'Sending…' : 'Email me a code'}
+
+ {error &&
{error}
}
+
+ );
+ }
+
+ return (
+
+
+ {state.prototypeCode ? (
+ <>
+ Your code: {state.prototypeCode}
+
+ (dev mode: this would be emailed to {state.email})
+ >
+ ) : (
+ <>
+ We emailed a 6-digit code to {state.email} .
+ >
+ )}
+
+
setCode(e.target.value)}
+ onKeyDown={(e) => e.key === 'Enter' && void submitCode()}
+ />
+
+ {busy ? 'Verifying…' : 'Verify'}
+
+ {error &&
{error}
}
+
+ );
+}
diff --git a/app/embed/MineTab.tsx b/app/embed/MineTab.tsx
new file mode 100644
index 0000000..52f9e28
--- /dev/null
+++ b/app/embed/MineTab.tsx
@@ -0,0 +1,59 @@
+import { useCallback } from 'react';
+import { formatShortDate } from '../../src/shared/index.js';
+import { api, getToken, isAuthExpired, setToken, type Booking } from '../shared-ui/api';
+import { useAsync } from '../shared-ui/useAsync';
+import { Identify } from './Identify';
+import { errorMsg, slug } from './shared';
+
+type MineOutcome =
+ { kind: 'ok'; bookings: Booking[] } | { kind: 'reauth' } | { kind: 'error'; message: string };
+
+export function MineTab() {
+ // Resolves to a settled outcome rather than throwing — an expired/invalid token degrades to
+ // re-identify (see server/lib/token.ts) instead of surfacing as a generic error.
+ const load = useCallback(async (): Promise => {
+ const token = getToken(slug);
+ if (!token) return { kind: 'reauth' };
+ try {
+ const res = await api.myBookings(slug, token);
+ return { kind: 'ok', bookings: res.bookings };
+ } catch (e) {
+ if (isAuthExpired(e)) {
+ setToken(slug, null);
+ return { kind: 'reauth' };
+ }
+ return { kind: 'error', message: errorMsg(e) };
+ }
+ }, []);
+
+ const { data: outcome, reload } = useAsync(load);
+
+ // Seeded from token presence until the first load settles, so there's no flash of the wrong
+ // view while `outcome` is still null.
+ const needIdentify = outcome ? outcome.kind === 'reauth' : !getToken(slug);
+ const error = outcome?.kind === 'error' ? outcome.message : '';
+ const bookings = outcome?.kind === 'ok' ? outcome.bookings : null;
+
+ if (needIdentify) return ;
+ if (error) return {error}
;
+ if (!bookings) return Loading…
;
+ if (bookings.length === 0) return No bookings yet — book one above!
;
+
+ return (
+
+ {bookings.map((b) => (
+
+
+ {b.type} {formatShortDate(b.startDate)}
+ {b.endDate ? ` – ${formatShortDate(b.endDate)}` : ''} ·{' '}
+ {b.pets.length > 0
+ ? b.pets.join(', ')
+ : `${b.petCount} pet${b.petCount === 1 ? '' : 's'}`}
+ {b.estCost != null ? ` · est. $${b.estCost}` : ''}
+
+ {b.status}
+
+ ))}
+
+ );
+}
diff --git a/app/embed/QuestionField.tsx b/app/embed/QuestionField.tsx
new file mode 100644
index 0000000..00dbfc2
--- /dev/null
+++ b/app/embed/QuestionField.tsx
@@ -0,0 +1,50 @@
+import { type ServiceQuestion } from '../shared-ui/api';
+
+export function QuestionField({
+ question,
+ value,
+ onChange,
+}: {
+ question: ServiceQuestion;
+ value: string;
+ onChange: (value: string) => void;
+}) {
+ const label = `${question.label}${question.required ? ' *' : ''}`;
+ if (question.type === 'yesno') {
+ return (
+
+ {label}
+ onChange(e.target.value)}>
+ Choose…
+ Yes
+ No
+
+
+ );
+ }
+ if (question.type === 'select') {
+ return (
+
+ {label}
+ onChange(e.target.value)}>
+ Choose…
+ {(question.options ?? []).map((o) => (
+
+ {o}
+
+ ))}
+
+
+ );
+ }
+ return (
+
+ {label}
+ onChange(e.target.value)}
+ />
+
+ );
+}
diff --git a/app/embed/shared.ts b/app/embed/shared.ts
new file mode 100644
index 0000000..f1642f5
--- /dev/null
+++ b/app/embed/shared.ts
@@ -0,0 +1,16 @@
+/** Widget tenant comes from the iframe path: /embed/:slug — never from the host page. */
+export const slug = window.location.pathname.split('/').filter(Boolean)[1] ?? '';
+
+export const errorMsg = (e: unknown): string => (e instanceof Error ? e.message : 'Try again.');
+
+/**
+ * Compute the host page's origin for postMessage targetOrigin validation.
+ * ponytail: referrer can be stripped by the host page's Referrer-Policy — '*' fallback is safe, payloads carry no secrets.
+ */
+export const parentOrigin = (() => {
+ try {
+ return new URL(document.referrer).origin;
+ } catch {
+ return '*';
+ }
+})();
diff --git a/app/shared-ui/api.ts b/app/shared-ui/api.ts
index 2ce5611..67a1dce 100644
--- a/app/shared-ui/api.ts
+++ b/app/shared-ui/api.ts
@@ -1,25 +1,12 @@
/** Tiny same-origin API client for the widget + admin pages. */
-export type ServiceOption = {
- optionKey: string;
- label: string;
- durationMinutes: number | null;
- rate: number;
- startTime: string | null;
- endTime: string | null;
- capacity: number | null;
-};
-export type ServiceQuestion = {
- id: string;
- label: string;
- type: 'text' | 'yesno' | 'number' | 'select';
- required: boolean;
- min?: number;
- max?: number;
- pattern?: string;
- options?: string[];
-};
-export type ServiceConfig = {
+import type { ServiceConstraints, ServiceOption, ServiceQuestion } from '../../src/shared/index.js';
+
+// Re-exported as-is: the widget/admin config wire format is field-for-field the shared shape —
+// see src/shared/booking/service-rules.ts for the single definition.
+export type { ServiceOption, ServiceQuestion };
+
+export type ServiceConfig = ServiceConstraints & {
type: string;
label: string;
shape: 'range' | 'single';
@@ -27,10 +14,6 @@ export type ServiceConfig = {
hasDuration: boolean;
options: ServiceOption[];
questions: ServiceQuestion[];
- minNights: number | null;
- maxNights: number | null;
- minPetCount: number | null;
- maxPetCount: number | null;
};
export type TenantConfig = {
slug: string;
@@ -40,11 +23,13 @@ export type TenantConfig = {
maxHouseSitsPerDay: number | null;
maxStayNights: number | null;
timezone: string | null;
+ contactEmail: string | null;
+ contactPhone: string | null;
petTypes: string[];
services: ServiceConfig[];
};
-export type Pet = { id: string; name: string; petType: 'dog' | 'cat' };
+export type Pet = { id: string; name: string; petType: 'dog' | 'cat'; notes?: string | null };
export type MonthDay = {
date: string;
status: 'available' | 'partial' | 'unavailable';
@@ -71,11 +56,27 @@ export type Customer = {
id: string;
email: string;
name: string | null;
+ phone: string | null;
status: 'invited' | 'active';
invitedAt?: string | null;
pets: Pet[];
};
+export type AdminBooking = {
+ id: string;
+ customerEmail: string | null;
+ customerName: string | null;
+ type: string;
+ startDate: string;
+ endDate: string | null;
+ startTime: string | null;
+ optionKey: string | null;
+ petCount: number;
+ estCost: number | null;
+ status: string;
+ createdAt: string;
+};
+
export class ApiError extends Error {
constructor(
public status: number,
@@ -170,24 +171,31 @@ export const adminApi = {
request<{ customers: Customer[] }>(`/api/${slug}/admin/customers`, {
headers: authHeaders(token),
}),
- add: (slug: string, token: string, email: string, name: string) =>
+ add: (slug: string, token: string, email: string, name: string, phone: string) =>
request<{ id: string; status: string }>(`/api/${slug}/admin/customers`, {
method: 'POST',
headers: { ...jsonHeaders, ...authHeaders(token) },
- body: JSON.stringify({ email, name }),
+ body: JSON.stringify({ email, name, phone }),
}),
remove: (slug: string, token: string, id: string) =>
request(`/api/${slug}/admin/customers/${id}`, {
method: 'DELETE',
headers: authHeaders(token),
}),
- addPet: (slug: string, token: string, endUserId: string, name: string, petType: string) =>
+ addPet: (
+ slug: string,
+ token: string,
+ endUserId: string,
+ name: string,
+ petType: string,
+ notes: string,
+ ) =>
request<{ id: string; name: string; petType: string }>(
`/api/${slug}/admin/customers/${endUserId}/pets`,
{
method: 'POST',
headers: { ...jsonHeaders, ...authHeaders(token) },
- body: JSON.stringify({ name, petType }),
+ body: JSON.stringify({ name, petType, notes }),
},
),
removePet: (slug: string, token: string, endUserId: string, petId: string) =>
@@ -196,6 +204,23 @@ export const adminApi = {
headers: authHeaders(token),
}),
},
+ bookings: {
+ list: (slug: string, token: string) =>
+ request<{ bookings: AdminBooking[] }>(`/api/${slug}/admin/bookings`, {
+ headers: authHeaders(token),
+ }),
+ setStatus: (
+ slug: string,
+ token: string,
+ id: string,
+ status: 'confirmed' | 'declined' | 'cancelled',
+ ) =>
+ request<{ status: string; notified: boolean }>(`/api/${slug}/admin/bookings/${id}/status`, {
+ method: 'POST',
+ headers: { ...jsonHeaders, ...authHeaders(token) },
+ body: JSON.stringify({ status }),
+ }),
+ },
calendar: {
start: (slug: string, token: string) =>
request<{ url: string }>(`/api/${slug}/admin/providers/calendar/oauth/start`, {
diff --git a/app/shared-ui/useAsync.ts b/app/shared-ui/useAsync.ts
new file mode 100644
index 0000000..10e6daa
--- /dev/null
+++ b/app/shared-ui/useAsync.ts
@@ -0,0 +1,63 @@
+import { useEffect, useState } from 'react';
+
+export interface AsyncResult {
+ data: T | null;
+ error: unknown;
+ loading: boolean;
+ reload: () => void;
+}
+
+/**
+ * Runs `fn` in an effect and exposes its settled result. Re-runs whenever `fn`'s identity
+ * changes — memoize it with `useCallback` listing its real dependencies, the same discipline
+ * `useEffect` deps already require — or whenever `reload()` is called. `reload()` is
+ * fire-and-forget: it schedules a re-run and returns immediately; observe completion through
+ * `loading`/`data`, not a promise.
+ *
+ * On failure, `error` is set but the last successful `data` is retained (stale-while-error),
+ * so a failed reload doesn't blank out data the user was already looking at. Callers that
+ * want blank-on-error can gate on `error` themselves.
+ *
+ * Replaces the repeated `let active = true` + cleanup-flag dance: a stale resolution (from a
+ * superseded `fn` or a reload that's since been superseded again) is detected the same way —
+ * by comparing the fn/reload the result belongs to against the current one — and is dropped
+ * instead of applied. setState is only ever called from inside the `.then`/`.catch` callbacks
+ * below, never synchronously in the effect body, so this satisfies
+ * react-hooks/set-state-in-effect without a suppression.
+ */
+export function useAsync(fn: () => Promise): AsyncResult {
+ const [reloadCount, setReloadCount] = useState(0);
+ const [settled, setSettled] = useState<{
+ forFn: (() => Promise) | null;
+ forReload: number;
+ data: T | null;
+ error: unknown;
+ }>({ forFn: null, forReload: -1, data: null, error: null });
+
+ useEffect(() => {
+ let active = true;
+ fn()
+ .then((data) => {
+ if (active) setSettled({ forFn: fn, forReload: reloadCount, data, error: null });
+ })
+ .catch((error: unknown) => {
+ // Keep prev.data: a failed refetch shows the error alongside the last-known data
+ // rather than wiping it (stale-while-error, see the doc comment).
+ if (active) {
+ setSettled((prev) => ({ forFn: fn, forReload: reloadCount, data: prev.data, error }));
+ }
+ });
+ return () => {
+ active = false;
+ };
+ }, [fn, reloadCount]);
+
+ const loading = settled.forFn !== fn || settled.forReload !== reloadCount;
+
+ return {
+ data: settled.data,
+ error: settled.error,
+ loading,
+ reload: () => setReloadCount((c) => c + 1),
+ };
+}
diff --git a/migrations/0002_tenant_config_limits.sql b/migrations/0002_tenant_config_limits.sql
index 832fea5..21e37a7 100644
--- a/migrations/0002_tenant_config_limits.sql
+++ b/migrations/0002_tenant_config_limits.sql
@@ -1,3 +1,6 @@
+-- WARNING: DATA-DESTRUCTIVE ON RE-RUN against a live DB — it rebuilds Tenants keeping only 6
+-- columns, wiping MaxHouseSitsPerDay/MaxStayNights/Timezone. Do not run this directly against
+-- an already-provisioned DB; see the baselining procedure in migrations/README.md first.
-- Make MaxBoardingPets nullable (NULL = unlimited) and add the new optional config columns.
-- SQLite cannot ALTER away a NOT NULL/DEFAULT, so rebuild Tenants preserving existing rows and
-- their MaxBoardingPets values (existing sitters keep their cap; only new tenants default to NULL).
diff --git a/migrations/0007_slot_index.sql b/migrations/0007_slot_index.sql
new file mode 100644
index 0000000..0faf1a5
--- /dev/null
+++ b/migrations/0007_slot_index.sql
@@ -0,0 +1,3 @@
+-- Covering index for the windowed-slot capacity path (countSlotBookings / listSlotBookingCounts).
+CREATE INDEX IF NOT EXISTS idx_BookingRequests_Slot
+ ON BookingRequests (TenantId, ServiceType, OptionKey, StartDate);
diff --git a/migrations/0008_contact_and_notes.sql b/migrations/0008_contact_and_notes.sql
new file mode 100644
index 0000000..8e321c6
--- /dev/null
+++ b/migrations/0008_contact_and_notes.sql
@@ -0,0 +1,9 @@
+-- UX round: business contact info, client phone, pet care notes, and a distinct
+-- "declined" marker for booking requests (kept as a flag beside the Status enum —
+-- widening the Status CHECK would need a full table rebuild).
+-- ponytail: fold Declined into Status if BookingRequests ever needs a rebuild migration anyway.
+ALTER TABLE Tenants ADD COLUMN ContactEmail TEXT;
+ALTER TABLE Tenants ADD COLUMN ContactPhone TEXT;
+ALTER TABLE EndUsers ADD COLUMN Phone TEXT;
+ALTER TABLE EndUserPets ADD COLUMN Notes TEXT;
+ALTER TABLE BookingRequests ADD COLUMN Declined INTEGER NOT NULL DEFAULT 0;
diff --git a/migrations/README.md b/migrations/README.md
new file mode 100644
index 0000000..a0bc0ae
--- /dev/null
+++ b/migrations/README.md
@@ -0,0 +1,68 @@
+# Migrations
+
+## Fresh installs
+
+Fresh databases are provisioned from `sql/schema.sql` + `sql/seed.sql`, **not** from this
+directory — `sql/schema.sql` is the canonical DDL and already includes everything through
+`0007_slot_index.sql`. Use:
+
+```
+npm run seed:local # wrangler d1 execute pawbook-db --local --file=./sql/schema.sql && ...seed.sql
+npm run seed:remote # same, against the remote DB
+```
+
+Do **not** run `migrate:local` / `migrate:remote` (`wrangler d1 migrations apply`) against a
+freshly-seeded DB — the tables already exist (from schema.sql), so replaying these files would
+fail or duplicate work.
+
+## Existing/already-provisioned DBs — READ BEFORE RUNNING `migrate:*`
+
+Every DB that was ever provisioned before wrangler's migration tracking was introduced (i.e.
+any real DB in use today) had `0001`–`0007` applied manually via `wrangler d1 execute ... --file`.
+None of those runs went through `wrangler d1 migrations apply`, so **no `d1_migrations` tracking
+table exists on any real DB**, and wrangler doesn't know these seven migrations were already
+applied.
+
+If you run `npm run migrate:local` / `migrate:remote` on one of these DBs without baselining
+first, wrangler will try to apply `0001_add_login_codes_attempts.sql` from scratch and fail
+(the `Attempts` column already exists) — and if that failure is ever worked around, `0002` is
+**data-destructive on re-run** (see the warning below). **Never run `migrate:remote` before
+baselining.**
+
+### Baselining procedure (run once, per DB, before ever running `migrate:*`)
+
+Create wrangler's tracking table and mark `0001`–`0007` as already applied, without actually
+executing their statements again:
+
+```sql
+CREATE TABLE IF NOT EXISTS d1_migrations (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT UNIQUE,
+ applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+INSERT INTO d1_migrations (name) VALUES ('0001_add_login_codes_attempts.sql');
+INSERT INTO d1_migrations (name) VALUES ('0002_tenant_config_limits.sql');
+INSERT INTO d1_migrations (name) VALUES ('0003_calendar_oauth_and_invites.sql');
+INSERT INTO d1_migrations (name) VALUES ('0004_customer_pets.sql');
+INSERT INTO d1_migrations (name) VALUES ('0005_service_rules.sql');
+INSERT INTO d1_migrations (name) VALUES ('0006_service_slots.sql');
+INSERT INTO d1_migrations (name) VALUES ('0007_slot_index.sql');
+```
+
+Run this against `--local` and/or `--remote` with `wrangler d1 execute pawbook-db --command "..."`
+(or save it to a `.sql` file and use `--file`) for whichever environment you're baselining.
+Verify the filenames above against the current contents of this directory before running —
+if new migration files have been added since this README was written, extend the list to
+match (`ls migrations/`).
+
+Once baselined, `migrate:local` / `migrate:remote` will only apply migrations numbered after
+`0007`, which is the intended, safe use of those scripts going forward.
+
+### ⚠️ `0002_tenant_config_limits.sql` is DATA-DESTRUCTIVE if ever re-run against a live DB
+
+It rebuilds the `Tenants` table, copying forward only 6 columns (`Id`, `Slug`, `DisplayName`,
+`AccentColor`, `MaxBoardingPets`, `CreatedAt`). Re-running it against a DB that already has real
+data in `MaxHouseSitsPerDay`, `MaxStayNights`, or `Timezone` **wipes those columns back to
+NULL** for every tenant. This is exactly what baselining (above) prevents — do the baseline
+INSERTs, never a real replay of `0002`, against any DB that already has this schema.
diff --git a/package.json b/package.json
index 309854c..d1fbb34 100644
--- a/package.json
+++ b/package.json
@@ -33,6 +33,8 @@
"deploy": "npm run build && wrangler deploy",
"seed:local": "wrangler d1 execute pawbook-db --local --file=./sql/schema.sql && wrangler d1 execute pawbook-db --local --file=./sql/seed.sql",
"seed:remote": "wrangler d1 execute pawbook-db --remote --file=./sql/schema.sql && wrangler d1 execute pawbook-db --remote --file=./sql/seed.sql",
+ "migrate:local": "wrangler d1 migrations apply pawbook-db --local",
+ "migrate:remote": "wrangler d1 migrations apply pawbook-db --remote",
"lint": "eslint .",
"format": "prettier --check .",
"format:fix": "prettier --write .",
diff --git a/server/__tests__/admin-bookings.test.ts b/server/__tests__/admin-bookings.test.ts
new file mode 100644
index 0000000..2c937a0
--- /dev/null
+++ b/server/__tests__/admin-bookings.test.ts
@@ -0,0 +1,215 @@
+import { describe, expect, it } from 'vitest';
+import app from '../index';
+import { adminHeaders, createTestEnv, endUserToken, TENANT_A, TENANT_B } from './helpers';
+
+/** Books one dog (Bella, sunny-paws) for a 2-night boarding stay via the real customer flow. */
+async function bookBoarding(
+ env: Env,
+ startDate: string,
+ endDate: string,
+ petIds: string[] = ['pet_sp_bella'],
+): Promise {
+ const token = await endUserToken(env, 'sunny-paws', 'jess@example.com');
+ return app.request(
+ '/api/sunny-paws/bookings',
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
+ body: JSON.stringify({ type: 'boarding', startDate, endDate, petIds }),
+ },
+ env,
+ );
+}
+
+describe('admin booking lifecycle', () => {
+ it('GET lists a created booking with customer email, excluding blocked rows', async () => {
+ const { env } = createTestEnv();
+ const created = (await (await bookBoarding(env, '2028-10-01', '2028-10-03')).json()) as {
+ id: string;
+ };
+
+ const res = await app.request(
+ '/api/sunny-paws/admin/bookings',
+ { headers: await adminHeaders(TENANT_A) },
+ env,
+ );
+ expect(res.status).toBe(200);
+ const { bookings } = (await res.json()) as {
+ bookings: { id: string; customerEmail: string | null; type: string; status: string }[];
+ };
+ const mine = bookings.find((b) => b.id === created.id);
+ expect(mine).toBeTruthy();
+ expect(mine?.customerEmail).toBe('jess@example.com');
+ expect(mine?.type).toBe('boarding');
+ expect(mine?.status).toBe('pending');
+ // The seeded blocked range for sunny-paws must never appear in the admin bookings list.
+ expect(bookings.some((b) => b.type === 'blocked')).toBe(false);
+ });
+
+ it('POST confirm moves pending -> confirmed, reflected on the next GET', async () => {
+ const { env } = createTestEnv();
+ const created = (await (await bookBoarding(env, '2028-10-05', '2028-10-07')).json()) as {
+ id: string;
+ };
+
+ const confirm = await app.request(
+ `/api/sunny-paws/admin/bookings/${created.id}/status`,
+ {
+ method: 'POST',
+ headers: { ...(await adminHeaders(TENANT_A)), 'Content-Type': 'application/json' },
+ body: JSON.stringify({ status: 'confirmed' }),
+ },
+ env,
+ );
+ expect(confirm.status).toBe(200);
+ expect(await confirm.json()).toEqual({ status: 'confirmed', notified: false });
+
+ const list = (await (
+ await app.request(
+ '/api/sunny-paws/admin/bookings',
+ { headers: await adminHeaders(TENANT_A) },
+ env,
+ )
+ ).json()) as { bookings: { id: string; status: string }[] };
+ expect(list.bookings.find((b) => b.id === created.id)?.status).toBe('confirmed');
+ });
+
+ it('POST decline is pending-only, listed as declined, and distinct from cancelled', async () => {
+ const { env } = createTestEnv();
+ const created = (await (await bookBoarding(env, '2028-11-01', '2028-11-03')).json()) as {
+ id: string;
+ };
+ const auth = { ...(await adminHeaders(TENANT_A)), 'Content-Type': 'application/json' };
+ const setStatus = (status: string) =>
+ app.request(
+ `/api/sunny-paws/admin/bookings/${created.id}/status`,
+ { method: 'POST', headers: auth, body: JSON.stringify({ status }) },
+ env,
+ );
+
+ const decline = await setStatus('declined');
+ expect(decline.status).toBe(200);
+ expect(await decline.json()).toEqual({ status: 'declined', notified: false });
+
+ const list = (await (
+ await app.request(
+ '/api/sunny-paws/admin/bookings',
+ { headers: await adminHeaders(TENANT_A) },
+ env,
+ )
+ ).json()) as { bookings: { id: string; status: string }[] };
+ expect(list.bookings.find((b) => b.id === created.id)?.status).toBe('declined');
+
+ // Declining a non-pending row never matches: it's already terminal here.
+ expect((await setStatus('declined')).status).toBe(404);
+ });
+
+ it('POST cancel on a confirmed booking cancels it; further status changes 404 (terminal)', async () => {
+ const { env } = createTestEnv();
+ const created = (await (await bookBoarding(env, '2028-10-08', '2028-10-10')).json()) as {
+ id: string;
+ };
+ const auth = { ...(await adminHeaders(TENANT_A)), 'Content-Type': 'application/json' };
+ const setStatus = (status: string) =>
+ app.request(
+ `/api/sunny-paws/admin/bookings/${created.id}/status`,
+ { method: 'POST', headers: auth, body: JSON.stringify({ status }) },
+ env,
+ );
+
+ expect((await setStatus('confirmed')).status).toBe(200);
+ const cancel = await setStatus('cancelled');
+ expect(cancel.status).toBe(200);
+ expect(await cancel.json()).toEqual({ status: 'cancelled', notified: false });
+
+ // Cancelled is terminal: even re-confirming the same row is rejected.
+ const again = await setStatus('confirmed');
+ expect(again.status).toBe(404);
+ });
+
+ it('POST with a bad status value is rejected with 400', async () => {
+ const { env } = createTestEnv();
+ const created = (await (await bookBoarding(env, '2028-10-12', '2028-10-14')).json()) as {
+ id: string;
+ };
+ const res = await app.request(
+ `/api/sunny-paws/admin/bookings/${created.id}/status`,
+ {
+ method: 'POST',
+ headers: { ...(await adminHeaders(TENANT_A)), 'Content-Type': 'application/json' },
+ body: JSON.stringify({ status: 'bogus' }),
+ },
+ env,
+ );
+ expect(res.status).toBe(400);
+ });
+
+ it('tenant isolation: another tenant admin cannot see or update tenant A bookings', async () => {
+ const { env } = createTestEnv();
+ const created = (await (await bookBoarding(env, '2028-10-16', '2028-10-18')).json()) as {
+ id: string;
+ };
+
+ // Tenant B's admin bookings list must not include tenant A's booking.
+ const listB = (await (
+ await app.request(
+ '/api/happy-tails/admin/bookings',
+ { headers: await adminHeaders(TENANT_B) },
+ env,
+ )
+ ).json()) as { bookings: { id: string }[] };
+ expect(listB.bookings.some((b) => b.id === created.id)).toBe(false);
+
+ // Tenant B's admin cannot confirm/cancel tenant A's booking either.
+ const crossUpdate = await app.request(
+ `/api/happy-tails/admin/bookings/${created.id}/status`,
+ {
+ method: 'POST',
+ headers: {
+ ...(await adminHeaders(TENANT_B)),
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ status: 'confirmed' }),
+ },
+ env,
+ );
+ expect(crossUpdate.status).toBe(404);
+ });
+
+ it('cancelling a booking releases capacity for a new booking on the same dates', async () => {
+ const { env } = createTestEnv();
+ // Sunny Paws is seeded with MaxBoardingPets=2. Use fresh dates so the seeded boarding row
+ // (2028-06-20..25) doesn't interfere. A 3-night span is used (not 2) so the middle night
+ // isn't a boundary day eligible for the engine's "soft bookend" endpoint-sharing rule —
+ // otherwise a same-range third request could dodge the capacity check entirely.
+ const start = '2028-10-20';
+ const end = '2028-10-23';
+
+ const first = await bookBoarding(env, start, end, ['pet_sp_bella']);
+ expect(first.status).toBe(201);
+ const firstId = ((await first.json()) as { id: string }).id;
+
+ const second = await bookBoarding(env, start, end, ['pet_sp_mochi']);
+ expect(second.status).toBe(201);
+
+ // At capacity (2/2) — a third booking on the same dates must be rejected.
+ const third = await bookBoarding(env, start, end, ['pet_sp_bella']);
+ expect(third.status).toBe(409);
+
+ // Cancel the first booking via the admin endpoint...
+ const cancel = await app.request(
+ `/api/sunny-paws/admin/bookings/${firstId}/status`,
+ {
+ method: 'POST',
+ headers: { ...(await adminHeaders(TENANT_A)), 'Content-Type': 'application/json' },
+ body: JSON.stringify({ status: 'cancelled' }),
+ },
+ env,
+ );
+ expect(cancel.status).toBe(200);
+
+ // ...and the same booking now succeeds, since capacity is back to 1/2.
+ const retry = await bookBoarding(env, start, end, ['pet_sp_bella']);
+ expect(retry.status).toBe(201);
+ });
+});
diff --git a/server/__tests__/admin.test.ts b/server/__tests__/admin.test.ts
index af91648..ade5eb6 100644
--- a/server/__tests__/admin.test.ts
+++ b/server/__tests__/admin.test.ts
@@ -1,6 +1,5 @@
import { describe, expect, it } from 'vitest';
import app from '../index';
-import { providerViews, type CapabilityDescriptor } from '../lib/providers';
import { mintToken } from '../lib/token';
import {
adminHeaders,
@@ -229,46 +228,14 @@ describe('tenant admin', () => {
expect(walkAfter.available).toBe(true);
});
- it('connecting a provider flips status to connected-stub, per tenant', async () => {
+ it('GET /admin/settings reports the calendar connection as disconnected by default', async () => {
const { env } = createTestEnv();
- await app.request(
- '/api/sunny-paws/admin/providers/calendar/connect',
- { method: 'POST', headers: await auth(TENANT_A) },
- env,
- );
- const a = (await (
+ const res = (await (
await app.request('/api/sunny-paws/admin/settings', { headers: await auth(TENANT_A) }, env)
- ).json()) as { providers: { capability: string; status: string }[] };
- const b = (await (
- await app.request('/api/happy-tails/admin/settings', { headers: await auth(TENANT_B) }, env)
- ).json()) as { providers: { capability: string; status: string }[] };
- expect(a.providers.find((p) => p.capability === 'calendar')?.status).toBe('connected-stub');
- expect(b.providers.find((p) => p.capability === 'calendar')?.status).toBe('disconnected');
-
- const unknown = await app.request(
- '/api/sunny-paws/admin/providers/teleportation/connect',
- { method: 'POST', headers: await auth(TENANT_A) },
- env,
- );
- expect(unknown.status).toBe(404);
- });
-
- it('adding a capability is a registry entry, not a schema change (FR18)', () => {
- const extended: CapabilityDescriptor[] = [
- { capability: 'payments', provider: 'stripe', label: 'Stripe', authMode: 'stub' },
- ];
- const views = providerViews([], extended);
- expect(views).toEqual([
- {
- capability: 'payments',
- provider: 'stripe',
- label: 'Stripe',
- authMode: 'stub',
- status: 'disconnected',
- connectedAt: null,
- calendarId: null,
- },
- ]);
+ ).json()) as {
+ calendar: { status: string; connectedAt: string | null; calendarId: string | null };
+ };
+ expect(res.calendar).toEqual({ status: 'disconnected', connectedAt: null, calendarId: null });
});
it('saves pet types and free-typed service options, reflected in config', async () => {
@@ -319,7 +286,38 @@ describe('tenant admin', () => {
expect(res.status).toBe(400);
});
- it('rejects duplicate durations within one service', async () => {
+ it('accepts two options sharing a duration but not a name, with distinct keys', async () => {
+ const { env } = createTestEnv();
+ const res = await app.request(
+ '/api/sunny-paws/admin/settings',
+ {
+ method: 'PUT',
+ headers: await auth(TENANT_A, true),
+ body: JSON.stringify({
+ services: [
+ {
+ type: 'checkin',
+ enabled: true,
+ options: [
+ { label: '30 minutes', durationMinutes: 30, rate: 18 },
+ { label: 'Puppy Check-in', durationMinutes: 30, rate: 22 },
+ ],
+ },
+ ],
+ }),
+ },
+ env,
+ );
+ expect(res.status).toBe(204);
+ const settings = (await (
+ await app.request('/api/sunny-paws/admin/settings', { headers: await auth(TENANT_A) }, env)
+ ).json()) as { services: { type: string; options: { optionKey: string; label: string }[] }[] };
+ const checkin = settings.services.find((s) => s.type === 'checkin')!;
+ expect(checkin.options.map((o) => o.label).sort()).toEqual(['30 minutes', 'Puppy Check-in']);
+ expect(new Set(checkin.options.map((o) => o.optionKey)).size).toBe(2);
+ });
+
+ it('rejects two options with the same name within one service', async () => {
const { env } = createTestEnv();
const res = await app.request(
'/api/sunny-paws/admin/settings',
@@ -333,7 +331,7 @@ describe('tenant admin', () => {
enabled: true,
options: [
{ label: '30 min', durationMinutes: 30, rate: 20 },
- { label: 'also 30', durationMinutes: 30, rate: 25 },
+ { label: '30 min', durationMinutes: 30, rate: 25 },
],
},
],
diff --git a/server/__tests__/availability.test.ts b/server/__tests__/availability.test.ts
index 247513d..bc14359 100644
--- a/server/__tests__/availability.test.ts
+++ b/server/__tests__/availability.test.ts
@@ -16,6 +16,8 @@ function tenant(over: Partial = {}): Tenant {
MaxHouseSitsPerDay: null,
MaxStayNights: null,
Timezone: null,
+ ContactEmail: null,
+ ContactPhone: null,
...over,
};
}
diff --git a/server/__tests__/booking-by-pets.test.ts b/server/__tests__/booking-by-pets.test.ts
index b112e9e..d574128 100644
--- a/server/__tests__/booking-by-pets.test.ts
+++ b/server/__tests__/booking-by-pets.test.ts
@@ -27,14 +27,16 @@ describe('booking by petIds', () => {
}),
});
expect(book.status).toBe(201);
+ const { id: bookedId } = (await book.json()) as { id: string };
const mine = (await (
await req(env, '/api/sunny-paws/bookings/mine', {
headers: { Authorization: `Bearer ${token}` },
})
).json()) as { bookings: { id: string; petCount: number; pets: string[] }[] };
- expect(mine.bookings[0].petCount).toBe(2);
- expect(mine.bookings[0].pets.sort()).toEqual(['Bella', 'Mochi']);
+ const created = mine.bookings.find((b) => b.id === bookedId)!;
+ expect(created.petCount).toBe(2);
+ expect(created.pets.sort()).toEqual(['Bella', 'Mochi']);
});
it('dedupes duplicate petIds in a booking', async () => {
@@ -52,14 +54,16 @@ describe('booking by petIds', () => {
}),
});
expect(book.status).toBe(201);
+ const { id: bookedId } = (await book.json()) as { id: string };
const mine = (await (
await req(env, '/api/sunny-paws/bookings/mine', {
headers: { Authorization: `Bearer ${token}` },
})
).json()) as { bookings: { id: string; petCount: number; pets: string[] }[] };
- expect(mine.bookings[0].petCount).toBe(1);
- expect(mine.bookings[0].pets).toEqual(['Bella']);
+ const created = mine.bookings.find((b) => b.id === bookedId)!;
+ expect(created.petCount).toBe(1);
+ expect(created.pets).toEqual(['Bella']);
});
it("rejects a pet the caller doesn't own", async () => {
diff --git a/server/__tests__/calendar-id.test.ts b/server/__tests__/calendar-id.test.ts
index d56caae..dd73807 100644
--- a/server/__tests__/calendar-id.test.ts
+++ b/server/__tests__/calendar-id.test.ts
@@ -93,10 +93,7 @@ describe('POST /:slug/admin/providers/calendar/calendar-id (route)', () => {
env,
);
expect(res.status).toBe(200);
- const body = (await res.json()) as {
- providers: { capability: string; calendarId: string | null }[];
- };
- const calendarProvider = body.providers.find((p) => p.capability === 'calendar');
- expect(calendarProvider?.calendarId).toBe(CAL_ID);
+ const body = (await res.json()) as { calendar: { calendarId: string | null } };
+ expect(body.calendar.calendarId).toBe(CAL_ID);
});
});
diff --git a/server/__tests__/google-calendar.test.ts b/server/__tests__/google-calendar.test.ts
index e550f76..68dad30 100644
--- a/server/__tests__/google-calendar.test.ts
+++ b/server/__tests__/google-calendar.test.ts
@@ -4,7 +4,6 @@ import {
buildEventResource,
createEvent,
exchangeCode,
- listCalendarEvents,
refreshAccessToken,
} from '../lib/google-calendar';
@@ -168,93 +167,4 @@ describe('google-calendar', () => {
expect(r.extendedProperties?.private.customerEmail).toBe('');
expect(r.extendedProperties?.private.pawbook).toBe('true');
});
-
- describe('listCalendarEvents', () => {
- it('normalizes an all-day event and a timed event', async () => {
- const fakeBody = {
- items: [
- {
- summary: 'Dog boarding',
- start: { date: '2030-06-01' },
- end: { date: '2030-06-04' },
- extendedProperties: {
- private: { pawbook: 'true', category: 'boarding', bookingId: 'bk-a' },
- },
- },
- {
- summary: 'Walk',
- start: { dateTime: '2030-06-05T09:30:00-07:00' },
- end: { dateTime: '2030-06-05T10:30:00-07:00' },
- extendedProperties: {
- private: { pawbook: 'true', category: 'walks', bookingId: 'bk-b' },
- },
- },
- ],
- };
- vi.spyOn(globalThis, 'fetch').mockResolvedValue(
- new Response(JSON.stringify(fakeBody), { status: 200 }),
- );
-
- const events = await listCalendarEvents(
- 'AT',
- 'primary',
- '2030-06-01T00:00:00Z',
- '2030-07-01T00:00:00Z',
- );
-
- expect(events).toHaveLength(2);
- // all-day event
- expect(events[0]).toEqual({
- summary: 'Dog boarding',
- start: '2030-06-01',
- end: '2030-06-04',
- private: { pawbook: 'true', category: 'boarding', bookingId: 'bk-a' },
- });
- // timed event — dateTime sliced to date part
- expect(events[1]).toEqual({
- summary: 'Walk',
- start: '2030-06-05',
- end: '2030-06-05',
- private: { pawbook: 'true', category: 'walks', bookingId: 'bk-b' },
- });
- });
-
- it('defaults to empty private map and empty summary when fields are absent', async () => {
- const fakeBody = {
- items: [{ start: { date: '2030-06-01' }, end: { date: '2030-06-02' } }],
- };
- vi.spyOn(globalThis, 'fetch').mockResolvedValue(
- new Response(JSON.stringify(fakeBody), { status: 200 }),
- );
-
- const events = await listCalendarEvents('AT', 'primary', '2030-06-01Z', '2030-07-01Z');
- expect(events[0].summary).toBe('');
- expect(events[0].private).toEqual({});
- });
-
- it('sends the correct query parameters and Authorization header', async () => {
- const spy = vi
- .spyOn(globalThis, 'fetch')
- .mockResolvedValue(new Response(JSON.stringify({ items: [] }), { status: 200 }));
-
- await listCalendarEvents('MY_TOKEN', 'cal@group.calendar.google.com', 'tMin', 'tMax');
-
- const [url, init] = spy.mock.calls[0] as [string, RequestInit];
- expect(url).toContain(encodeURIComponent('cal@group.calendar.google.com'));
- const parsed = new URL(url);
- expect(parsed.searchParams.get('timeMin')).toBe('tMin');
- expect(parsed.searchParams.get('timeMax')).toBe('tMax');
- expect(parsed.searchParams.get('singleEvents')).toBe('true');
- expect(parsed.searchParams.get('maxResults')).toBe('2500');
- expect(parsed.searchParams.get('orderBy')).toBe('startTime');
- expect((init.headers as Record)['Authorization']).toBe('Bearer MY_TOKEN');
- });
-
- it('throws on a non-2xx response', async () => {
- vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('no', { status: 403 }));
- await expect(
- listCalendarEvents('AT', 'primary', '2030-06-01Z', '2030-07-01Z'),
- ).rejects.toThrow('Google listCalendarEvents failed (403)');
- });
- });
});
diff --git a/server/__tests__/identify-email.test.ts b/server/__tests__/identify-email.test.ts
index c1763f7..ce969ec 100644
--- a/server/__tests__/identify-email.test.ts
+++ b/server/__tests__/identify-email.test.ts
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import app from '../index';
+import { createLoginCode } from '../db/repo';
import { createTestEnv } from './helpers';
/** When email is configured, /identify must email the code and never return it in the response. */
@@ -53,3 +54,48 @@ describe('identify with email configured', () => {
expect(body.prototypeCode).toBeUndefined();
});
});
+
+/** Verify that creating a new login code deletes expired codes but leaves valid ones intact. */
+describe('login code pruning on creation', () => {
+ it('deletes expired codes for the same tenant and keeps unexpired ones', async () => {
+ const { env, raw } = createTestEnv();
+ const tenantId = 'tnt_sunnypaws';
+ const endUserId = 'eu_sp_jess'; // seeded test user
+
+ // Set up: insert an already-expired code and an unexpired code for the same tenant.
+ const expiredCodeId = crypto.randomUUID();
+ const unexpiredCodeId = crypto.randomUUID();
+ const now = new Date();
+ const expiredTime = new Date(now.getTime() - 1000).toISOString(); // 1 second in the past
+ const unexpiredTime = new Date(now.getTime() + 60 * 60 * 1000).toISOString(); // 1 hour in future
+
+ raw.exec(`
+ INSERT INTO LoginCodes (Id, TenantId, EndUserId, Code, ExpiresAt)
+ VALUES ('${expiredCodeId}', '${tenantId}', '${endUserId}', 'expired123', '${expiredTime}')
+ `);
+ raw.exec(`
+ INSERT INTO LoginCodes (Id, TenantId, EndUserId, Code, ExpiresAt)
+ VALUES ('${unexpiredCodeId}', '${tenantId}', '${endUserId}', 'valid456', '${unexpiredTime}')
+ `);
+
+ // Create a new login code with a specific "now" that should prune the expired one.
+ const nowForPrune = new Date(now.getTime() + 500); // halfway between expired and unexpired
+ const newCodeId = await createLoginCode(
+ env.PAWBOOK_DB,
+ tenantId,
+ endUserId,
+ 'newcode789',
+ new Date(nowForPrune.getTime() + 10 * 60 * 1000).toISOString(), // 10 min from "now"
+ nowForPrune.toISOString(),
+ );
+
+ // Verify: expired code is gone, unexpired code and new code remain.
+ const expiredRow = raw.prepare('SELECT * FROM LoginCodes WHERE Id = ?').get(expiredCodeId);
+ const unexpiredRow = raw.prepare('SELECT * FROM LoginCodes WHERE Id = ?').get(unexpiredCodeId);
+ const newRow = raw.prepare('SELECT * FROM LoginCodes WHERE Id = ?').get(newCodeId);
+
+ expect(expiredRow).toBeUndefined();
+ expect(unexpiredRow).toBeDefined();
+ expect(newRow).toBeDefined();
+ });
+});
diff --git a/server/__tests__/isolation.test.ts b/server/__tests__/isolation.test.ts
index 0f235ae..c45ff35 100644
--- a/server/__tests__/isolation.test.ts
+++ b/server/__tests__/isolation.test.ts
@@ -27,7 +27,14 @@ describe('tenant isolation', () => {
it('READ: a booking created under tenant A never appears in tenant B queries', async () => {
const { env } = createTestEnv();
- const userA = await insertInvitedCustomer(env.PAWBOOK_DB, TENANT_A, 'jess@example.com', null);
+ // A fresh email — 'jess@example.com' is the seeded demo customer and now comes with
+ // seeded bookings under BOTH tenants.
+ const userA = await insertInvitedCustomer(
+ env.PAWBOOK_DB,
+ TENANT_A,
+ 'iso-read@example.com',
+ null,
+ );
await insertBookingRequest(env.PAWBOOK_DB, TENANT_A, {
endUserId: userA.Id,
serviceType: 'boarding',
@@ -68,8 +75,18 @@ describe('tenant isolation', () => {
it('LIST: my-bookings under the other tenant is empty for the same email', async () => {
const { env } = createTestEnv();
- const userA = await insertInvitedCustomer(env.PAWBOOK_DB, TENANT_A, 'jess@example.com', null);
- const userB = await insertInvitedCustomer(env.PAWBOOK_DB, TENANT_B, 'jess@example.com', null);
+ const userA = await insertInvitedCustomer(
+ env.PAWBOOK_DB,
+ TENANT_A,
+ 'iso-list@example.com',
+ null,
+ );
+ const userB = await insertInvitedCustomer(
+ env.PAWBOOK_DB,
+ TENANT_B,
+ 'iso-list@example.com',
+ null,
+ );
await insertBookingRequest(env.PAWBOOK_DB, TENANT_A, {
endUserId: userA.Id,
serviceType: 'walk',
diff --git a/server/__tests__/month-availability.test.ts b/server/__tests__/month-availability.test.ts
index 284d5c2..95dc357 100644
--- a/server/__tests__/month-availability.test.ts
+++ b/server/__tests__/month-availability.test.ts
@@ -1,52 +1,39 @@
-import { afterEach, describe, expect, it, vi } from 'vitest';
+import { describe, expect, it } from 'vitest';
import app from '../index';
-import { createTestEnv, TEST_SECRET, endUserToken } from './helpers';
-import { insertBookingRequest, setProviderTokens } from '../db/repo';
-import { encryptToken } from '../lib/token-crypto';
+import { createTestEnv, TENANT_A, endUserToken } from './helpers';
+import { insertBookingRequest } from '../db/repo';
import type { MonthDay } from '../lib/availability';
-const BOARDING_EVENT = {
- summary: 'Boarding: Bella',
- start: { date: '2026-10-20' },
- end: { date: '2026-10-21' },
- extendedProperties: {
- private: {
- pawbook: 'true',
- category: 'boarding',
- petCount: '1',
- customerEmail: 'jess@example.com',
- },
- },
-};
-
-const UNAVAILABLE_EVENT = {
- summary: 'Unavailable',
- start: { date: '2026-10-10' },
- end: { date: '2026-10-11' },
-};
-
-async function seedConnectedCalendar(env: Env) {
- await setProviderTokens(env.PAWBOOK_DB, 'tnt_sunnypaws', 'calendar', 'google-calendar', {
- access: await encryptToken(TEST_SECRET, 'fake-access'),
- refresh: await encryptToken(TEST_SECRET, 'fake-refresh'),
- expiresAt: '2099-01-01T00:00:00.000Z',
- calendarId: 'primary',
- });
-}
-
-function mockCalendarFetch(...items: object[]) {
- vi.spyOn(globalThis, 'fetch').mockResolvedValue(
- new Response(JSON.stringify({ items }), { status: 200 }),
- );
-}
+// Seeded in sql/seed.sql: Jess's fixed EndUserId for the Sunny Paws tenant.
+const JESS_END_USER_ID = 'eu_sp_jess';
describe('GET /api/:slug/availability/month', () => {
- afterEach(() => vi.restoreAllMocks());
-
- it('connected calendar: boarding — blocks, partial, available, mine', async () => {
+ it('D1 boarding booking: blocks, partial, available, mine', async () => {
const { env } = createTestEnv();
- await seedConnectedCalendar(env);
- mockCalendarFetch(BOARDING_EVENT, UNAVAILABLE_EVENT);
+ // A blocked day (no calendar involved — a plain 'blocked' BookingRequests row).
+ await insertBookingRequest(env.PAWBOOK_DB, TENANT_A, {
+ endUserId: null,
+ serviceType: 'blocked',
+ startDate: '2026-10-10',
+ endDate: '2026-10-11',
+ optionKey: null,
+ petType: null,
+ petCount: 1,
+ estCost: null,
+ status: 'confirmed',
+ });
+ // Jess's own confirmed boarding booking, one night.
+ await insertBookingRequest(env.PAWBOOK_DB, TENANT_A, {
+ endUserId: JESS_END_USER_ID,
+ serviceType: 'boarding',
+ startDate: '2026-10-20',
+ endDate: '2026-10-21',
+ optionKey: null,
+ petType: 'dog',
+ petCount: 1,
+ estCost: null,
+ status: 'confirmed',
+ });
const token = await endUserToken(env, 'sunny-paws', 'jess@example.com');
const res = await app.request(
@@ -65,7 +52,7 @@ describe('GET /api/:slug/availability/month', () => {
const d20 = body.days.find((d) => d.date === '2026-10-20')!;
expect(d20.status).toBe('partial');
expect(d20.used).toBe(1);
- expect(d20.max).toBe(2);
+ expect(d20.max).toBe(2); // Sunny Paws seeded MaxBoardingPets=2
expect(d20.mine).toBe(true);
const d15 = body.days.find((d) => d.date === '2026-10-15')!;
@@ -76,10 +63,30 @@ describe('GET /api/:slug/availability/month', () => {
expect(body.today).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
- it('connected calendar: walk — blocks propagate, boarding capacity ignored, max=null', async () => {
+ it('walk: blocks propagate, boarding capacity ignored, max=null', async () => {
const { env } = createTestEnv();
- await seedConnectedCalendar(env);
- mockCalendarFetch(BOARDING_EVENT, UNAVAILABLE_EVENT);
+ await insertBookingRequest(env.PAWBOOK_DB, TENANT_A, {
+ endUserId: null,
+ serviceType: 'blocked',
+ startDate: '2026-10-10',
+ endDate: '2026-10-11',
+ optionKey: null,
+ petType: null,
+ petCount: 1,
+ estCost: null,
+ status: 'confirmed',
+ });
+ await insertBookingRequest(env.PAWBOOK_DB, TENANT_A, {
+ endUserId: JESS_END_USER_ID,
+ serviceType: 'boarding',
+ startDate: '2026-10-20',
+ endDate: '2026-10-21',
+ optionKey: null,
+ petType: 'dog',
+ petCount: 1,
+ estCost: null,
+ status: 'confirmed',
+ });
const token = await endUserToken(env, 'sunny-paws', 'jess@example.com');
const res = await app.request(
@@ -100,11 +107,8 @@ describe('GET /api/:slug/availability/month', () => {
expect(d20.used).toBeNull();
});
- it('connected calendar: Google 500 → fail open, all available', async () => {
+ it('no bookings at all: every day available', async () => {
const { env } = createTestEnv();
- await seedConnectedCalendar(env);
- vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('Server Error', { status: 500 }));
-
const token = await endUserToken(env, 'sunny-paws', 'jess@example.com');
const res = await app.request(
'/api/sunny-paws/availability/month?type=boarding&month=2026-10',
@@ -118,10 +122,21 @@ describe('GET /api/:slug/availability/month', () => {
expect(body.days.every((d) => d.status === 'available')).toBe(true);
});
- it('not connected: returns all available without calling fetch', async () => {
+ it('regression: a confirmed D1 boarding booking filling capacity marks the day unavailable, with no calendar connected', async () => {
const { env } = createTestEnv();
- // No calendar seeded — connection absent
- const fetchSpy = vi.spyOn(globalThis, 'fetch');
+ // Sunny Paws MaxBoardingPets=2 — a 2-pet booking fills the day on its own. No ProviderConnections
+ // row is seeded for this tenant/capability, so there is no calendar connection whatsoever.
+ await insertBookingRequest(env.PAWBOOK_DB, TENANT_A, {
+ endUserId: null,
+ serviceType: 'boarding',
+ startDate: '2026-10-05',
+ endDate: '2026-10-06',
+ optionKey: null,
+ petType: 'dog',
+ petCount: 2,
+ estCost: null,
+ status: 'confirmed',
+ });
const token = await endUserToken(env, 'sunny-paws', 'jess@example.com');
const res = await app.request(
@@ -131,15 +146,11 @@ describe('GET /api/:slug/availability/month', () => {
);
expect(res.status).toBe(200);
- const body = (await res.json()) as { today: string; days: MonthDay[] };
- expect(body.days).toHaveLength(31);
- expect(body.days.every((d) => d.status === 'available')).toBe(true);
-
- // fetch must NOT have been called for calendar events
- const calendarCalls = fetchSpy.mock.calls.filter(([url]) =>
- String(url).includes('googleapis.com/calendar'),
- );
- expect(calendarCalls).toHaveLength(0);
+ const body = (await res.json()) as { days: MonthDay[] };
+ const d5 = body.days.find((d) => d.date === '2026-10-05')!;
+ expect(d5.status).toBe('unavailable');
+ expect(d5.used).toBe(2);
+ expect(d5.max).toBe(2);
});
it('walk with a capacity-limited option: full day is unavailable, independent of calendar connection', async () => {
@@ -206,21 +217,38 @@ describe('GET /api/:slug/availability/month', () => {
expect(res.status).toBe(400);
});
- it('marks a same-day timed booking (windowed walk) as "mine"', async () => {
- const { env } = createTestEnv();
- await seedConnectedCalendar(env);
- mockCalendarFetch({
- summary: 'Walk — jess@example.com (1 pet)',
- start: { dateTime: '2026-10-15T11:00:00-07:00' },
- end: { dateTime: '2026-10-15T14:00:00-07:00' },
- extendedProperties: {
- private: {
- pawbook: 'true',
- category: 'walk',
- petCount: '1',
- customerEmail: 'jess@example.com',
- },
- },
+ it('marks a same-day windowed walk booking as "mine"', async () => {
+ const { env, raw } = createTestEnv();
+ raw
+ .prepare(
+ `INSERT INTO TenantServiceOptions
+ (Id, TenantId, ServiceType, OptionKey, Label, DurationMinutes, Rate, RateUnit, StartTime, EndTime, Capacity)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ )
+ .run(
+ 'opt_test_afternoon',
+ 'tnt_sunnypaws',
+ 'walk',
+ 'afternoon-walk',
+ 'Afternoon Walk',
+ 180,
+ 25,
+ 'visit',
+ '11:00',
+ '14:00',
+ null,
+ );
+ await insertBookingRequest(env.PAWBOOK_DB, TENANT_A, {
+ endUserId: JESS_END_USER_ID,
+ serviceType: 'walk',
+ startDate: '2026-10-15',
+ endDate: null,
+ optionKey: 'afternoon-walk',
+ petType: 'dog',
+ petCount: 1,
+ startTime: '11:00',
+ estCost: null,
+ status: 'confirmed',
});
const token = await endUserToken(env, 'sunny-paws', 'jess@example.com');
diff --git a/server/db/repo.ts b/server/db/repo.ts
index bf8fc35..163a6ec 100644
--- a/server/db/repo.ts
+++ b/server/db/repo.ts
@@ -23,11 +23,17 @@ import { constantTimeEqual } from '../lib/timing';
*/
const TENANT_COLS =
- 'Id, Slug, DisplayName, AccentColor, MaxBoardingPets, MaxHouseSitsPerDay, MaxStayNights, Timezone';
+ 'Id, Slug, DisplayName, AccentColor, MaxBoardingPets, MaxHouseSitsPerDay, MaxStayNights, Timezone, ContactEmail, ContactPhone';
const BOOKING_COLS =
'Id, TenantId, EndUserId, ServiceType, StartDate, EndDate, StartTime, OptionKey, PetType, PetCount, EstCost, GCalEventId, Status, CreatedAt';
+/** BOOKING_COLS, table-qualified — needed once a query joins BookingRequests against another
+ * table (EndUsers) that shares column names like Id/TenantId, which would otherwise be ambiguous. */
+const BOOKING_COLS_QUALIFIED = BOOKING_COLS.split(', ')
+ .map((col) => `BookingRequests.${col}`)
+ .join(', ');
+
export async function getTenantBySlug(db: D1Database, slug: string): Promise {
return await db
.prepare(`SELECT ${TENANT_COLS} FROM Tenants WHERE Slug = ?`)
@@ -92,14 +98,20 @@ export async function createLoginCode(
endUserId: string,
code: string,
expiresAtIso: string,
+ nowIso: string = new Date().toISOString(),
): Promise {
const id = crypto.randomUUID();
- await db
- .prepare(
- 'INSERT INTO LoginCodes (Id, TenantId, EndUserId, Code, ExpiresAt) VALUES (?, ?, ?, ?, ?)',
- )
- .bind(id, tenantId, endUserId, code, expiresAtIso)
- .run();
+ // ponytail: opportunistic prune on each new code — a cron is overkill at this scale
+ await db.batch([
+ db
+ .prepare('DELETE FROM LoginCodes WHERE TenantId = ? AND ExpiresAt < ?')
+ .bind(tenantId, nowIso),
+ db
+ .prepare(
+ 'INSERT INTO LoginCodes (Id, TenantId, EndUserId, Code, ExpiresAt) VALUES (?, ?, ?, ?, ?)',
+ )
+ .bind(id, tenantId, endUserId, code, expiresAtIso),
+ ]);
return id;
}
@@ -165,6 +177,29 @@ export async function listCapacityRows(
return results;
}
+/**
+ * One end user's own pending/confirmed booking date ranges overlapping [from, to) — across
+ * EVERY service type (unlike listCapacityRows, which is boarding/house-sit/blocked only).
+ * Feeds the month grid's "mine" flag, so a walk/daycare/check-in booking still highlights.
+ */
+export async function listUserBookingDatesInRange(
+ db: D1Database,
+ tenantId: string,
+ endUserId: string,
+ fromDate: string,
+ toDateExclusive: string,
+): Promise<{ StartDate: string; EndDate: string | null }[]> {
+ const { results } = await db
+ .prepare(
+ `SELECT StartDate, EndDate FROM BookingRequests
+ WHERE TenantId = ? AND EndUserId = ? AND Status IN ('pending', 'confirmed')
+ AND StartDate < ? AND COALESCE(EndDate, StartDate) >= ?`,
+ )
+ .bind(tenantId, endUserId, toDateExclusive, fromDate)
+ .all<{ StartDate: string; EndDate: string | null }>();
+ return results;
+}
+
/**
* Count non-cancelled bookings against one option on one date — enforces a windowed option's
* Capacity. `excludeId` lets the post-insert race check ask "do I still fit, ignoring myself?",
@@ -276,7 +311,7 @@ export async function listBookingsForUser(
): Promise {
const { results } = await db
.prepare(
- `SELECT ${BOOKING_COLS}
+ `SELECT ${BOOKING_COLS}, Declined
FROM BookingRequests
WHERE TenantId = ? AND EndUserId = ?
ORDER BY StartDate DESC`,
@@ -286,6 +321,83 @@ export async function listBookingsForUser(
return results;
}
+/**
+ * All non-blocked bookings for the sitter's admin list, newest-first, with the customer's
+ * Email/Name joined in (NULL for a booking whose customer was later removed — EndUserId only
+ * ever points at a row in the SAME tenant, enforced by how bookings are created).
+ */
+export async function listBookingsForTenant(
+ db: D1Database,
+ tenantId: string,
+): Promise<(BookingRow & { Email: string | null; Name: string | null })[]> {
+ const { results } = await db
+ .prepare(
+ `SELECT ${BOOKING_COLS_QUALIFIED}, BookingRequests.Declined AS Declined,
+ EndUsers.Email AS Email, EndUsers.Name AS Name
+ FROM BookingRequests
+ LEFT JOIN EndUsers ON EndUsers.Id = BookingRequests.EndUserId
+ AND EndUsers.TenantId = BookingRequests.TenantId
+ WHERE BookingRequests.TenantId = ? AND BookingRequests.ServiceType != 'blocked'
+ ORDER BY BookingRequests.StartDate DESC, BookingRequests.CreatedAt DESC`,
+ )
+ .bind(tenantId)
+ .all();
+ return results;
+}
+
+/**
+ * Sitter-driven lifecycle transition. The guard is entirely in SQL so it's atomic with the write:
+ * 'blocked' rows aren't real bookings (never surfaced or manageable here), and 'cancelled' is
+ * terminal — once cancelled, no further transition matches. Confirming an already-confirmed row
+ * still matches (harmless no-op). Returns whether a row actually changed.
+ *
+ * 'declined' is a sitter's "no" to a still-pending request: stored as Status 'cancelled' with the
+ * Declined flag set (the Status CHECK can't grow a value without a table rebuild), and only valid
+ * from 'pending' — a confirmed booking is cancelled, never declined.
+ */
+export async function updateBookingStatus(
+ db: D1Database,
+ tenantId: string,
+ id: string,
+ status: 'confirmed' | 'cancelled' | 'declined',
+): Promise {
+ const result =
+ status === 'declined'
+ ? await db
+ .prepare(
+ `UPDATE BookingRequests SET Status = 'cancelled', Declined = 1
+ WHERE TenantId = ? AND Id = ? AND ServiceType != 'blocked' AND Status = 'pending'`,
+ )
+ .bind(tenantId, id)
+ .run()
+ : await db
+ .prepare(
+ `UPDATE BookingRequests SET Status = ?
+ WHERE TenantId = ? AND Id = ? AND ServiceType != 'blocked' AND Status != 'cancelled'`,
+ )
+ .bind(status, tenantId, id)
+ .run();
+ return (result.meta as { changes?: number }).changes !== 0;
+}
+
+/** One booking joined with its customer's contact details — for status-change notifications. */
+export async function getBookingWithCustomer(
+ db: D1Database,
+ tenantId: string,
+ id: string,
+): Promise<(BookingRow & { Email: string | null; Name: string | null }) | null> {
+ return await db
+ .prepare(
+ `SELECT ${BOOKING_COLS_QUALIFIED}, EndUsers.Email AS Email, EndUsers.Name AS Name
+ FROM BookingRequests
+ LEFT JOIN EndUsers ON EndUsers.Id = BookingRequests.EndUserId
+ AND EndUsers.TenantId = BookingRequests.TenantId
+ WHERE BookingRequests.TenantId = ? AND BookingRequests.Id = ?`,
+ )
+ .bind(tenantId, id)
+ .first();
+}
+
export async function listProviderConnections(
db: D1Database,
tenantId: string,
@@ -321,12 +433,15 @@ export async function updateTenantSettings(
maxHouseSitsPerDay: number | null;
maxStayNights: number | null;
timezone: string | null;
+ contactEmail?: string | null;
+ contactPhone?: string | null;
},
): Promise {
await db
.prepare(
`UPDATE Tenants SET DisplayName = ?, AccentColor = ?, MaxBoardingPets = ?,
- MaxHouseSitsPerDay = ?, MaxStayNights = ?, Timezone = ? WHERE Id = ?`,
+ MaxHouseSitsPerDay = ?, MaxStayNights = ?, Timezone = ?,
+ ContactEmail = ?, ContactPhone = ? WHERE Id = ?`,
)
.bind(
settings.displayName,
@@ -335,6 +450,8 @@ export async function updateTenantSettings(
settings.maxHouseSitsPerDay,
settings.maxStayNights,
settings.timezone,
+ settings.contactEmail ?? null,
+ settings.contactPhone ?? null,
tenantId,
)
.run();
@@ -547,7 +664,7 @@ export async function getEndUserById(
.first();
}
-const ENDUSER_COLS = 'Id, TenantId, Email, Name, Status, InvitedAt';
+const ENDUSER_COLS = 'Id, TenantId, Email, Name, Phone, Status, InvitedAt';
export async function getEndUserByEmail(
db: D1Database,
@@ -565,6 +682,7 @@ export async function insertInvitedCustomer(
tenantId: string,
email: string,
name: string | null,
+ phone: string | null = null,
): Promise {
const existing = await getEndUserByEmail(db, tenantId, email);
if (existing) return existing; // idempotent — never downgrade an active customer to invited
@@ -572,16 +690,17 @@ export async function insertInvitedCustomer(
const invitedAt = new Date().toISOString();
await db
.prepare(
- `INSERT INTO EndUsers (Id, TenantId, Email, Name, Status, InvitedAt)
- VALUES (?, ?, ?, ?, 'invited', ?)`,
+ `INSERT INTO EndUsers (Id, TenantId, Email, Name, Phone, Status, InvitedAt)
+ VALUES (?, ?, ?, ?, ?, 'invited', ?)`,
)
- .bind(id, tenantId, email, name, invitedAt)
+ .bind(id, tenantId, email, name, phone, invitedAt)
.run();
return {
Id: id,
TenantId: tenantId,
Email: email,
Name: name,
+ Phone: phone,
Status: 'invited',
InvitedAt: invitedAt,
};
@@ -636,32 +755,13 @@ export async function promoteCustomerActive(
.run();
}
-export async function setProviderStatus(
- db: D1Database,
- tenantId: string,
- capability: string,
- provider: string,
- status: 'connected-stub',
-): Promise {
- const connectedAt = new Date().toISOString();
- await db
- .prepare(
- `INSERT INTO ProviderConnections (Id, TenantId, Capability, Provider, Status, ConnectedAt)
- VALUES (?, ?, ?, ?, ?, ?)
- ON CONFLICT (TenantId, Capability)
- DO UPDATE SET Provider = excluded.Provider, Status = excluded.Status, ConnectedAt = excluded.ConnectedAt`,
- )
- .bind(crypto.randomUUID(), tenantId, capability, provider, status, connectedAt)
- .run();
-}
-
export async function listAllEndUserPetsByTenant(
db: D1Database,
tenantId: string,
): Promise {
const { results } = await db
.prepare(
- `SELECT Id, TenantId, EndUserId, Name, PetType, CreatedAt
+ `SELECT Id, TenantId, EndUserId, Name, PetType, Notes, CreatedAt
FROM EndUserPets WHERE TenantId = ? ORDER BY EndUserId, Name`,
)
.bind(tenantId)
@@ -676,7 +776,7 @@ export async function listEndUserPets(
): Promise {
const { results } = await db
.prepare(
- `SELECT Id, TenantId, EndUserId, Name, PetType, CreatedAt
+ `SELECT Id, TenantId, EndUserId, Name, PetType, Notes, CreatedAt
FROM EndUserPets WHERE TenantId = ? AND EndUserId = ? ORDER BY Name`,
)
.bind(tenantId, endUserId)
@@ -690,17 +790,18 @@ export async function addEndUserPet(
endUserId: string,
name: string,
petType: PetType,
+ notes: string | null = null,
): Promise {
const id = crypto.randomUUID();
await db
.prepare(
- `INSERT INTO EndUserPets (Id, TenantId, EndUserId, Name, PetType) VALUES (?, ?, ?, ?, ?)`,
+ `INSERT INTO EndUserPets (Id, TenantId, EndUserId, Name, PetType, Notes) VALUES (?, ?, ?, ?, ?, ?)`,
)
- .bind(id, tenantId, endUserId, name, petType)
+ .bind(id, tenantId, endUserId, name, petType, notes)
.run();
const row = await db
.prepare(
- `SELECT Id, TenantId, EndUserId, Name, PetType, CreatedAt FROM EndUserPets WHERE TenantId = ? AND Id = ?`,
+ `SELECT Id, TenantId, EndUserId, Name, PetType, Notes, CreatedAt FROM EndUserPets WHERE TenantId = ? AND Id = ?`,
)
.bind(tenantId, id)
.first();
diff --git a/server/lib/availability.ts b/server/lib/availability.ts
index b44e6e4..f94246c 100644
--- a/server/lib/availability.ts
+++ b/server/lib/availability.ts
@@ -11,13 +11,11 @@ import {
type CapacityLimits,
} from '../../src/shared/index.js';
import {
- getProviderConnection,
listCapacityRows,
countSlotBookings,
listSlotBookingCounts,
+ listUserBookingDatesInRange,
} from '../db/repo';
-import { getCalendarAccessToken } from './calendar-sync';
-import { categorizeCalendarEvent, listCalendarEvents } from './google-calendar';
import { SERVICE_CATALOG, type ServiceType } from '../lib/services';
import type { BookingRow, Tenant, TenantServiceOption } from '../types';
@@ -179,16 +177,17 @@ export type MonthDay = {
};
/**
- * Per-day availability for a calendar month, sourced exclusively from the tenant's Google Calendar.
- * Boarding/Unavailable events are categorized via extendedProperties metadata and summary parsing,
- * then fed through the shared capacity engine to produce each day's status.
+ * Per-day availability for a calendar month, sourced from D1 — the same authoritative store
+ * `checkAvailability` reads via `listCapacityRows`, so the month grid can never show a day as
+ * open that the booking check would then reject (or vice versa). Google Calendar is a one-way
+ * sync TARGET only (`calendar-sync.ts`); it is never read back here.
*/
export async function monthAvailability(
env: Env,
tenant: Tenant,
serviceType: ServiceType,
month: string, // YYYY-MM
- callerEmail: string,
+ callerEndUserId: string,
option: TenantServiceOption | null = null,
): Promise<{ today: string; days: MonthDay[] }> {
const today = getPacificDateStr(new Date(), tenant.Timezone ?? DEFAULT_TIMEZONE);
@@ -199,18 +198,15 @@ export async function monthAvailability(
const daysInMonth = new Date(Number(yearStr), Number(monStr), 0).getDate();
const lastDay = addDays(monthStart, daysInMonth - 1);
const monthEndExclusive = addDays(lastDay, 1);
- const timeMin = `${addDays(monthStart, -1)}T00:00:00Z`;
- const timeMax = `${addDays(monthEndExclusive, 1)}T00:00:00Z`;
const requestType: 'boarding' | 'house-sit' | null =
serviceType === 'housesitting' || serviceType === 'boarding'
? serviceTypeToCapacityType(serviceType)
: null;
- // Slot capacity is DB-sourced (our own bookings) and independent of the Google Calendar work
- // below — fetched ONCE for the whole grid (not per day), matching buildCapacity's "build the
- // map once" pattern, and run concurrently with the calendar fetch rather than after it, since
- // neither depends on the other's result.
+ // Slot capacity is fetched ONCE for the whole grid (not per day), matching buildCapacity's
+ // "build the map once" pattern, and run concurrently with the other D1 reads since none of
+ // them depend on each other's result.
const capacityLimit = requestType === null ? (option?.Capacity ?? null) : null;
const slotCountsPromise =
capacityLimit !== null
@@ -224,64 +220,28 @@ export async function monthAvailability(
)
: Promise.resolve(null);
- const conn = await getProviderConnection(env.PAWBOOK_DB, tenant.Id, 'calendar');
+ const [capacityRows, slotCounts, mineRows] = await Promise.all([
+ listCapacityRows(env.PAWBOOK_DB, tenant.Id, monthStart, monthEndExclusive),
+ slotCountsPromise,
+ listUserBookingDatesInRange(
+ env.PAWBOOK_DB,
+ tenant.Id,
+ callerEndUserId,
+ monthStart,
+ monthEndExclusive,
+ ),
+ ]);
- const capacityEvents: CapacityEvent[] = [];
const mineDays = new Set();
-
- const calendarWork = (async () => {
- try {
- if (conn && conn.Status === 'connected' && conn.AccessToken && conn.RefreshToken) {
- const accessToken = await getCalendarAccessToken(env, tenant, conn);
- const events = await listCalendarEvents(
- accessToken,
- conn.CalendarId ?? 'primary',
- timeMin,
- timeMax,
- );
-
- for (const ev of events) {
- const result = categorizeCalendarEvent(ev);
- if (result.kind === 'booking') {
- const { category, petCount, email } = result;
- if (category === 'boarding') {
- capacityEvents.push({
- start_date: ev.start,
- end_date: ev.end,
- type: 'boarding',
- petCount,
- });
- } else if (category === 'housesitting') {
- capacityEvents.push({ start_date: ev.start, end_date: ev.end, type: 'house-sit' });
- }
- // walk/daycare/checkin: no capacity event
- if (email === callerEmail) {
- // A same-day timed event (a windowed booking) normalizes start===end after
- // date-only slicing — the exclusive-end loop below would never run for it, so
- // handle that case directly instead of falling through to the multi-day loop.
- if (ev.start === ev.end) {
- mineDays.add(ev.start);
- } else {
- for (let d = ev.start; d < ev.end; d = addDays(d, 1)) {
- mineDays.add(d);
- }
- }
- }
- } else if (result.kind === 'block') {
- capacityEvents.push({ start_date: ev.start, end_date: ev.end, type: 'blocked' });
- }
- // kind === 'ignore': skip
- }
- }
- } catch {
- // Calendar read failed (e.g. Google 5xx, token refresh error). Fail open: treat as if
- // no calendar is connected so the widget stays usable. All days will appear available.
+ for (const row of mineRows) {
+ // Single-day bookings (walk/daycare/check-in) store EndDate = null; treat as a one-day span.
+ const end = row.EndDate ?? addDays(row.StartDate, 1);
+ for (let d = row.StartDate; d < end; d = addDays(d, 1)) {
+ mineDays.add(d);
}
- })();
-
- const [slotCounts] = await Promise.all([slotCountsPromise, calendarWork]);
+ }
- const cap = buildCapacity(capacityEvents);
+ const cap = buildCapacity(rowsToCapacityEvents(capacityRows));
const days: MonthDay[] = [];
for (let i = 0; i < daysInMonth; i++) {
diff --git a/server/lib/email.ts b/server/lib/email.ts
index 4f1db7c..a4332f6 100644
--- a/server/lib/email.ts
+++ b/server/lib/email.ts
@@ -44,6 +44,26 @@ export async function sendLoginCode(env: Env, to: string, code: string): Promise
});
}
+/**
+ * Tell the customer their request was confirmed/declined or their booking cancelled.
+ * Throws if email is not configured or Resend rejects the request.
+ */
+export async function sendBookingStatusEmail(
+ env: Env,
+ to: string,
+ displayName: string,
+ statusWord: 'confirmed' | 'declined' | 'cancelled',
+ whenText: string,
+): Promise {
+ if (!isEmailConfigured(env)) throw new Error('Email is not configured.');
+ await resendPost(env, {
+ to,
+ subject: `Your booking with ${displayName} was ${statusWord}`,
+ text: `${displayName} has ${statusWord} your booking (${whenText}).`,
+ html: `${htmlEscape(displayName)} has ${statusWord} your booking (${htmlEscape(whenText)}).
`,
+ });
+}
+
/** Send a booking invite. Throws if email is not configured or Resend rejects the request. */
export async function sendInvite(
env: Env,
diff --git a/server/lib/google-calendar.ts b/server/lib/google-calendar.ts
index d89e842..639a6cd 100644
--- a/server/lib/google-calendar.ts
+++ b/server/lib/google-calendar.ts
@@ -133,13 +133,6 @@ type EventResource = {
extendedProperties?: { private: Record };
};
-export type CalendarEvent = {
- summary: string;
- start: string; // 'YYYY-MM-DD' (all-day) or the date part of a dateTime
- end: string; // exclusive end date, same normalization
- private: Record; // extendedProperties.private, or {}
-};
-
function addMinutesToLocal(date: string, time: string, minutes: number): string {
// Treat the wall-clock value as UTC purely for arithmetic; the timeZone field carries the real
// zone, so adding minutes here yields the correct local end time (even across an hour/day roll).
@@ -183,59 +176,3 @@ export function buildEventResource(b: CalendarBooking): EventResource {
extendedProperties,
};
}
-
-export const BLOCK_EVENT_SUMMARY = 'unavailable';
-
-export function categorizeCalendarEvent(
- event: CalendarEvent,
-):
- | { kind: 'booking'; category: string; petCount: number; email: string }
- | { kind: 'block' }
- | { kind: 'ignore' } {
- if (event.private.pawbook === 'true') {
- return {
- kind: 'booking',
- category: event.private.category,
- petCount: Number(event.private.petCount) || 1,
- email: event.private.customerEmail ?? '',
- };
- }
- if (event.summary.trim().toLowerCase() === BLOCK_EVENT_SUMMARY) {
- return { kind: 'block' };
- }
- return { kind: 'ignore' };
-}
-
-export async function listCalendarEvents(
- accessToken: string,
- calendarId: string,
- timeMinISO: string,
- timeMaxISO: string,
-): Promise {
- const params = new URLSearchParams({
- timeMin: timeMinISO,
- timeMax: timeMaxISO,
- singleEvents: 'true',
- maxResults: '2500',
- orderBy: 'startTime',
- });
- const res = await fetch(
- `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events?${params.toString()}`,
- { headers: { Authorization: `Bearer ${accessToken}` } },
- );
- if (!res.ok) throw new Error(`Google listCalendarEvents failed (${res.status})`);
- const j = (await res.json()) as {
- items: Array<{
- summary?: string;
- start: { date?: string; dateTime?: string };
- end: { date?: string; dateTime?: string };
- extendedProperties?: { private?: Record };
- }>;
- };
- return (j.items ?? []).map((item) => ({
- summary: item.summary ?? '',
- start: item.start.date ?? item.start.dateTime?.slice(0, 10) ?? '',
- end: item.end.date ?? item.end.dateTime?.slice(0, 10) ?? '',
- private: item.extendedProperties?.private ?? {},
- }));
-}
diff --git a/server/lib/providers.ts b/server/lib/providers.ts
index 38a2876..9a6dda9 100644
--- a/server/lib/providers.ts
+++ b/server/lib/providers.ts
@@ -1,51 +1,22 @@
import type { ProviderConnection } from '../types';
/**
- * Capability→adapter registry (mirrors the shelved Plan 4 shape): adding a provider is a
- * registry entry, never a schema migration. All adapters are STUBS in the prototype — the
- * connect flow flips persisted status only; real OAuth is a graduation task (PRD FR18).
+ * Google Calendar is the only real integration — it connects via the OAuth routes in
+ * server/routes/admin.ts (start/disconnect/calendar-id), not a generic registry.
*/
-export type CapabilityDescriptor = {
- capability: string;
- provider: string;
- label: string;
- authMode: 'oauth' | 'stub';
-};
-
-export const CAPABILITIES: readonly CapabilityDescriptor[] = [
- {
- capability: 'calendar',
- provider: 'google-calendar',
- label: 'Google Calendar',
- authMode: 'oauth',
- },
- { capability: 'crm', provider: 'notion', label: 'Notion', authMode: 'stub' },
- { capability: 'email', provider: 'gmail', label: 'Gmail', authMode: 'stub' },
-];
-
-export type ProviderView = CapabilityDescriptor & {
+export type CalendarView = {
status: 'disconnected' | 'connected-stub' | 'connected';
connectedAt: string | null;
calendarId: string | null;
};
-/** Merge the static registry with a tenant's persisted connection rows. */
-export function providerViews(
- connections: ProviderConnection[],
- registry: readonly CapabilityDescriptor[] = CAPABILITIES,
-): ProviderView[] {
- return registry.map((descriptor) => {
- const row = connections.find((c) => c.Capability === descriptor.capability);
- return {
- ...descriptor,
- status: row?.Status ?? 'disconnected',
- connectedAt: row?.ConnectedAt ?? null,
- calendarId: row?.CalendarId ?? null,
- };
- });
-}
-
-export function findCapability(capability: string): CapabilityDescriptor | undefined {
- return CAPABILITIES.find((c) => c.capability === capability);
+/** Project a tenant's persisted connection rows down to the calendar connection's view. */
+export function calendarView(connections: ProviderConnection[]): CalendarView {
+ const row = connections.find((c) => c.Capability === 'calendar');
+ return {
+ status: row?.Status ?? 'disconnected',
+ connectedAt: row?.ConnectedAt ?? null,
+ calendarId: row?.CalendarId ?? null,
+ };
}
diff --git a/server/routes/admin.ts b/server/routes/admin.ts
index dec4dc4..c76ef44 100644
--- a/server/routes/admin.ts
+++ b/server/routes/admin.ts
@@ -5,6 +5,7 @@ import {
clearProviderConnection,
countBookingPetRefs,
countBookingsForUser,
+ getBookingWithCustomer,
getEndUserById,
deleteBlockedRange,
deleteCustomer,
@@ -13,6 +14,7 @@ import {
insertInvitedCustomer,
listAllEndUserPetsByTenant,
listBlockedRanges,
+ listBookingsForTenant,
listCustomers,
listPetTypes,
listProviderConnections,
@@ -22,15 +24,15 @@ import {
replaceServiceOptions,
setProviderCalendarId,
setPetTypeEnabled,
- setProviderStatus,
setServiceConfig,
+ updateBookingStatus,
updateTenantSettings,
} from '../db/repo';
-import { isEmailConfigured, sendInvite } from '../lib/email';
+import { isEmailConfigured, sendBookingStatusEmail, sendInvite } from '../lib/email';
import { buildAuthUrl, revokeToken } from '../lib/google-calendar';
import { adminAuth } from '../lib/middleware';
import { signState } from '../lib/oauth-state';
-import { findCapability, providerViews } from '../lib/providers';
+import { calendarView } from '../lib/providers';
import { embedSnippets } from '../lib/snippet';
import { isPetType, isServiceType, PET_TYPES, SERVICE_CATALOG } from '../lib/services';
import { decryptToken } from '../lib/token-crypto';
@@ -140,9 +142,23 @@ function resolveServiceOptions(
existingKeys: Set,
): { error: string } | { resolved: ResolvedOption[] } {
const resolved: ResolvedOption[] = [];
+ // Duplicate names are the only collision a sitter should ever have to fix by hand — keys are
+ // derived plumbing and are de-duped automatically below (two same-duration options are fine).
+ const seenLabels = new Set();
+ // Keys already claimed in this payload: preserved keys up front (order-independent), fresh
+ // derivations as they're assigned.
+ const usedKeys = new Set(
+ opts.map((o) => o.optionKey).filter((k): k is string => k !== undefined && existingKeys.has(k)),
+ );
for (const o of opts) {
const label = o.label?.trim();
- if (!label) return { error: `${serviceLabel}: every option needs a label.` };
+ if (!label) return { error: `${serviceLabel}: every option needs a name.` };
+ const labelKey = label.toLowerCase();
+ if (seenLabels.has(labelKey))
+ return {
+ error: `${serviceLabel}: two options are both named “${label}” — give each option a different name.`,
+ };
+ seenLabels.add(labelKey);
if (!isValidRate(o.rate)) return { error: 'Rates must be whole dollars ≥ 1.' };
const hasStart = o.startTime !== undefined && o.startTime !== null;
@@ -175,12 +191,18 @@ function resolveServiceOptions(
? `d${durationMinutes}`
: 'standard';
const preserveExisting = o.optionKey !== undefined && existingKeys.has(o.optionKey);
- const optionKey = preserveExisting ? (o.optionKey as string) : derivedKey;
// A label that's entirely punctuation/whitespace after the non-empty check above still
// slugifies to '' (e.g. "---") — treat that the same as no usable label. Only relevant when
// a fresh key is actually being derived; a preserved key is already known-valid.
if (windowed && !preserveExisting && derivedKey === '')
- return { error: `${serviceLabel}: that label has no usable letters or numbers.` };
+ return { error: `${serviceLabel}: that name has no usable letters or numbers.` };
+ let optionKey = preserveExisting ? (o.optionKey as string) : derivedKey;
+ if (!preserveExisting) {
+ // Two options may legitimately derive the same key (e.g. two 30-minute check-ins with
+ // different names/rates) — suffix until unique instead of bouncing the save back.
+ for (let n = 2; usedKeys.has(optionKey); n++) optionKey = `${derivedKey}-${n}`;
+ usedKeys.add(optionKey);
+ }
resolved.push({
optionKey,
@@ -192,13 +214,13 @@ function resolveServiceOptions(
capacity: o.capacity ?? null,
});
}
- if (meta.hasDuration) {
- const keys = resolved.map((o) => o.optionKey);
- if (new Set(keys).size !== keys.length)
- return {
- error: `${serviceLabel}: two options have the same name — use distinct labels for each service option.`,
- };
- }
+ // Backstop only: fresh keys are de-duped above, so this can fire only when two options in the
+ // payload name the SAME saved optionKey (a stale/duplicated client state).
+ const keys = resolved.map((o) => o.optionKey);
+ if (new Set(keys).size !== keys.length)
+ return {
+ error: `${serviceLabel}: two options point at the same saved option — reload the page and try again.`,
+ };
return { resolved };
}
@@ -247,6 +269,8 @@ type SettingsBody = {
maxHouseSitsPerDay?: number | null;
maxStayNights?: number | null;
timezone?: string | null;
+ contactEmail?: string | null;
+ contactPhone?: string | null;
petTypes?: string[];
services?: ServiceBody[];
};
@@ -258,7 +282,13 @@ type SettingsBody = {
*/
function patchNullable(
body: SettingsBody,
- key: 'maxBoardingPets' | 'maxHouseSitsPerDay' | 'maxStayNights' | 'timezone',
+ key:
+ | 'maxBoardingPets'
+ | 'maxHouseSitsPerDay'
+ | 'maxStayNights'
+ | 'timezone'
+ | 'contactEmail'
+ | 'contactPhone',
current: T | null,
): T | null {
return key in body ? ((body[key] as T | null | undefined) ?? null) : current;
@@ -284,6 +314,8 @@ export const adminRoutes = new Hono()
maxHouseSitsPerDay: tenant.MaxHouseSitsPerDay,
maxStayNights: tenant.MaxStayNights,
timezone: tenant.Timezone,
+ contactEmail: tenant.ContactEmail,
+ contactPhone: tenant.ContactPhone,
petTypes: PET_TYPES.map((pt) => ({
petType: pt,
enabled: petTypes.some((p) => p.PetType === pt && p.Enabled),
@@ -316,17 +348,7 @@ export const adminRoutes = new Hono()
};
}),
blocked: blocked.map((b) => ({ id: b.Id, startDate: b.StartDate, endDate: b.EndDate })),
- providers: providerViews(connections).map(
- ({ capability, provider, label, authMode, status, connectedAt, calendarId }) => ({
- capability,
- provider,
- label,
- authMode,
- status,
- connectedAt,
- calendarId,
- }),
- ),
+ calendar: calendarView(connections),
});
})
@@ -346,6 +368,11 @@ export const adminRoutes = new Hono()
);
const maxStayNights = patchNullable(body, 'maxStayNights', tenant.MaxStayNights);
const timezone = patchNullable(body, 'timezone', tenant.Timezone);
+ // Whitespace-only contact fields mean "cleared" — store NULL, not ''.
+ const rawContactEmail = patchNullable(body, 'contactEmail', tenant.ContactEmail);
+ const contactEmail = rawContactEmail?.trim() || null;
+ const rawContactPhone = patchNullable(body, 'contactPhone', tenant.ContactPhone);
+ const contactPhone = rawContactPhone?.trim() || null;
const petTypes = body.petTypes;
const services = body.services ?? [];
// Per-service PATCH semantics for questions/constraints (mirrors patchNullable above): a field
@@ -383,6 +410,10 @@ export const adminRoutes = new Hono()
400,
);
if (!isValidTimezone(timezone)) return c.json({ error: 'Unknown timezone.' }, 400);
+ if (contactEmail !== null && !EMAIL_RE.test(contactEmail))
+ return c.json({ error: 'Contact email must be a valid email address.' }, 400);
+ if (contactPhone !== null && contactPhone.length > 40)
+ return c.json({ error: 'Contact phone is too long.' }, 400);
if (petTypes !== undefined) {
if (!Array.isArray(petTypes) || !petTypes.every(isPetType))
return c.json({ error: 'Unknown pet type.' }, 400);
@@ -436,6 +467,8 @@ export const adminRoutes = new Hono()
maxHouseSitsPerDay,
maxStayNights,
timezone,
+ contactEmail,
+ contactPhone,
});
if (petTypes !== undefined) {
for (const pt of PET_TYPES)
@@ -498,7 +531,7 @@ export const adminRoutes = new Hono()
const start = typeof body.startDate === 'string' ? body.startDate : '';
const end = typeof body.endDate === 'string' ? body.endDate : '';
if (!isRealDate(start) || !isRealDate(end) || end <= start)
- return c.json({ error: 'Provide a valid range (end is exclusive).' }, 400);
+ return c.json({ error: 'Provide a valid date range.' }, 400);
const id = await insertBookingRequest(c.env.PAWBOOK_DB, tenant.Id, {
endUserId: null,
serviceType: 'blocked',
@@ -525,20 +558,6 @@ export const adminRoutes = new Hono()
return c.json(embedSnippets(new URL(c.req.url).origin, tenant.Slug));
})
- .post('/:slug/admin/providers/:capability/connect', async (c) => {
- const tenant = c.get('tenant');
- const descriptor = findCapability(c.req.param('capability'));
- if (!descriptor) return c.json({ error: 'Unknown capability.' }, 404);
- await setProviderStatus(
- c.env.PAWBOOK_DB,
- tenant.Id,
- descriptor.capability,
- descriptor.provider,
- 'connected-stub',
- );
- return c.json({ status: 'connected-stub' });
- })
-
.get('/:slug/admin/providers/calendar/oauth/start', async (c) => {
const tenant = c.get('tenant');
if (!c.env.GOOGLE_CLIENT_ID || !c.env.GOOGLE_CLIENT_SECRET || !c.env.GOOGLE_OAUTH_REDIRECT_URI)
@@ -593,16 +612,20 @@ export const adminRoutes = new Hono()
listCustomers(c.env.PAWBOOK_DB, tenant.Id),
listAllEndUserPetsByTenant(c.env.PAWBOOK_DB, tenant.Id),
]);
- const byUser = new Map();
+ const byUser = new Map<
+ string,
+ { id: string; name: string; petType: string; notes: string | null }[]
+ >();
for (const p of allPets) {
const list = byUser.get(p.EndUserId) ?? [];
- list.push({ id: p.Id, name: p.Name, petType: p.PetType });
+ list.push({ id: p.Id, name: p.Name, petType: p.PetType, notes: p.Notes });
byUser.set(p.EndUserId, list);
}
const withPets = customers.map((u) => ({
id: u.Id,
email: u.Email,
name: u.Name,
+ phone: u.Phone,
status: u.Status,
invitedAt: u.InvitedAt,
pets: byUser.get(u.Id) ?? [],
@@ -613,14 +636,17 @@ export const adminRoutes = new Hono()
.post('/:slug/admin/customers', async (c) => {
const tenant = c.get('tenant');
const body = await c.req
- .json<{ email?: unknown; name?: unknown }>()
- .catch(() => ({}) as { email?: unknown; name?: unknown });
+ .json<{ email?: unknown; name?: unknown; phone?: unknown }>()
+ .catch(() => ({}) as { email?: unknown; name?: unknown; phone?: unknown });
const email = typeof body.email === 'string' ? body.email.trim().toLowerCase() : '';
const rawName = typeof body.name === 'string' ? body.name.trim() : '';
const name = rawName || null;
+ const rawPhone = typeof body.phone === 'string' ? body.phone.trim() : '';
+ const phone = rawPhone || null;
if (!EMAIL_RE.test(email)) return c.json({ error: 'Enter a valid email.' }, 400);
+ if (phone !== null && phone.length > 40) return c.json({ error: 'Phone is too long.' }, 400);
- const customer = await insertInvitedCustomer(c.env.PAWBOOK_DB, tenant.Id, email, name);
+ const customer = await insertInvitedCustomer(c.env.PAWBOOK_DB, tenant.Id, email, name, phone);
// Only send the invite for a freshly-invited customer — skip if the customer is already active
// (a re-POST of an existing active customer must not send a confusing "you're invited" email).
@@ -633,7 +659,13 @@ export const adminRoutes = new Hono()
}
}
return c.json(
- { id: customer.Id, email: customer.Email, name: customer.Name, status: customer.Status },
+ {
+ id: customer.Id,
+ email: customer.Email,
+ name: customer.Name,
+ phone: customer.Phone,
+ status: customer.Status,
+ },
201,
);
})
@@ -651,12 +683,15 @@ export const adminRoutes = new Hono()
const tenant = c.get('tenant');
const endUserId = c.req.param('id');
const body = await c.req
- .json<{ name?: unknown; petType?: unknown }>()
- .catch(() => ({}) as { name?: unknown; petType?: unknown });
+ .json<{ name?: unknown; petType?: unknown; notes?: unknown }>()
+ .catch(() => ({}) as { name?: unknown; petType?: unknown; notes?: unknown });
const name = typeof body.name === 'string' ? body.name.trim() : '';
const petType = body.petType;
+ const rawNotes = typeof body.notes === 'string' ? body.notes.trim() : '';
+ const notes = rawNotes || null;
if (!name) return c.json({ error: 'Enter a pet name.' }, 400);
if (!isPetType(petType)) return c.json({ error: 'Unknown pet type.' }, 400);
+ if (notes !== null && notes.length > 2000) return c.json({ error: 'Notes are too long.' }, 400);
// The customer id comes from the URL; confirm it belongs to this tenant before writing a pet
// under it (production D1 has foreign keys OFF, so nothing else stops a cross-tenant orphan).
if (!(await getEndUserById(c.env.PAWBOOK_DB, tenant.Id, endUserId)))
@@ -665,8 +700,8 @@ export const adminRoutes = new Hono()
(pt) => pt.PetType === petType && pt.Enabled,
);
if (!accepted) return c.json({ error: 'That pet type is not accepted.' }, 400);
- const pet = await addEndUserPet(c.env.PAWBOOK_DB, tenant.Id, endUserId, name, petType);
- return c.json({ id: pet.Id, name: pet.Name, petType: pet.PetType }, 201);
+ const pet = await addEndUserPet(c.env.PAWBOOK_DB, tenant.Id, endUserId, name, petType, notes);
+ return c.json({ id: pet.Id, name: pet.Name, petType: pet.PetType, notes: pet.Notes }, 201);
})
.delete('/:slug/admin/customers/:id/pets/:petId', async (c) => {
const tenant = c.get('tenant');
@@ -675,4 +710,60 @@ export const adminRoutes = new Hono()
const removed = await removeEndUserPet(c.env.PAWBOOK_DB, tenant.Id, c.req.param('petId'));
if (!removed) return c.json({ error: 'Not found.' }, 404);
return c.body(null, 204);
+ })
+
+ .get('/:slug/admin/bookings', async (c) => {
+ const tenant = c.get('tenant');
+ const rows = await listBookingsForTenant(c.env.PAWBOOK_DB, tenant.Id);
+ return c.json({
+ bookings: rows.map((r) => ({
+ id: r.Id,
+ customerEmail: r.Email,
+ customerName: r.Name,
+ type: r.ServiceType,
+ startDate: r.StartDate,
+ endDate: r.EndDate,
+ startTime: r.StartTime,
+ optionKey: r.OptionKey,
+ petCount: r.PetCount,
+ estCost: r.EstCost,
+ status: r.Declined ? 'declined' : r.Status,
+ createdAt: r.CreatedAt,
+ })),
+ });
+ })
+
+ .post('/:slug/admin/bookings/:id/status', async (c) => {
+ const tenant = c.get('tenant');
+ const body = await c.req.json<{ status?: unknown }>().catch(() => ({}) as { status?: unknown });
+ const status = body.status;
+ if (status !== 'confirmed' && status !== 'cancelled' && status !== 'declined')
+ return c.json({ error: "Status must be 'confirmed', 'declined', or 'cancelled'." }, 400);
+ // ponytail: cancel leaves any synced GCal event in place; delete via GCalEventId if sitters complain
+ const updated = await updateBookingStatus(
+ c.env.PAWBOOK_DB,
+ tenant.Id,
+ c.req.param('id'),
+ status,
+ );
+ if (!updated) return c.json({ error: 'Not found.' }, 404);
+
+ // Best-effort customer notification; `notified` lets the dashboard tell the sitter honestly
+ // whether the client heard about it (false when email isn't configured or the send failed).
+ let notified = false;
+ if (isEmailConfigured(c.env)) {
+ const booking = await getBookingWithCustomer(c.env.PAWBOOK_DB, tenant.Id, c.req.param('id'));
+ if (booking?.Email) {
+ const whenText = booking.EndDate
+ ? `${booking.StartDate} – ${booking.EndDate}`
+ : booking.StartDate;
+ try {
+ await sendBookingStatusEmail(c.env, booking.Email, tenant.DisplayName, status, whenText);
+ notified = true;
+ } catch {
+ /* status change stands; the dashboard reports the client was not emailed */
+ }
+ }
+ }
+ return c.json({ status, notified });
});
diff --git a/server/routes/bookings.ts b/server/routes/bookings.ts
index c9d342f..5b07c65 100644
--- a/server/routes/bookings.ts
+++ b/server/routes/bookings.ts
@@ -37,7 +37,6 @@ export const bookingRoutes = new Hono()
const optionKey = c.req.query('option');
if (!isServiceType(type)) return c.json({ error: 'Unknown service type.' }, 400);
if (!/^\d{4}-\d{2}$/.test(month)) return c.json({ error: 'Bad month.' }, 400);
- const user = await getEndUserById(c.env.PAWBOOK_DB, tenant.Id, c.get('endUserId'));
const options = await listServiceOptions(c.env.PAWBOOK_DB, tenant.Id);
const serviceOptions = options.filter((o) => o.ServiceType === type);
let option = serviceOptions[0] ?? null;
@@ -49,7 +48,7 @@ export const bookingRoutes = new Hono()
if (!found) return c.json({ error: 'Unknown service option.' }, 400);
option = found;
}
- const result = await monthAvailability(c.env, tenant, type, month, user?.Email ?? '', option);
+ const result = await monthAvailability(c.env, tenant, type, month, c.get('endUserId'), option);
return c.json(result);
})
@@ -228,7 +227,7 @@ export const bookingRoutes = new Hono()
petCount: r.PetCount,
pets: petsByBooking.get(r.Id) ?? [],
estCost: r.EstCost,
- status: r.Status,
+ status: r.Declined ? 'declined' : r.Status,
})),
});
});
diff --git a/server/routes/public.ts b/server/routes/public.ts
index 677897c..9dbe146 100644
--- a/server/routes/public.ts
+++ b/server/routes/public.ts
@@ -22,6 +22,8 @@ export const publicRoutes = new Hono()
maxHouseSitsPerDay: tenant.MaxHouseSitsPerDay,
maxStayNights: tenant.MaxStayNights,
timezone: tenant.Timezone,
+ contactEmail: tenant.ContactEmail,
+ contactPhone: tenant.ContactPhone,
petTypes: petTypes.filter((p) => p.Enabled).map((p) => p.PetType),
services: [...enabled].map((type) => {
const svc = services.find((s) => s.ServiceType === type)!;
diff --git a/server/types.ts b/server/types.ts
index a1580c6..2f0ab11 100644
--- a/server/types.ts
+++ b/server/types.ts
@@ -12,6 +12,8 @@ export type Tenant = {
MaxHouseSitsPerDay: number | null; // null = unlimited
MaxStayNights: number | null; // null = unlimited
Timezone: string | null; // null = DEFAULT_TIMEZONE
+ ContactEmail: string | null; // shown to clients in the booking widget
+ ContactPhone: string | null; // shown to clients in the booking widget
};
export type TenantUser = {
@@ -57,6 +59,7 @@ export type EndUser = {
TenantId: string;
Email: string;
Name: string | null;
+ Phone: string | null;
Status: 'invited' | 'active';
InvitedAt: string | null;
};
@@ -67,6 +70,7 @@ export type EndUserPet = {
EndUserId: string;
Name: string;
PetType: 'dog' | 'cat';
+ Notes: string | null; // sitter's care notes (feeding, meds, temperament)
CreatedAt: string;
};
@@ -84,6 +88,9 @@ export type BookingRow = {
GCalEventId: string | null;
EstCost: number | null;
Status: 'pending' | 'confirmed' | 'cancelled';
+ // 1 = the cancellation was the sitter declining a pending request. Optional because only the
+ // booking-list queries select it; capacity/availability queries never need it.
+ Declined?: number;
CreatedAt: string;
};
@@ -92,7 +99,7 @@ export type ProviderConnection = {
TenantId: string;
Capability: string;
Provider: string;
- Status: 'disconnected' | 'connected-stub' | 'connected';
+ Status: 'disconnected' | 'connected-stub' | 'connected'; // 'connected-stub' is a legacy value from the removed stub-provider flow; no code path writes it anymore
ConnectedAt: string | null;
CalendarId: string | null;
};
diff --git a/sql/schema.sql b/sql/schema.sql
index f38b9c3..7356949 100644
--- a/sql/schema.sql
+++ b/sql/schema.sql
@@ -11,6 +11,9 @@ CREATE TABLE IF NOT EXISTS Tenants (
MaxHouseSitsPerDay INTEGER,
MaxStayNights INTEGER,
Timezone TEXT,
+ -- Optional contact details shown to clients in the booking widget.
+ ContactEmail TEXT,
+ ContactPhone TEXT,
CreatedAt TEXT NOT NULL DEFAULT (datetime('now'))
);
@@ -73,6 +76,7 @@ CREATE TABLE IF NOT EXISTS EndUsers (
TenantId TEXT NOT NULL REFERENCES Tenants(Id),
Email TEXT NOT NULL,
Name TEXT,
+ Phone TEXT,
InvitedAt TEXT,
Status TEXT NOT NULL DEFAULT 'active' CHECK (Status IN ('invited', 'active')),
CreatedAt TEXT NOT NULL DEFAULT (datetime('now')),
@@ -101,16 +105,21 @@ CREATE TABLE IF NOT EXISTS BookingRequests (
EndDate TEXT, -- exclusive checkout for boarding/blocked ranges; NULL for single-day walks
OptionKey TEXT, -- which TenantServiceOptions row the customer picked; NULL for blocked
PetType TEXT, -- booked species ('dog'|'cat'); NULL for blocked. No pricing/capacity effect.
- PetCount INTEGER NOT NULL DEFAULT 1,
+ PetCount INTEGER NOT NULL DEFAULT 1 CHECK (PetCount >= 1), -- fresh-install only; existing DBs enforce this in app code (validation.ts)
StartTime TEXT, -- 'HH:MM' wall-clock for timed bookings (walk/check-in); NULL = all-day event
GCalEventId TEXT, -- Google Calendar event id created for this booking; NULL if none/unsynced
EstCost INTEGER,
Answers TEXT NOT NULL DEFAULT '{}', -- JSON {questionId: answer}; questions defined on TenantServices
Status TEXT NOT NULL DEFAULT 'pending' CHECK (Status IN ('pending', 'confirmed', 'cancelled')),
+ -- 1 when a pending request was declined by the sitter (stored as Status 'cancelled' + this
+ -- flag; widening the CHECK above would require a table rebuild on existing databases).
+ Declined INTEGER NOT NULL DEFAULT 0,
CreatedAt TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_BookingRequests_Tenant_Dates ON BookingRequests (TenantId, StartDate);
+CREATE INDEX IF NOT EXISTS idx_BookingRequests_Slot
+ ON BookingRequests (TenantId, ServiceType, OptionKey, StartDate);
CREATE INDEX IF NOT EXISTS idx_BookingRequests_Tenant_User ON BookingRequests (TenantId, EndUserId);
CREATE TABLE IF NOT EXISTS EndUserPets (
@@ -119,6 +128,7 @@ CREATE TABLE IF NOT EXISTS EndUserPets (
EndUserId TEXT NOT NULL REFERENCES EndUsers(Id),
Name TEXT NOT NULL,
PetType TEXT NOT NULL CHECK (PetType IN ('dog', 'cat')),
+ Notes TEXT, -- care notes the sitter keeps (feeding, meds, temperament)
CreatedAt TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_EndUserPets_Tenant_User ON EndUserPets (TenantId, EndUserId);
diff --git a/sql/seed.sql b/sql/seed.sql
index 822553c..7f5618c 100644
--- a/sql/seed.sql
+++ b/sql/seed.sql
@@ -65,27 +65,34 @@ INSERT OR REPLACE INTO TenantPetTypes (TenantId, PetType, Enabled) VALUES
-- Demo customers. Invite-only gating means /identify only succeeds for known customers, so the
-- demo widget (and the existing identify/booking tests) need a seeded, already-active customer.
-INSERT OR REPLACE INTO EndUsers (Id, TenantId, Email, Name, Status) VALUES
- ('eu_sp_jess', 'tnt_sunnypaws', 'jess@example.com', 'Jess Demo', 'active'),
- ('eu_ht_jess', 'tnt_happytails', 'jess@example.com', 'Jess Demo', 'active'),
- ('eu_pr_jess', 'tnt_pawsandrelax', 'jess@example.com', 'Jess Demo', 'active');
+INSERT OR REPLACE INTO EndUsers (Id, TenantId, Email, Name, Phone, Status) VALUES
+ ('eu_sp_jess', 'tnt_sunnypaws', 'jess@example.com', 'Jess Demo', '(555) 555-0142', 'active'),
+ ('eu_ht_jess', 'tnt_happytails', 'jess@example.com', 'Jess Demo', '(555) 555-0142', 'active'),
+ ('eu_pr_jess', 'tnt_pawsandrelax', 'jess@example.com', 'Jess Demo', NULL, 'active');
-- Demo pets (sitter-managed). Jess has two at Sunny Paws (dogs+cats), one at Happy Tails (dogs only).
-INSERT OR REPLACE INTO EndUserPets (Id, TenantId, EndUserId, Name, PetType) VALUES
- ('pet_sp_bella', 'tnt_sunnypaws', 'eu_sp_jess', 'Bella', 'dog'),
- ('pet_sp_mochi', 'tnt_sunnypaws', 'eu_sp_jess', 'Mochi', 'cat'),
- ('pet_ht_otis', 'tnt_happytails', 'eu_ht_jess', 'Otis', 'dog');
+INSERT OR REPLACE INTO EndUserPets (Id, TenantId, EndUserId, Name, PetType, Notes) VALUES
+ ('pet_sp_bella', 'tnt_sunnypaws', 'eu_sp_jess', 'Bella', 'dog', 'Allergic to chicken — no chicken treats. Pulls on the leash near squirrels.'),
+ ('pet_sp_mochi', 'tnt_sunnypaws', 'eu_sp_jess', 'Mochi', 'cat', NULL),
+ ('pet_ht_otis', 'tnt_happytails', 'eu_ht_jess', 'Otis', 'dog', 'Deaf in one ear; approach from the front.');
--- Existing bookings so availability looks real.
+-- Existing bookings so availability looks real, tied to the demo customer so the admin list
+-- never shows an anonymous "Unknown customer" row.
-- Sunny Paws (max 2 pets): June 20-25 already has 1 pet boarding -> 1 slot left.
-- Happy Tails (max 4 pets): June 20-25 has 2 pets boarding -> 2 slots left.
-- Both tenants blocked July 3-5 (exclusive end: blocked days are Jul 3 and Jul 4).
INSERT OR REPLACE INTO BookingRequests (Id, TenantId, EndUserId, ServiceType, StartDate, EndDate, PetCount, EstCost, Status) VALUES
- ('seed_sp_board1', 'tnt_sunnypaws', NULL, 'boarding', '2028-06-20', '2028-06-25', 1, 250, 'confirmed'),
+ ('seed_sp_board1', 'tnt_sunnypaws', 'eu_sp_jess', 'boarding', '2028-06-20', '2028-06-25', 1, 250, 'confirmed'),
('seed_sp_block1', 'tnt_sunnypaws', NULL, 'blocked', '2028-07-03', '2028-07-05', 1, NULL, 'confirmed'),
- ('seed_ht_board1', 'tnt_happytails', NULL, 'boarding', '2028-06-20', '2028-06-25', 2, 400, 'confirmed'),
+ ('seed_ht_board1', 'tnt_happytails', 'eu_ht_jess', 'boarding', '2028-06-20', '2028-06-25', 2, 400, 'confirmed'),
('seed_ht_block1', 'tnt_happytails', NULL, 'blocked', '2028-07-03', '2028-07-05', 1, NULL, 'confirmed');
+-- Pending requests so the admin "Needs your reply" list has real work in it on a fresh seed.
+INSERT OR REPLACE INTO BookingRequests (Id, TenantId, EndUserId, ServiceType, StartDate, EndDate, OptionKey, PetType, PetCount, StartTime, EstCost, Status) VALUES
+ ('seed_sp_pend1', 'tnt_sunnypaws', 'eu_sp_jess', 'walk', '2026-07-10', NULL, 'd30', 'dog', 1, '09:00', 20, 'pending'),
+ ('seed_sp_pend2', 'tnt_sunnypaws', 'eu_sp_jess', 'boarding', '2026-07-20', '2026-07-23', NULL, 'dog', 1, NULL, 150, 'pending'),
+ ('seed_ht_pend1', 'tnt_happytails', 'eu_ht_jess', 'walk', '2026-07-12', NULL, 'd60', 'dog', 1, '15:00', 40, 'pending');
+
INSERT OR REPLACE INTO ProviderConnections (Id, TenantId, Capability, Provider, Status) VALUES
('seed_sp_cal', 'tnt_sunnypaws', 'calendar', 'google-calendar', 'disconnected'),
('seed_sp_crm', 'tnt_sunnypaws', 'crm', 'notion', 'disconnected'),
diff --git a/src/shared/booking/service-rules.ts b/src/shared/booking/service-rules.ts
index 5beffe2..4343775 100644
--- a/src/shared/booking/service-rules.ts
+++ b/src/shared/booking/service-rules.ts
@@ -21,6 +21,19 @@ export type ServiceConstraints = {
maxPetCount: number | null;
};
+/** A priced, bookable slot within a service (e.g. a duration tier or a fixed time window).
+ * Field-level shape shared by the widget config, the admin form, and the admin settings wire
+ * format — see app/shared-ui/api.ts and app/admin/shared.ts for how each extends this. */
+export type ServiceOption = {
+ optionKey: string;
+ label: string;
+ durationMinutes: number | null;
+ rate: number;
+ startTime: string | null; // 'HH:MM'; null = no fixed window
+ endTime: string | null; // 'HH:MM'; null = no fixed window
+ capacity: number | null; // max concurrent bookings/date; null = unlimited
+};
+
/** Safety rail (NOT a business rule): bounds regex-evaluation cost against a pathological
* pattern that slipped past admin-time validation. Intake answers are short by nature. */
const MAX_PATTERN_INPUT_LENGTH = 100;
diff --git a/src/shared/index.ts b/src/shared/index.ts
index bbfb35d..d8b9474 100644
--- a/src/shared/index.ts
+++ b/src/shared/index.ts
@@ -31,5 +31,6 @@ export {
validateServiceConstraints,
type ServiceQuestion,
type ServiceConstraints,
+ type ServiceOption,
type QuestionType,
} from './booking/service-rules.js';