CPJ Appointment Scheduler
WordPress Plugin for appointment scheduling.
by Paul Jarvis · github.com/cpj9251/cpj-appointment-scheduler · website
Install
No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:
wp plugin install https://github.com/cpj9251/cpj-appointment-scheduler/archive/refs/heads/main.zipA WordPress plugin for booking appointments: visitors pick a date and time on a front-end calendar, submit their contact info, and both they and the site admin get a confirmation email. Built as a Gutenberg block + React front end on top of a small, customer-centric REST API.
- Author: Paul Jarvis (cpauljarvis.com)
- Requires: PHP 8.2+, WordPress with the block editor
- Status: v1 beta — see Roadmap for what's intentionally not built yet
How it works
- A page or post has the CPJ Appointment Scheduler block added to it.
- A visitor picks a date on the calendar (only dates with open slots are selectable), then a time, then enters their name/email/phone.
- On submit, the plugin finds-or-creates a Customer record (matched by email, so repeat visitors don't get duplicated) and creates a linked Appointment record.
- The visitor gets a confirmation email; the admin gets a notification email. Both are template-driven and editable from the settings screen.
- Admins manage everything from wp-admin: the Appointments and Customers post-list screens, each with a real Gutenberg block (not a classic meta box) for editing the underlying data.
The block editor preview for the scheduler block renders the exact same React component tree as the live front end — there's no separate "editor preview" to keep in sync.
Data model
Customer-centric, not appointment-centric: a customer can accumulate many appointments over time without their info being re-entered or duplicated.
cpj_customer (post) cpj_appointment (post)
├─ meta: first_name ├─ meta: customer_id ──┐
├─ meta: last_name ├─ meta: date │
├─ meta: email ├─ meta: time │
├─ meta: phone ├─ meta: duration_minutes
├─ meta: address ├─ meta: status (confirmed|cancelled|completed)
└─ meta: preferred_contact └─ meta: notes
▲ │
└────────────────────────────────────┘
Both post types register their meta via register_post_meta (validated,
show_in_rest) and template-lock a dedicated Gutenberg block onto their
editor screen, so wp-admin shows real form controls instead of the classic
"Custom Fields" box. Because the customer-facing booking flow creates these
posts anonymously (no logged-in user), post creation deliberately bypasses
wp_kses for that one call — otherwise WordPress strips the block markup
right back out of post_content before it's saved.
REST API
Namespace: cpj-appt-sched/v1. Availability/booking routes are public
(anonymous) by design; the customer-appointments lookup is admin-only.
| Method | Route | Auth | Purpose |
|---|---|---|---|
GET |
/availability?month=YYYY-MM |
Public | Which dates in a month have at least one open slot |
GET |
/availability/times?date=YYYY-MM-DD |
Public | Open time slots for one date |
POST |
/appointments |
Public | Book an appointment (honeypot + rate-limited) |
GET |
/customers/{id}/appointments |
edit_posts |
An admin-screen lookup for a customer's appointment history |
Settings are read/written through WordPress's own /wp/v2/settings endpoint
rather than a custom route — see below.
Abuse protection on POST /appointments: a hidden honeypot field
(website) silently no-ops the request if filled in, and submissions are
rate-limited per IP (5 requests / 10 minutes) via transients. There's no
CAPTCHA in v1 — see Roadmap.
Settings
Stored as a single option, cpj_appt_scheduler_settings, registered with
show_in_rest so it's readable/writable at /wp/v2/settings. Configurable
from Settings → Appointment Scheduler:
| Field | Description |
|---|---|
appointmentDurationMinutes |
Length of one appointment slot |
minimumNoticeMinutes |
How far in advance a booking must be made |
maximumAdvanceDays |
How far out bookings are allowed |
weeklyAvailability |
Recurring open times per weekday |
dateOverrides |
Per-date exceptions — close a whole day or block specific times |
notifyEmail |
Admin address for new-booking notifications |
customerEmailSubject / customerEmailBody |
Confirmation email sent to the customer |
adminEmailSubject / adminEmailBody |
Notification email sent to the admin |
Email bodies support {first_name}, {last_name}, {date}, and {time}
placeholders, substituted at send time.
Project structure
cpj-appointment-scheduler.php Plugin bootstrap
src/
├─ Plugin.php Wires everything up on load
├─ Blocks/SchedulerBlock.php Registers + conditionally enqueues the booking block
├─ CustomPostTypes/ Appointments, Customers
├─ MetaFields/ register_post_meta + block-template wiring per CPT
├─ Rest/ Route registration + request handling
├─ Services/ Business logic: Availability, Appointment, Email
└─ Settings/Settings.php Options schema, defaults, sanitization
assets/src/
├─ block/ Scheduler block registration + editor preview
├─ frontend/ The booking flow itself (calendar → time → details → confirmation)
├─ metafields/ Appointment CPT's editor block
├─ customer-metafields/ Customer CPT's editor block
└─ settings/ Settings screen React app
assets/build/ is compiled output (gitignored). prev_version/ is the
original procedural/jQuery plugin, kept for reference only — nothing in the
active plugin loads it.
Development setup
composer install
npm install
npm run start # webpack watch mode
npm run build # production build (required before deploying)
Linting
Both linters are configured against shared rulesets and are expected to pass clean before anything ships:
composer run lint # phpcs
composer run lint:fix # phpcbf — re-run `lint` after, it doesn't catch everything
npm run lint # eslint
npm run lint:fix # eslint --fix
Note:
phpcbfcan occasionally mangle complex compound conditionals (e.g. a Yoda-condition fix inside an||expression) into something that no longer parses. Always runphp -lon anything it touches before trusting the auto-fix.
Roadmap
Deliberately out of scope for v1 beta:
- Payment collection at booking time.
- A custom admin dashboard — v1 uses the default WordPress post-list screens (with a few extra sortable columns) rather than a bespoke React admin UI.
- CAPTCHA/third-party anti-spam — the honeypot + rate limit is judged sufficient for now.
- Plugin uninstall/cleanup routine — no
uninstall.phpyet; deactivating the plugin does not remove its data or options.