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
22 changes: 14 additions & 8 deletions app/admin/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,9 +119,6 @@ function Login({ onLogin }: { onLogin: (s: Session) => void }) {
{busy ? 'Signing in…' : 'Sign in'}
</button>
{error && <p className="pb-error">{error}</p>}
<p className="pb-hint">
<small>Demo logins are in the app's DEMO_NOTES.md.</small>
</p>
</div>
);
}
Expand All @@ -132,19 +129,21 @@ type SectionKey =
const SECTIONS: { key: SectionKey; label: string; icon: typeof IconStore }[] = [
{ key: 'bookings', label: 'Bookings', icon: IconClipboardCheck },
{ key: 'business', label: 'Business', icon: IconStore },
{ key: 'pets', label: 'Pets', icon: IconPaw },
{ key: 'pets', label: 'Pet types', icon: IconPaw },
{ key: 'services', label: 'Services & rates', icon: IconTag },
{ key: 'timeoff', label: 'Time off', icon: IconCalendar },
{ key: 'clients', label: 'Clients', icon: IconUsers },
{ key: 'apps', label: 'Connected apps', icon: IconPlug },
{ key: 'embed', label: 'Embed', icon: IconCode },
{ key: 'embed', label: 'Your website', icon: IconCode },
];

/** Reads the initial section from the URL hash (e.g. `/admin#clients`) so deep links and page
* refreshes land on the right section, same as the old anchor-nav did. */
function sectionFromHash(): SectionKey {
const hash = window.location.hash.slice(1);
return SECTIONS.some((s) => s.key === hash) ? (hash as SectionKey) : 'business';
// Default to Bookings — the sitter's morning question is "what needs my reply?",
// not their own settings.
return SECTIONS.some((s) => s.key === hash) ? (hash as SectionKey) : 'bookings';
}

function Dashboard({ session, onSignOut }: { session: Session; onSignOut: () => void }) {
Expand Down Expand Up @@ -185,15 +184,20 @@ 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);
}, []);

// 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]);

