feat: appointment confirmation and cancellation (GH #98, GRO-153)
Add customer confirmation/cancellation flow for appointments: - DB migration (0013): add confirmation_status, confirmed_at, cancelled_at, confirmation_token to appointments table with index on token column - schema.ts + factories.ts + types: expose new columns and ConfirmationStatus type - GET /api/book/confirm/:token — tokenized confirm via email link (redirects) - GET /api/book/cancel/:token — tokenized cancel via email link, single-use token - POST /api/appointments/:id/confirm — portal/staff confirm endpoint - POST /api/appointments/:id/cancel — portal/staff cancel endpoint - Reminder emails now include Confirm/Cancel CTA buttons with tokenized links - Reminder service generates confirmation token if missing before sending - Staff calendar shows confirmation status indicator on appointment cards and in the detail modal (confirmed ✓ / customer cancelled ✗) - /booking/confirmed, /booking/cancelled, /booking/error redirect pages - 23 new unit tests covering all new endpoints and edge cases Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -9,6 +9,9 @@ import { BookPage } from "./pages/Book.js";
|
||||
import { ReportsPage } from "./pages/Reports.js";
|
||||
import { GroupBookingPage } from "./pages/GroupBooking.js";
|
||||
import { SettingsPage } from "./pages/Settings.js";
|
||||
import { BookingConfirmedPage } from "./pages/BookingConfirmed.js";
|
||||
import { BookingCancelledPage } from "./pages/BookingCancelled.js";
|
||||
import { BookingErrorPage } from "./pages/BookingError.js";
|
||||
import { CustomerPortal } from "./portal/CustomerPortal.js";
|
||||
import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js";
|
||||
import { DevSessionIndicator } from "./components/DevSessionIndicator.js";
|
||||
@@ -151,6 +154,17 @@ export function App() {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
// Public booking redirect pages — no auth or portal chrome needed
|
||||
if (location.pathname === "/booking/confirmed") {
|
||||
return <BookingConfirmedPage />;
|
||||
}
|
||||
if (location.pathname === "/booking/cancelled") {
|
||||
return <BookingCancelledPage />;
|
||||
}
|
||||
if (location.pathname === "/booking/error") {
|
||||
return <BookingErrorPage />;
|
||||
}
|
||||
|
||||
return (
|
||||
<BrandingProvider>
|
||||
{location.pathname.startsWith("/admin") ? (
|
||||
|
||||
@@ -431,6 +431,12 @@ export function AppointmentsPage() {
|
||||
{a.seriesId && (
|
||||
<div style={{ opacity: 0.85, fontSize: 10 }}>↻ recurring</div>
|
||||
)}
|
||||
{a.confirmationStatus === "confirmed" && (
|
||||
<div style={{ opacity: 0.95, fontSize: 10 }}>✓ confirmed</div>
|
||||
)}
|
||||
{a.confirmationStatus === "cancelled" && (
|
||||
<div style={{ opacity: 0.95, fontSize: 10, textDecoration: "line-through" }}>✗ cust. cancelled</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -695,6 +701,11 @@ function AppointmentDetail({
|
||||
["Start", new Date(appt.startTime).toLocaleString()],
|
||||
["End", new Date(appt.endTime).toLocaleString()],
|
||||
["Status", appt.status.replace("_", " ")],
|
||||
["Confirmation", appt.confirmationStatus === "confirmed"
|
||||
? `✓ Confirmed${appt.confirmedAt ? ` (${new Date(appt.confirmedAt).toLocaleString()})` : ""}`
|
||||
: appt.confirmationStatus === "cancelled"
|
||||
? `✗ Customer cancelled${appt.cancelledAt ? ` (${new Date(appt.cancelledAt).toLocaleString()})` : ""}`
|
||||
: "Pending"],
|
||||
["Notes", appt.notes ?? "—"],
|
||||
...(appt.seriesId
|
||||
? [["Series slot", `#${(appt.seriesIndex ?? 0) + 1}`] as [string, string]]
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
export function BookingCancelledPage() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: "100vh",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontFamily: "system-ui, sans-serif",
|
||||
background: "#fff7ed",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: "#fff",
|
||||
borderRadius: 12,
|
||||
padding: "2.5rem 3rem",
|
||||
boxShadow: "0 4px 24px rgba(0,0,0,0.08)",
|
||||
textAlign: "center",
|
||||
maxWidth: 420,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 56, marginBottom: "0.5rem" }}>✗</div>
|
||||
<h1 style={{ color: "#c2410c", fontSize: 24, margin: "0 0 0.5rem" }}>
|
||||
Appointment Cancelled
|
||||
</h1>
|
||||
<p style={{ color: "#4b5563", margin: "0 0 1.5rem" }}>
|
||||
Your appointment has been cancelled. If this was a mistake or you'd
|
||||
like to rebook, please contact us.
|
||||
</p>
|
||||
<a
|
||||
href="/"
|
||||
style={{
|
||||
display: "inline-block",
|
||||
padding: "0.6rem 1.5rem",
|
||||
background: "#ea580c",
|
||||
color: "#fff",
|
||||
borderRadius: 6,
|
||||
textDecoration: "none",
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
Back to Portal
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
export function BookingConfirmedPage() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: "100vh",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontFamily: "system-ui, sans-serif",
|
||||
background: "#f0fdf4",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: "#fff",
|
||||
borderRadius: 12,
|
||||
padding: "2.5rem 3rem",
|
||||
boxShadow: "0 4px 24px rgba(0,0,0,0.08)",
|
||||
textAlign: "center",
|
||||
maxWidth: 420,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 56, marginBottom: "0.5rem" }}>✓</div>
|
||||
<h1 style={{ color: "#15803d", fontSize: 24, margin: "0 0 0.5rem" }}>
|
||||
Appointment Confirmed
|
||||
</h1>
|
||||
<p style={{ color: "#4b5563", margin: "0 0 1.5rem" }}>
|
||||
Thank you! Your appointment is confirmed. We look forward to seeing you
|
||||
and your furry friend.
|
||||
</p>
|
||||
<a
|
||||
href="/"
|
||||
style={{
|
||||
display: "inline-block",
|
||||
padding: "0.6rem 1.5rem",
|
||||
background: "#16a34a",
|
||||
color: "#fff",
|
||||
borderRadius: 6,
|
||||
textDecoration: "none",
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
Back to Portal
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
export function BookingErrorPage() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: "100vh",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontFamily: "system-ui, sans-serif",
|
||||
background: "#fef2f2",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: "#fff",
|
||||
borderRadius: 12,
|
||||
padding: "2.5rem 3rem",
|
||||
boxShadow: "0 4px 24px rgba(0,0,0,0.08)",
|
||||
textAlign: "center",
|
||||
maxWidth: 420,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 56, marginBottom: "0.5rem" }}>⚠️</div>
|
||||
<h1 style={{ color: "#b91c1c", fontSize: 24, margin: "0 0 0.5rem" }}>
|
||||
Link Invalid or Expired
|
||||
</h1>
|
||||
<p style={{ color: "#4b5563", margin: "0 0 1.5rem" }}>
|
||||
This confirmation link is invalid, has already been used, or your
|
||||
appointment has already passed. Please contact us if you need help.
|
||||
</p>
|
||||
<a
|
||||
href="/"
|
||||
style={{
|
||||
display: "inline-block",
|
||||
padding: "0.6rem 1.5rem",
|
||||
background: "#dc2626",
|
||||
color: "#fff",
|
||||
borderRadius: 6,
|
||||
textDecoration: "none",
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
Back to Portal
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user