Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .claude/skills/running-pawbook/SKILL.md
Original file line number Diff line number Diff line change
@@ -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`.
192 changes: 73 additions & 119 deletions app/admin/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -116,9 +126,11 @@ function Login({ onLogin }: { onLogin: (s: Session) => void }) {
);
}

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: 'services', label: 'Services & rates', icon: IconTag },
Expand All @@ -142,8 +154,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.
Expand Down Expand Up @@ -221,70 +231,47 @@ 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,
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');
Expand All @@ -304,50 +291,19 @@ function Dashboard({ session, onSignOut }: { session: Session; onSignOut: () =>
await refresh();
});

const [customers, setCustomers] = useState<Customer[]>([]);
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<unknown>) =>
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<Customer[]> => {
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(() => {
Expand All @@ -365,40 +321,38 @@ function Dashboard({ session, onSignOut }: { session: Session; onSignOut: () =>
const enabledPetTypes = settings.petTypes.filter((p) => p.enabled).map((p) => p.petType);

const panels: Record<SectionKey, ReactNode> = {
bookings: (
<BookingsSection session={session} handleError={handle} clearError={() => setError('')} />
),
business: <BusinessSection settings={settings} setSettings={setSettings} />,
pets: <PetsSection settings={settings} setSettings={setSettings} />,
services: <ServicesSection settings={settings} setSettings={setSettings} />,
timeoff: (
<TimeOffSection
blocked={settings.blocked}
blockStart={blockStart}
blockEnd={blockEnd}
setBlockStart={setBlockStart}
setBlockEnd={setBlockEnd}
addBlock={addBlock}
removeBlock={removeBlock}
slug={slug}
token={token}
onChanged={refresh}
handleError={handle}
clearError={() => setError('')}
/>
),
clients: (
<ClientsSection
customers={customers}
custEmail={custEmail}
custName={custName}
setCustEmail={setCustEmail}
setCustName={setCustName}
addCustomer={addCustomer}
removeCustomer={removeCustomer}
addPet={addPet}
removePet={removePet}
customers={customers ?? []}
enabledPetTypes={enabledPetTypes}
slug={slug}
token={token}
onCustomersChanged={reloadCustomers}
handleError={handle}
clearError={() => setError('')}
/>
),
apps: (
<AppsSection
providers={settings.providers}
calendar={settings.calendar}
slug={slug}
token={token}
connect={connect}
connectCalendar={connectCalendar}
disconnectCalendar={disconnectCalendar}
onCalendarSaved={() => void refresh()}
Expand Down
Loading
Loading