Expand Down Expand Up @@ -242,6 +246,8 @@ function Dashboard({ session, onSignOut }: { session: Session; onSignOut: () =>
maxHouseSitsPerDay: settings.maxHouseSitsPerDay,
maxStayNights: settings.maxStayNights,
timezone: settings.timezone,
contactEmail: settings.contactEmail,
contactPhone: settings.contactPhone,
petTypes: settings.petTypes.filter((p) => p.enabled).map((p) => p.petType),
services: settings.services.map((s): ServicePayload => ({
type: s.type,
Expand Down
7 changes: 7 additions & 0 deletions app/admin/admin.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
146 changes: 93 additions & 53 deletions app/admin/sections/BookingsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ function formatWhen(b: AdminBooking): string {
return b.startTime ? `${range} at ${b.startTime}` : range;
}

const byStartDate = (a: AdminBooking, b: AdminBooking) => a.startDate.localeCompare(b.startDate);

function chipClass(status: string): string {
if (status === 'confirmed') return ' pb-chip-ok';
if (status === 'cancelled') return ' pb-chip-bad';
if (status === 'declined') return ' pb-chip-warn';
return '';
}

export function BookingsSection({
session,
handleError,
Expand All @@ -20,6 +29,7 @@ export function BookingsSection({
}) {
const [bookings, setBookings] = useState<AdminBooking[] | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const [message, setMessage] = useState('');

const load = () =>
adminApi.bookings.list(session.slug, session.token).then(({ bookings: list }) => list);
Expand All @@ -35,12 +45,34 @@ export function BookingsSection({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [session]);

const setStatus = async (id: string, status: 'confirmed' | 'cancelled') => {
const setStatus = async (b: AdminBooking, status: 'confirmed' | 'declined' | 'cancelled') => {
if (busyId) return;
if (
status === 'cancelled' &&
!window.confirm(
`Cancel ${b.customerName || b.customerEmail || 'this client'}'s ${b.type} booking (${formatWhen(b)})? This can't be undone.`,
)
)
return;
clearError();
setBusyId(id);
setMessage('');
setBusyId(b.id);
try {
await adminApi.bookings.setStatus(session.slug, session.token, id, status);
const { notified } = await adminApi.bookings.setStatus(
session.slug,
session.token,
b.id,
status,
);
const who = b.customerName || b.customerEmail || 'the client';
const verb =
status === 'confirmed' ? 'Confirmed' : status === 'declined' ? 'Declined' : 'Cancelled';
setMessage(
`${verb} ${who}'s ${b.type} ${status === 'cancelled' ? 'booking' : 'request'}. ` +
(notified
? `We emailed ${who} the update.`
: `${who} couldn't be emailed automatically (email sending isn't set up), so let them know directly.`),
);
setBookings(await load());
} catch (e) {
handleError(e);
Expand All @@ -49,66 +81,74 @@ export function BookingsSection({
}
};

const actionsFor = (b: AdminBooking) => (
<span>
{b.status === 'pending' && (
<>
<button disabled={busyId === b.id} onClick={() => void setStatus(b, 'confirmed')}>
Confirm
</button>
<button disabled={busyId === b.id} onClick={() => void setStatus(b, 'declined')}>
Decline
</button>
</>
)}
{b.status === 'confirmed' && (
<button disabled={busyId === b.id} onClick={() => void setStatus(b, 'cancelled')}>
Cancel
</button>
)}
</span>
);

const row = (b: AdminBooking) => (
<li key={b.id}>
<span>
{b.customerName || b.customerEmail || 'Unknown customer'} — {b.type}
<br />
{formatWhen(b)} · {b.petCount} pet{b.petCount === 1 ? '' : 's'}
{b.estCost != null ? ` · $${b.estCost}` : ''}{' '}
<span className={`pb-chip${chipClass(b.status)}`}>{b.status}</span>
</span>
{actionsFor(b)}
</li>
);

const pending = (bookings ?? []).filter((b) => b.status === 'pending').sort(byStartDate);
const rest = (bookings ?? []).filter((b) => b.status !== 'pending').sort(byStartDate);

return (
<>
<h2>
<IconClipboardCheck size={18} /> Bookings
</h2>
<p className="pb-applies">Confirm or decline requests as they come in.</p>
{/* 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 && (
<div className="pb-savebar" role="status">
<p className="pb-savebar-saved">{message}</p>
<button onClick={() => setMessage('')}>OK</button>
</div>
)}
{bookings === null ? (
<p>Loading…</p>
) : bookings.length === 0 ? (
<p className="pb-hint">No bookings yet.</p>
) : (
<ul>
{bookings.map((b) => (
<li key={b.id}>
<span>
{b.customerName || b.customerEmail || 'Unknown customer'} — {b.type}
<br />
{formatWhen(b)} · {b.petCount} pet{b.petCount === 1 ? '' : 's'}
{b.estCost != null ? ` · $${b.estCost}` : ''}{' '}
<span
className={`pb-chip${
b.status === 'confirmed'
? ' pb-chip-ok'
: b.status === 'cancelled'
? ' pb-chip-warn'
: ''
}`}
>
{b.status}
</span>
</span>
<span>
{b.status === 'pending' && (
<>
<button
disabled={busyId === b.id}
onClick={() => void setStatus(b.id, 'confirmed')}
>
Confirm
</button>
<button
disabled={busyId === b.id}
onClick={() => void setStatus(b.id, 'cancelled')}
>
Decline
</button>
</>
)}
{b.status === 'confirmed' && (
<button
disabled={busyId === b.id}
onClick={() => void setStatus(b.id, 'cancelled')}
>
Cancel
</button>
)}
</span>
</li>
))}
</ul>
<>
<h3>
{pending.length === 0
? 'No requests waiting for a reply'
: `Needs your reply (${pending.length})`}
</h3>
{pending.length > 0 && <ul>{pending.map(row)}</ul>}
{rest.length > 0 && (
<>
<h3>Everything else</h3>
<ul>{rest.map(row)}</ul>
</>
)}
</>
)}
</>
);
Expand Down
19 changes: 19 additions & 0 deletions app/admin/sections/BusinessSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,25 @@ export function BusinessSection({ settings, setSettings }: SettingsSectionProps)
onChange={(e) => setSettings({ ...settings, accentColor: e.target.value })}
/>
</label>
<label>
Contact email
<input
type="email"
placeholder="you@example.com"
value={settings.contactEmail ?? ''}
onChange={(e) => setSettings({ ...settings, contactEmail: e.target.value || null })}
/>
</label>
<label>
Contact phone
<input
type="tel"
placeholder="(555) 555-0123"
value={settings.contactPhone ?? ''}
onChange={(e) => setSettings({ ...settings, contactPhone: e.target.value || null })}
/>
</label>
<p className="pb-hint">Shown to your clients on the booking page so they can reach you.</p>
<NullableNumberField
label="Boarding spots per day"
value={settings.maxBoardingPets}
Expand Down
29 changes: 26 additions & 3 deletions app/admin/sections/ClientsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,17 @@ function PetAdder({
}) {
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);
await adminApi.customers.addPet(slug, token, customer.id, name.trim(), petType, notes.trim());
setName('');
setNotes('');
onAdded();
} catch (e) {
onError(e);
Expand All @@ -49,6 +51,11 @@ function PetAdder({
</option>
))}
</select>
<input
placeholder="Care notes (feeding, meds, quirks — optional)"
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>
<button onClick={() => void add()} disabled={busy || !name.trim()}>
{busy ? 'Adding…' : 'Add pet'}
</button>
Expand All @@ -75,6 +82,7 @@ export function ClientsSection({
}) {
const [custEmail, setCustEmail] = useState('');
const [custName, setCustName] = useState('');
const [custPhone, setCustPhone] = useState('');
const [busy, setBusy] = useState(false);

/** Matches the old Dashboard run() semantics: clear the error banner at the START of each
Expand All @@ -96,9 +104,16 @@ export function ClientsSection({

const addCustomer = () =>
mutate(async () => {
await adminApi.customers.add(slug, token, custEmail.trim().toLowerCase(), custName.trim());
await adminApi.customers.add(
slug,
token,
custEmail.trim().toLowerCase(),
custName.trim(),
custPhone.trim(),
);
setCustEmail('');
setCustName('');
setCustPhone('');
});

const removeCustomer = (id: string) => mutate(() => adminApi.customers.remove(slug, token, id));
Expand Down Expand Up @@ -127,6 +142,12 @@ export function ClientsSection({
value={custName}
onChange={(e) => setCustName(e.target.value)}
/>
<input
type="tel"
placeholder="Phone (optional)"
value={custPhone}
onChange={(e) => setCustPhone(e.target.value)}
/>
<button onClick={() => void addCustomer()} disabled={busy}>
{busy ? 'Adding…' : 'Add customer'}
</button>
Expand All @@ -137,7 +158,8 @@ export function ClientsSection({
<div className="pb-row">
<span>
{cust.email}
{cust.name ? ` (${cust.name})` : ''}{' '}
{cust.name ? ` (${cust.name})` : ''}
{cust.phone ? ` · ${cust.phone}` : ''}{' '}
<span
className={`pb-chip${cust.status === 'active' ? ' pb-chip-ok' : ' pb-chip-warn'}`}
>
Expand All @@ -152,6 +174,7 @@ export function ClientsSection({
{cust.pets.map((p) => (
<li key={p.id}>
{p.name} <em>{p.petType}</em>
{p.notes ? <span className="pb-hint"> — {p.notes}</span> : null}
<button onClick={() => void removePet(cust.id, p.id)} disabled={busy}>
Remove
</button>
Expand Down
Loading
Loading