@nineteenlabs/quickschedule
v0.1.0
Published
Open-source scheduling plugin for Payload CMS 3
Maintainers
Readme
@nineteenlabs/quickschedule
A scheduling plugin for Payload CMS 3 that adds appointment booking to any Payload project. Drop in 4 collections, wire up API routes, and build your own booking UI with headless React components.
Features
- 4 collections — providers, appointment types, bookings, blocked times
- Slot computation — intersects provider schedules with bookings and blocked times
- Headless React components — render-prop based, bring your own styles
- Email notifications — confirmation, cancellation, reminders (Resend, custom, or console)
- Fully configurable — custom slugs, extra fields, validation hooks, timezone support
- TypeScript-first — complete type definitions for everything
Quick Start
Install
npm install @nineteenlabs/quickschedule
# or
pnpm add @nineteenlabs/quickschedulePeer dependencies: payload@^3.0.0, react@^18 || ^19
Add the Plugin
// payload.config.ts
import { buildConfig } from 'payload'
import { quickschedulePlugin } from '@nineteenlabs/quickschedule'
export default buildConfig({
plugins: [
quickschedulePlugin({
slotInterval: 30, // minutes between slots (default: 30)
timezone: 'America/New_York',
}),
],
// ... rest of your config
})This adds 4 collections to your Payload admin:
- qs-providers — staff/practitioners with weekly schedules
- qs-appointment-types — services offered (duration, pricing, limits)
- qs-bookings — individual appointments
- qs-blocked-times — time-off, holidays, blocked periods
Wire Up API Routes
Create a catch-all route in your Next.js app:
// app/api/quickschedule/[...path]/route.ts
import { createRouteHandlers } from '@nineteenlabs/quickschedule/api'
import configPromise from '@payload-config'
const { GET, POST } = createRouteHandlers(configPromise)
export { GET, POST }This exposes:
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | /api/quickschedule/providers | List active providers |
| GET | /api/quickschedule/appointment-types | List appointment types (optional ?providerId=) |
| GET | /api/quickschedule/availability | Get available slots (?providerId=&appointmentTypeId=&date=) |
| POST | /api/quickschedule/book | Create a booking |
| POST | /api/quickschedule/cancel | Cancel a booking |
Add the Booking UI
Wrap your booking page with BookingProvider and compose the headless components:
'use client'
import {
BookingProvider,
AppointmentTypePicker,
DatePicker,
TimeSlotPicker,
BookingForm,
BookingConfirmation,
} from '@nineteenlabs/quickschedule/react'
export default function BookingPage() {
return (
<BookingProvider>
<h1>Book an Appointment</h1>
<AppointmentTypePicker />
<DatePicker />
<TimeSlotPicker />
<BookingForm />
<BookingConfirmation />
</BookingProvider>
)
}That's it. The components manage the booking flow automatically: select type, pick date, choose time, fill form, see confirmation.
Configuration
All options with their defaults:
quickschedulePlugin({
// Scheduling
slotInterval: 30, // minutes between available slots
bookingWindow: 60, // how many days ahead bookings are allowed
minNotice: 24, // minimum hours before appointment
timezone: 'UTC', // IANA timezone
// Enable/disable
enabled: true,
// API
routePrefix: '/api/quickschedule',
// Validation
validation: {
requirePhone: false,
requireEmail: true, // true by default
customValidation: async (booking) => ({
valid: true,
message: undefined,
}),
},
// Email
email: {
provider: 'ses', // 'ses' | 'resend' | 'console' | 'custom'
region: 'us-east-1', // AWS region (for SES)
apiKey: '', // API key (for Resend)
from: '[email protected]',
replyTo: '[email protected]',
templates: {}, // override default templates
variables: {}, // extra template variables
},
// Notifications
notifications: {
sendConfirmation: true,
sendReminder: false,
reminderHours: 24,
notifyProvider: false,
notifyEmail: '', // office email for notifications
},
// Collection overrides
collections: {
providers: { slug: 'qs-providers' },
appointmentTypes: { slug: 'qs-appointment-types' },
bookings: { slug: 'qs-bookings' },
blockedTimes: { slug: 'qs-blocked-times' },
},
})Custom Collection Slugs
Rename any collection to fit your project:
quickschedulePlugin({
collections: {
providers: { slug: 'staff' },
bookings: { slug: 'appointments' },
},
})Extra Fields
Add custom fields to any collection:
quickschedulePlugin({
collections: {
providers: {
fields: [
{ name: 'specialty', type: 'text' },
{ name: 'languages', type: 'select', options: ['English', 'Spanish'] },
],
},
bookings: {
fields: [
{ name: 'insuranceProvider', type: 'text' },
],
},
},
})React Components
All components are headless — they handle data fetching and state, you control rendering through render props. Every component works out of the box with sensible defaults, but you can customize everything.
BookingProvider
Wraps your booking UI and manages the multi-step flow.
<BookingProvider
apiBase="/api/quickschedule" // API route prefix
onBookingComplete={(response) => {}} // called on successful booking
onError={(error) => {}} // called on errors
>
{children}
</BookingProvider>Flow steps: type → date → time → form → confirmation
AppointmentTypePicker
Fetches and displays available appointment types.
<AppointmentTypePicker
providerId="optional-filter"
renderType={({ type, isSelected, select }) => (
<div
onClick={select}
className={isSelected ? 'selected' : ''}
>
<h3>{type.name}</h3>
<p>{type.duration} minutes — ${type.price}</p>
</div>
)}
renderLoading={() => <Spinner />}
renderEmpty={() => <p>No appointment types available</p>}
/>DatePicker
A calendar date picker with month navigation.
// Custom rendering
<DatePicker
renderDay={({ date, dateStr, isSelected, isToday, isPast, select }) => (
<button
onClick={select}
disabled={isPast}
className={cn({ selected: isSelected, today: isToday })}
>
{date.getDate()}
</button>
)}
renderHeader={({ month, year, prev, next }) => (
<div className="calendar-header">
<button onClick={prev}>←</button>
<span>{month} {year}</span>
<button onClick={next}>→</button>
</div>
)}
/>
// Or delegate to a third-party date picker
<DatePicker
as={({ value, onChange }) => (
<MyFancyDatePicker value={value} onChange={onChange} />
)}
/>TimeSlotPicker
Shows available time slots for the selected provider, type, and date.
<TimeSlotPicker
renderSlot={({ time, isSelected, select }) => (
<button
onClick={select}
className={isSelected ? 'ring-2 ring-blue-500' : ''}
>
{time}
</button>
)}
renderLoading={() => <p>Finding available times...</p>}
renderEmpty={() => <p>No times available for this date</p>}
/>BookingForm
Patient information form with validation.
<BookingForm
fields={[
{ name: 'name', label: 'Full Name', required: true },
{ name: 'email', label: 'Email', type: 'email', required: true },
{ name: 'phone', label: 'Phone', type: 'tel' },
{ name: 'notes', label: 'Anything we should know?', type: 'textarea' },
]}
renderField={({ field, value, onChange, error }) => (
<div>
<label>{field.label}</label>
<input
type={field.type || 'text'}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
{error && <span className="text-red-500">{error}</span>}
</div>
)}
renderSubmit={({ isSubmitting, submit }) => (
<button onClick={submit} disabled={isSubmitting}>
{isSubmitting ? 'Booking...' : 'Confirm Booking'}
</button>
)}
onSuccess={() => console.log('Booked!')}
/>BookingConfirmation
Renders after a successful booking.
<BookingConfirmation
render={({ booking, provider, appointmentType }) => (
<div>
<h2>You're booked!</h2>
<p>Confirmation: {booking.confirmationNumber}</p>
<p>{appointmentType.name} with {provider.name}</p>
<p>{booking.date} at {booking.time}</p>
</div>
)}
/>Hooks
Use hooks directly for custom flows:
import { useBookingContext, useAvailableSlots, useCreateBooking } from '@nineteenlabs/quickschedule/react'
// Access the full booking state
const { step, selectedType, selectDate, reset } = useBookingContext()
// Fetch available slots (reads provider/type/date from context)
const { slots, isLoading, error, refetch } = useAvailableSlots()
// Create a booking (reads selections from context)
const { book, isBooking, error } = useCreateBooking()
const result = await book({ name: 'Jane', email: '[email protected]', phone: '555-0123' })API Reference
Booking Request
POST /api/quickschedule/book
Content-Type: application/json
{
"appointmentTypeId": "abc123",
"providerId": "def456",
"date": "2026-03-16",
"time": "10:00",
"patient": {
"name": "Jane Smith",
"email": "[email protected]",
"phone": "555-123-4567",
"notes": "First visit"
}
}Success (201):
{
"success": true,
"booking": {
"id": "booking-id",
"confirmationNumber": "QS-2026-0316-001",
"date": "2026-03-16",
"time": "10:00",
"duration": 30,
"status": "confirmed"
},
"token": "cancel-token"
}Error (400):
{
"success": false,
"error": "slot_unavailable",
"message": "This time slot is no longer available"
}Error codes: validation_error, provider_not_found, appointment_type_not_found, slot_unavailable
Cancel Request
POST /api/quickschedule/cancel
Content-Type: application/json
{
"bookingId": "booking-id",
"token": "cancel-token",
"reason": "Schedule conflict"
}Availability Query
GET /api/quickschedule/availability?providerId=xxx&appointmentTypeId=xxx&date=2026-03-16{
"date": "2026-03-16",
"provider": { "id": "xxx", "name": "Dr. Smith" },
"appointmentType": { "id": "xxx", "name": "Consultation", "duration": 30 },
"availableSlots": ["09:00", "09:30", "10:30", "11:00", "14:00", "14:30"]
}Email Templates
Four built-in templates use {{variable}} interpolation:
| Template | Sent When | Recipient |
|----------|-----------|-----------|
| confirmation | Booking created | Patient |
| cancellation | Booking cancelled | Patient |
| reminder | Before appointment | Patient |
| providerNotification | Booking created | Provider/office |
Supported providers:
| Provider | Config | Install |
|----------|--------|---------|
| ses | region (default: us-east-1) | npm install @aws-sdk/client-ses |
| resend | apiKey | included (uses fetch) |
| console | none | included (logs to stdout) |
| custom | sendFn | bring your own |
SES uses your standard AWS credential chain (env vars, IAM role, ~/.aws/credentials).
Override any template:
quickschedulePlugin({
email: {
provider: 'ses',
region: 'us-east-1',
from: '[email protected]',
templates: {
confirmation: {
subject: 'Your appointment is confirmed, {{patientName}}!',
html: `
<h1>See you soon!</h1>
<p>You have a {{appointmentTypeName}} with {{providerName}}
on {{formattedDate}} at {{formattedTime}}.</p>
<p>Confirmation: {{confirmationNumber}}</p>
<p><a href="{{cancelUrl}}">Need to cancel?</a></p>
`,
},
},
},
})Available variables: patientName, patientEmail, patientPhone, providerName, appointmentTypeName, date, formattedDate, time, formattedTime, duration, confirmationNumber, cancelUrl
Using Without React
The availability and booking logic works standalone:
import { getAvailableSlots, processBooking } from '@nineteenlabs/quickschedule/api'
// Pure function — compute available slots
const availability = getAvailableSlots({
provider,
appointmentType,
date: '2026-03-16',
bookings: existingBookings,
blockedTimes: blockedTimes,
config: { slotInterval: 30, minNotice: 24, timezone: 'UTC' },
})
// With dependency injection for testability
const result = await processBooking(bookingInput, {
findProvider: async (id) => { /* your lookup */ },
findAppointmentType: async (id) => { /* your lookup */ },
findBookingsForDate: async (providerId, date) => { /* your query */ },
findBlockedTimesForDate: async (providerId, date) => { /* your query */ },
createBookingRecord: async (data) => { /* your insert */ },
countBookingsForDate: async (providerId, date) => { /* your count */ },
config: resolvedConfig,
})Types
Import types from the main package or the dedicated types entry:
import type {
Provider,
AppointmentType,
Booking,
BlockedTime,
BookingInput,
BookingResponse,
AvailabilityResponse,
QuickScheduleConfig,
} from '@nineteenlabs/quickschedule'
// or
import type { Provider } from '@nineteenlabs/quickschedule/types'License
MIT
