Concept project · runs in your browserTypeScript · React.js · Next.js
Custom WordPress Booking Plugin Demo
A custom WordPress booking plugin you can use from both sides. Request a slot through the front-end form, then switch to wp-admin to approve, decline or cancel it in a list table and watch the emails go out. Switch users to test capability checks, leave the page open 25 hours to test nonces, and race two visitors to the same slot.
Interactive demo
Loading the demo site…
## How it works
The form posts to a REST route, acme-booking/v1/bookings. The callback checks the nonce first, then a honeypot field, then sanitises and validates every field and returns 400 with per-field messages that the form shows inline. A per-email limit answers 429.
Double booking is stopped twice. A query hides and rejects taken slots, and the table has a UNIQUE KEY on a generated active_slot column that is NULL for declined and cancelled rows. Tick "skip the PHP slot check" to see the database constraint catch a race and turn the duplicate-key error into a 409.
Admin actions go through admin-post.php: current_user_can('manage_options') first, then check_admin_referer. Nonces are tied to the user and to a 12-hour tick, so switching user or letting the page go stale makes old links fail with "The link you followed has expired."
## View source
The code running in the window above, exactly as deployed.
app/lab/wordpress-booking-plugin/booking.ts378 lines
/**
* Server-side logic of the Acme Booking plugin, written in TypeScript so it can run in the browser.
* It mirrors php/acme-booking.php: the REST route callback, nonce and capability checks, the custom table
* with its unique key, WP_List_Table queries and wp_mail notifications.
*/
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
export type Status = "new" | "confirmed" | "declined" | "cancelled";
/** One row of {$wpdb->prefix}acme_bookings. */
export type Booking = {
id: number;
name: string;
email: string;
phone: string;
service: string;
slot_date: string; // DATE
slot_time: string; // TIME, HH:MM
note: string;
status: Status;
created_at: string; // DATETIME (UTC)
};
export type Service = { slug: string; label: string; minutes: number };
export type Settings = {
services: Service[];
slots: string[];
openDays: number[]; // 0 = Sunday ... 6 = Saturday
horizonDays: number;
adminEmail: string;
deleteOnUninstall: boolean;
};
export type Role = "administrator" | "editor" | "subscriber" | "guest";
export type User = { id: number; login: string; role: Role };
export const USERS: User[] = [
{ id: 1, login: "dana (Administrator)", role: "administrator" },
{ id: 2, login: "omar (Editor)", role: "editor" },
{ id: 3, login: "sara (Subscriber)", role: "subscriber" },
{ id: 0, login: "Logged out visitor", role: "guest" },
];
const CAPS: Record<Role, string[]> = {
administrator: ["manage_options", "edit_posts", "read"],
editor: ["edit_posts", "read"],
subscriber: ["read"],
guest: [],
};
export const current_user_can = (u: User, cap: string) => CAPS[u.role].includes(cap);
export type Mail = { id: number; to: string; subject: string; body: string };
export type Db = { rows: Booking[]; autoIncrement: number; settings: Settings; outbox: Mail[]; today: string };
export type Result = { status: number; body: unknown; db: Db };
/* ------------------------------------------------------------------ */
/* Dates */
/* ------------------------------------------------------------------ */
export function addDays(ymd: string, n: number) {
const [y, m, d] = ymd.split("-").map(Number);
return new Date(Date.UTC(y!, m! - 1, d! + n)).toISOString().slice(0, 10);
}
export const weekday = (ymd: string) => new Date(ymd + "T12:00:00Z").getUTCDay();
export const isValidDate = (s: string) => /^\d{4}-\d{2}-\d{2}$/.test(s) && !Number.isNaN(Date.parse(s + "T00:00:00Z")) && new Date(s + "T00:00:00Z").toISOString().startsWith(s);
/* ------------------------------------------------------------------ */
/* Nonces: same shape as wp_create_nonce / wp_verify_nonce */
/* ------------------------------------------------------------------ */
const NONCE_SALT = "demo-nonce-salt"; // NONCE_KEY + NONCE_SALT from wp-config.php in WordPress
const HALF_DAY = 43_200_000;
/** wp_nonce_tick(): nonces live 12–24 hours. */
export const nonceTick = (now: number) => Math.ceil(now / HALF_DAY);
function wpHash(s: string) {
// WordPress uses hash_hmac('md5', ...); FNV-1a is enough to show the mechanics here.
let h = 2166136261;
for (const ch of NONCE_SALT + s) h = Math.imul(h ^ ch.charCodeAt(0), 16777619);
let h2 = h ^ 0x9e3779b9;
for (const ch of s) h2 = Math.imul(h2 ^ ch.charCodeAt(0), 2246822519);
return ((h >>> 0).toString(16) + (h2 >>> 0).toString(16)).padStart(16, "0");
}
export function wp_create_nonce(action: string, user: User, now: number) {
return wpHash(`${nonceTick(now)}|${action}|${user.id}|session`).slice(-12, -2);
}
/** Returns 1 (this half-day), 2 (previous half-day) or false. */
export function wp_verify_nonce(nonce: string, action: string, user: User, now: number): 1 | 2 | false {
const tick = nonceTick(now);
if (nonce === wpHash(`${tick}|${action}|${user.id}|session`).slice(-12, -2)) return 1;
if (nonce === wpHash(`${tick - 1}|${action}|${user.id}|session`).slice(-12, -2)) return 2;
return false;
}
/* ------------------------------------------------------------------ */
/* Seed */
/* ------------------------------------------------------------------ */
export const DEFAULT_SETTINGS: Settings = {
services: [
{ slug: "assessment", label: "First assessment", minutes: 45 },
{ slug: "follow-up", label: "Follow-up session", minutes: 30 },
{ slug: "sports-massage", label: "Sports massage", minutes: 60 },
],
slots: ["09:00", "10:00", "11:00", "12:00", "14:00", "15:00", "16:00"],
openDays: [1, 2, 3, 4, 5, 6],
horizonDays: 14,
adminEmail: "frontdesk@acme-physio.test",
deleteOnUninstall: false,
};
export function seedDb(today: string): Db {
const people = [
["Ali Raza", "ali@example.com", "0300 1112233"],
["Hina Malik", "hina@example.com", "0321 4445566"],
["Bilal Ahmed", "bilal@example.com", "0333 7778899"],
["Zara Shah", "zara@example.com", "0345 1239876"],
["Usman Tariq", "usman@example.com", "0312 6543210"],
["Maryam Iqbal", "maryam@example.com", "0301 2223344"],
["Fahad Khan", "fahad@example.com", "0322 9988776"],
["Sana Javed", "sana@example.com", "0300 5556677"],
] as const;
const statuses: Status[] = ["new", "confirmed", "new", "confirmed", "declined", "new", "confirmed", "cancelled"];
const rows: Booking[] = [];
let id = 101;
let day = 1;
people.forEach(([name, email, phone], i) => {
let date = addDays(today, day);
while (!DEFAULT_SETTINGS.openDays.includes(weekday(date))) date = addDays(date, 1);
rows.push({
id: id++,
name,
email,
phone,
service: DEFAULT_SETTINGS.services[i % 3]!.slug,
slot_date: date,
slot_time: DEFAULT_SETTINGS.slots[(i * 3) % DEFAULT_SETTINGS.slots.length]!,
note: i % 3 === 0 ? "Lower back pain after running." : "",
status: statuses[i]!,
created_at: `${addDays(today, -((i % 4) + 1))} 0${(i % 9) + 1}:15:00`,
});
if (i % 2) day++;
});
return { rows, autoIncrement: id, settings: DEFAULT_SETTINGS, outbox: [], today };
}
/* ------------------------------------------------------------------ */
/* Custom table with UNIQUE KEY active_slot */
/* ------------------------------------------------------------------ */
/** The generated column the unique key is built on: only new/confirmed rows hold a slot. */
const activeSlot = (r: Pick<Booking, "slot_date" | "slot_time" | "status">) =>
r.status === "new" || r.status === "confirmed" ? `${r.slot_date} ${r.slot_time}` : null;
export class DuplicateEntry extends Error {}
/** $wpdb->insert(): enforces the unique key exactly as MySQL would (error 1062). */
function insertRow(db: Db, row: Omit<Booking, "id">): { db: Db; id: number } {
const key = activeSlot(row);
if (key && db.rows.some((r) => activeSlot(r) === key)) {
throw new DuplicateEntry(`Duplicate entry '${key}' for key 'active_slot'`);
}
const id = db.autoIncrement;
return { db: { ...db, rows: [...db.rows, { ...row, id }], autoIncrement: id + 1 }, id };
}
export function takenSlots(db: Db, date: string): Set<string> {
return new Set(db.rows.filter((r) => r.slot_date === date && activeSlot(r)).map((r) => r.slot_time));
}
export function bookableDates(db: Db, horizon = db.settings.horizonDays): { date: string; open: boolean }[] {
return Array.from({ length: horizon }, (_, i) => {
const date = addDays(db.today, i + 1);
return { date, open: db.settings.openDays.includes(weekday(date)) };
});
}
/* ------------------------------------------------------------------ */
/* POST /wp-json/acme-booking/v1/bookings */
/* ------------------------------------------------------------------ */
export type BookingInput = {
name: string;
email: string;
phone: string;
service: string;
slot_date: string;
slot_time: string;
note: string;
website: string; // honeypot, must stay empty
_wpnonce: string;
};
export const sanitize_text_field = (s: string) => s.replace(/<[^>]*>/g, "").replace(/[\r\n\t]+/g, " ").replace(/\s{2,}/g, " ").trim();
export const sanitize_textarea_field = (s: string) => s.replace(/<[^>]*>/g, "").trim();
export const is_email = (s: string) => /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(s);
let mailSeq = 0;
function wp_mail(db: Db, to: string, subject: string, body: string): Db {
return { ...db, outbox: [{ id: ++mailSeq + db.outbox.length * 1000, to, subject, body }, ...db.outbox].slice(0, 20) };
}
export function createBooking(db: Db, raw: BookingInput, ctx: { user: User; now: number; skipAppCheck?: boolean }): Result {
// 1. Nonce. A bad nonce is 403, before anything else is read.
if (!wp_verify_nonce(raw._wpnonce, "acme_booking_submit", ctx.user, ctx.now)) {
return { status: 403, body: { code: "acme_bad_nonce", message: "Your session expired. Reload the page and try again.", data: { status: 403 } }, db };
}
// 2. Honeypot: pretend success so bots learn nothing.
if (raw.website.trim()) return { status: 201, body: { id: 0, status: "new", message: "Thanks, we will confirm by email." }, db };
// 3. Sanitise, then validate. Errors are keyed by field so the form can show them inline.
const input = {
name: sanitize_text_field(raw.name),
email: sanitize_text_field(raw.email).toLowerCase(),
phone: raw.phone.replace(/[^\d+]/g, ""),
service: sanitize_text_field(raw.service),
slot_date: sanitize_text_field(raw.slot_date),
slot_time: sanitize_text_field(raw.slot_time),
note: sanitize_textarea_field(raw.note).slice(0, 500),
};
const s = db.settings;
const errors: Record<string, string> = {};
if (input.name.length < 2) errors.name = "Please enter your name.";
if (!is_email(input.email)) errors.email = "Please enter a valid email address.";
if (!/^\+?\d{7,15}$/.test(input.phone)) errors.phone = "Please enter a phone number (7–15 digits).";
if (!s.services.some((x) => x.slug === input.service)) errors.service = "Please choose a service.";
if (!isValidDate(input.slot_date)) errors.slot_date = "Please choose a date.";
else if (input.slot_date <= db.today || input.slot_date > addDays(db.today, s.horizonDays)) errors.slot_date = `Dates can be booked from tomorrow up to ${s.horizonDays} days ahead.`;
else if (!s.openDays.includes(weekday(input.slot_date))) errors.slot_date = "We are closed on that day.";
if (!s.slots.includes(input.slot_time)) errors.slot_time = "Please choose a time.";
if (Object.keys(errors).length) {
return { status: 400, body: { code: "rest_invalid_param", message: "Some fields need attention.", data: { status: 400, params: errors } }, db };
}
// 4. Rate limit per email (a transient in WordPress): 3 open requests at most.
if (db.rows.filter((r) => r.email === input.email && r.status === "new").length >= 3) {
return { status: 429, body: { code: "acme_too_many", message: "You already have 3 pending requests. We will contact you soon.", data: { status: 429 } }, db };
}
// 5. Friendly check first; the unique key is the real guarantee if two requests race.
if (!ctx.skipAppCheck && takenSlots(db, input.slot_date).has(input.slot_time)) {
return { status: 409, body: { code: "acme_slot_taken", message: "That time was just taken. Please pick another.", data: { status: 409 } }, db };
}
let next: Db;
let id: number;
try {
({ db: next, id } = insertRow(db, { ...input, status: "new", created_at: new Date(ctx.now).toISOString().slice(0, 19).replace("T", " ") }));
} catch (e) {
if (e instanceof DuplicateEntry) {
return { status: 409, body: { code: "acme_slot_taken", message: "That time was just taken. Please pick another.", data: { status: 409, db_error: e.message } }, db };
}
throw e;
}
const svc = s.services.find((x) => x.slug === input.service)!;
next = wp_mail(next, s.adminEmail, `[Acme Physio] New booking request #${id}`, `${input.name} asked for ${svc.label} on ${input.slot_date} at ${input.slot_time}.\nReview: /wp-admin/admin.php?page=acme-bookings`);
next = wp_mail(next, input.email, "We received your booking request", `Hi ${input.name}, we have your request for ${input.slot_date} at ${input.slot_time}. We will confirm by email.`);
return { status: 201, body: { id, status: "new", message: "Thanks, we will confirm by email." }, db: next };
}
/* ------------------------------------------------------------------ */
/* Admin: list table, row actions, settings */
/* ------------------------------------------------------------------ */
export type ListQuery = { view: Status | "all"; search: string; orderby: "slot" | "created"; order: "asc" | "desc"; page: number; perPage: number };
/** Acme_Bookings_List_Table::prepare_items(): filter, search, sort, paginate. */
export function listBookings(db: Db, q: ListQuery) {
const counts = { all: db.rows.length, new: 0, confirmed: 0, declined: 0, cancelled: 0 } as Record<Status | "all", number>;
for (const r of db.rows) counts[r.status]++;
let rows = db.rows.filter((r) => q.view === "all" || r.status === q.view);
const s = q.search.trim().toLowerCase();
if (s) rows = rows.filter((r) => `${r.name} ${r.email} ${r.phone}`.toLowerCase().includes(s));
rows = [...rows].sort((a, b) => {
const ka = q.orderby === "slot" ? `${a.slot_date} ${a.slot_time}` : a.created_at;
const kb = q.orderby === "slot" ? `${b.slot_date} ${b.slot_time}` : b.created_at;
return q.order === "asc" ? ka.localeCompare(kb) : kb.localeCompare(ka);
});
const total = rows.length;
const pages = Math.max(1, Math.ceil(total / q.perPage));
const page = Math.min(q.page, pages);
return { rows: rows.slice((page - 1) * q.perPage, page * q.perPage), total, pages, page, counts };
}
export type RowAction = "confirm" | "decline" | "cancel" | "delete";
const TRANSITIONS: Record<Exclude<RowAction, "delete">, { from: Status[]; to: Status }> = {
confirm: { from: ["new"], to: "confirmed" },
decline: { from: ["new"], to: "declined" },
cancel: { from: ["confirmed", "new"], to: "cancelled" },
};
/** admin-post.php?action=acme_booking_status: capability, then nonce, then the update. */
export function applyAction(db: Db, ids: number[], action: RowAction, ctx: { user: User; nonce: string; now: number }): Result {
if (!current_user_can(ctx.user, "manage_options")) {
return { status: 403, body: { message: "Sorry, you are not allowed to manage bookings." }, db };
}
if (!wp_verify_nonce(ctx.nonce, "bulk-bookings", ctx.user, ctx.now)) {
return { status: 403, body: { message: "The link you followed has expired." }, db };
}
if (!ids.length) return { status: 400, body: { message: "No bookings selected." }, db };
let next = db;
const done: number[] = [];
const skipped: number[] = [];
if (action === "delete") {
next = { ...db, rows: db.rows.filter((r) => !ids.includes(r.id)) };
return { status: 200, body: { deleted: ids }, db: next };
}
const t = TRANSITIONS[action];
for (const id of ids) {
const row = next.rows.find((r) => r.id === id);
if (!row || !t.from.includes(row.status)) {
skipped.push(id);
continue;
}
next = { ...next, rows: next.rows.map((r) => (r.id === id ? { ...r, status: t.to } : r)) };
done.push(id);
const subject = { confirmed: "Your booking is confirmed", declined: "We could not confirm your booking", cancelled: "Your booking was cancelled" }[t.to as "confirmed" | "declined" | "cancelled"];
next = wp_mail(next, row.email, subject, `Hi ${row.name}, your request for ${row.slot_date} at ${row.slot_time} is now ${t.to}.`);
}
return { status: 200, body: { updated: done, skipped, status: t.to }, db: next };
}
/** options.php with register_setting( 'acme_booking', 'acme_booking_settings', [ 'sanitize_callback' => ... ] ). */
export function saveSettings(db: Db, raw: { slots: string; horizonDays: string; openDays: number[]; adminEmail: string; deleteOnUninstall: boolean }, ctx: { user: User; nonce: string; now: number }): Result {
if (!current_user_can(ctx.user, "manage_options")) return { status: 403, body: { message: "Sorry, you are not allowed to manage options for this site." }, db };
if (!wp_verify_nonce(ctx.nonce, "acme_booking-options", ctx.user, ctx.now)) return { status: 403, body: { message: "The link you followed has expired." }, db };
const errors: string[] = [];
const slots = [...new Set(raw.slots.split(/[\s,]+/).filter(Boolean))].sort();
const bad = slots.filter((x) => !/^([01]\d|2[0-3]):[0-5]\d$/.test(x));
if (bad.length) errors.push(`Invalid time(s): ${bad.join(", ")}. Use HH:MM.`);
if (!slots.length) errors.push("Add at least one time slot.");
const horizon = Number(raw.horizonDays);
if (!Number.isInteger(horizon) || horizon < 1 || horizon > 60) errors.push("Booking window must be 1–60 days.");
if (!is_email(raw.adminEmail)) errors.push("Notification email is not valid.");
if (!raw.openDays.length) errors.push("Choose at least one opening day.");
if (errors.length) return { status: 400, body: { errors }, db };
return {
status: 200,
body: { updated: true },
db: { ...db, settings: { ...db.settings, slots, horizonDays: horizon, openDays: [...raw.openDays].sort(), adminEmail: raw.adminEmail.trim(), deleteOnUninstall: raw.deleteOnUninstall } },
};
}
/* ------------------------------------------------------------------ */
/* Shortcode and block */
/* ------------------------------------------------------------------ */
export type WidgetAtts = { service: string; days: number; title: string };
/** shortcode_atts() with defaults, plus the same sanitising the PHP does. */
export function shortcodeAtts(text: string, s: Settings): { atts: WidgetAtts; error: string | null } {
const m = /^\[acme_booking((?:\s+[a-z_]+="[^"]*")*)\s*\/?\]$/.exec(text.trim());
const defaults: WidgetAtts = { service: "", days: s.horizonDays, title: "Book an appointment" };
if (!m) return { atts: defaults, error: 'Not a valid [acme_booking] shortcode. Example: [acme_booking service="follow-up" days="7"]' };
const atts = { ...defaults };
for (const [, k, v] of m[1]!.matchAll(/([a-z_]+)="([^"]*)"/g)) {
if (k === "service") atts.service = s.services.some((x) => x.slug === v) ? v! : "";
if (k === "days") atts.days = Math.max(1, Math.min(s.horizonDays, Number.parseInt(v!, 10) || s.horizonDays));
if (k === "title") atts.title = sanitize_text_field(v!);
}
return { atts, error: null };
}
export const blockMarkup = (a: WidgetAtts) =>
`<!-- wp:acme/booking ${JSON.stringify({ ...(a.service ? { service: a.service } : {}), days: a.days, title: a.title })} /-->`;app/lab/wordpress-booking-plugin/php/acme-booking.php319 lines
<?php
/**
* Plugin Name: Acme Booking
* Description: Booking requests for small clinics: a form (shortcode and block), an admin list and email alerts.
* Version: 1.0.0
* Requires PHP: 8.1
* Text Domain: acme-booking
*
* Production design for the booking plugin demo. Split across includes/ in the real plugin;
* kept in one file here so it reads top to bottom.
*/
defined( 'ABSPATH' ) || exit;
const ACME_BOOKING_DB_VERSION = '1.1';
/* ------------------------------------------------------------------ */
/* 1. Custom table, created and upgraded with dbDelta */
/* ------------------------------------------------------------------ */
register_activation_hook( __FILE__, 'acme_booking_install' );
add_action( 'plugins_loaded', 'acme_booking_maybe_upgrade' );
function acme_booking_install(): void {
global $wpdb;
$table = $wpdb->prefix . 'acme_bookings';
$charset = $wpdb->get_charset_collate();
// dbDelta is picky: two spaces after PRIMARY KEY, one field per line, KEY not INDEX.
// active_slot is NULL for declined/cancelled rows, so a freed slot can be booked again,
// while the UNIQUE KEY stops two open requests for the same date and time, even under a race.
$sql = "CREATE TABLE {$table} (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
name varchar(80) NOT NULL,
email varchar(190) NOT NULL,
phone varchar(20) NOT NULL,
service varchar(40) NOT NULL,
slot_date date NOT NULL,
slot_time time NOT NULL,
note text NULL,
status varchar(12) NOT NULL DEFAULT 'new',
created_at datetime NOT NULL,
active_slot varchar(20) GENERATED ALWAYS AS (IF(status IN ('new','confirmed'), CONCAT(slot_date,' ',slot_time), NULL)) STORED,
PRIMARY KEY (id),
UNIQUE KEY active_slot (active_slot),
KEY status_date (status, slot_date),
KEY email (email)
) {$charset};";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( $sql );
update_option( 'acme_booking_db_version', ACME_BOOKING_DB_VERSION, false );
add_option( 'acme_booking_settings', acme_booking_default_settings(), '', false ); // not autoloaded on every page
}
function acme_booking_maybe_upgrade(): void {
if ( get_option( 'acme_booking_db_version' ) !== ACME_BOOKING_DB_VERSION ) {
acme_booking_install();
}
}
function acme_booking_default_settings(): array {
return array(
'slots' => array( '09:00', '10:00', '11:00', '12:00', '14:00', '15:00', '16:00' ),
'open_days' => array( 1, 2, 3, 4, 5, 6 ),
'horizon_days' => 14,
'admin_email' => get_option( 'admin_email' ),
'delete_on_uninstall' => false,
);
}
function acme_booking_settings(): array {
return wp_parse_args( (array) get_option( 'acme_booking_settings', array() ), acme_booking_default_settings() );
}
/* ------------------------------------------------------------------ */
/* 2. Front end: shortcode and block share one renderer */
/* ------------------------------------------------------------------ */
add_action( 'init', 'acme_booking_register' );
function acme_booking_register(): void {
add_shortcode( 'acme_booking', 'acme_booking_shortcode' );
// block.json declares attributes (service, days, title), editorScript and "render": "file:./render.php".
register_block_type( __DIR__ . '/build/booking-block' );
wp_register_script( 'acme-booking-form', plugins_url( 'build/form.js', __FILE__ ), array( 'wp-api-fetch' ), '1.0.0', array( 'strategy' => 'defer' ) );
}
function acme_booking_shortcode( $atts ): string {
$s = acme_booking_settings();
$atts = shortcode_atts(
array( 'service' => '', 'days' => $s['horizon_days'], 'title' => __( 'Book an appointment', 'acme-booking' ) ),
$atts,
'acme_booking'
);
$atts['days'] = max( 1, min( (int) $s['horizon_days'], absint( $atts['days'] ) ) );
return acme_booking_render_form( $atts );
}
function acme_booking_render_form( array $atts ): string {
wp_enqueue_script( 'acme-booking-form' );
ob_start();
?>
<form class="acme-booking" data-days="<?php echo esc_attr( $atts['days'] ); ?>" data-service="<?php echo esc_attr( $atts['service'] ); ?>" novalidate>
<h2><?php echo esc_html( $atts['title'] ); ?></h2>
<?php wp_nonce_field( 'acme_booking_submit', '_wpnonce', false ); ?>
<p class="acme-hp" aria-hidden="true"><label>Website <input name="website" tabindex="-1" autocomplete="off"></label></p>
<?php /* Fields, date strip and slot buttons are rendered by template-parts/form-fields.php */ ?>
<?php include __DIR__ . '/template-parts/form-fields.php'; ?>
<div class="acme-booking__status" role="status" aria-live="polite"></div>
</form>
<?php
return (string) ob_get_clean();
}
/* ------------------------------------------------------------------ */
/* 3. REST route: POST /wp-json/acme-booking/v1/bookings */
/* ------------------------------------------------------------------ */
add_action( 'rest_api_init', 'acme_booking_routes' );
function acme_booking_routes(): void {
register_rest_route(
'acme-booking/v1',
'/bookings',
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => 'acme_booking_create',
'permission_callback' => '__return_true', // public form; protected by the nonce, honeypot and rate limit below
'args' => array(
'name' => array( 'type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_text_field' ),
'email' => array( 'type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_email' ),
'phone' => array( 'type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_text_field' ),
'service' => array( 'type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_key' ),
'slot_date' => array( 'type' => 'string', 'required' => true, 'format' => 'date' ),
'slot_time' => array( 'type' => 'string', 'required' => true, 'pattern' => '^([01]\d|2[0-3]):[0-5]\d$' ),
'note' => array( 'type' => 'string', 'default' => '', 'sanitize_callback' => 'sanitize_textarea_field' ),
),
)
);
}
function acme_booking_create( WP_REST_Request $req ) {
global $wpdb;
if ( ! wp_verify_nonce( (string) $req->get_param( '_wpnonce' ), 'acme_booking_submit' ) ) {
return new WP_Error( 'acme_bad_nonce', __( 'Your session expired. Reload the page and try again.', 'acme-booking' ), array( 'status' => 403 ) );
}
if ( '' !== trim( (string) $req->get_param( 'website' ) ) ) {
return new WP_REST_Response( array( 'id' => 0, 'status' => 'new' ), 201 ); // honeypot: look successful, store nothing
}
$s = acme_booking_settings();
$errors = acme_booking_validate( $req, $s ); // field => message, same rules as the demo's createBooking()
if ( $errors ) {
return new WP_Error( 'rest_invalid_param', __( 'Some fields need attention.', 'acme-booking' ), array( 'status' => 400, 'params' => $errors ) );
}
$table = $wpdb->prefix . 'acme_bookings';
$pending = (int) $wpdb->get_var(
$wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE email = %s AND status = 'new'", $req['email'] ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
);
if ( $pending >= 3 ) {
return new WP_Error( 'acme_too_many', __( 'You already have 3 pending requests.', 'acme-booking' ), array( 'status' => 429 ) );
}
// $wpdb->insert prepares every value with the given formats. If another request took the slot a moment ago,
// the UNIQUE KEY makes this insert fail and we answer 409 instead of double-booking.
$ok = $wpdb->insert(
$table,
array(
'name' => $req['name'],
'email' => $req['email'],
'phone' => preg_replace( '/[^\d+]/', '', $req['phone'] ),
'service' => $req['service'],
'slot_date' => $req['slot_date'],
'slot_time' => $req['slot_time'],
'note' => mb_substr( $req['note'], 0, 500 ),
'status' => 'new',
'created_at' => current_time( 'mysql', true ),
),
array( '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' )
);
if ( false === $ok ) {
$taken = str_contains( $wpdb->last_error, 'Duplicate entry' );
return new WP_Error(
$taken ? 'acme_slot_taken' : 'acme_db_error',
$taken ? __( 'That time was just taken. Please pick another.', 'acme-booking' ) : __( 'Could not save your request.', 'acme-booking' ),
array( 'status' => $taken ? 409 : 500 )
);
}
$id = (int) $wpdb->insert_id;
acme_booking_notify_new( $id, $s );
return new WP_REST_Response( array( 'id' => $id, 'status' => 'new' ), 201 );
}
/* ------------------------------------------------------------------ */
/* 4. Admin screen: WP_List_Table, row actions with nonce + capability */
/* ------------------------------------------------------------------ */
add_action( 'admin_menu', 'acme_booking_menu' );
function acme_booking_menu(): void {
$new = acme_booking_count( 'new' );
$badge = $new ? sprintf( ' <span class="awaiting-mod">%d</span>', $new ) : '';
add_menu_page( __( 'Bookings', 'acme-booking' ), __( 'Bookings', 'acme-booking' ) . $badge, 'manage_options', 'acme-bookings', 'acme_booking_admin_page', 'dashicons-calendar-alt', 26 );
add_submenu_page( 'acme-bookings', __( 'Booking settings', 'acme-booking' ), __( 'Settings', 'acme-booking' ), 'manage_options', 'acme-booking-settings', 'acme_booking_settings_page' );
}
function acme_booking_count( string $status ): int {
global $wpdb;
return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}acme_bookings WHERE status = %s", $status ) );
}
add_action( 'admin_post_acme_booking_status', 'acme_booking_handle_status' );
function acme_booking_handle_status(): void {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'Sorry, you are not allowed to manage bookings.', 'acme-booking' ), 403 );
}
check_admin_referer( 'bulk-bookings' ); // dies with "The link you followed has expired." on a bad nonce
$action = sanitize_key( $_REQUEST['booking_action'] ?? '' );
$ids = array_map( 'absint', (array) ( $_REQUEST['booking'] ?? array() ) );
$map = array( 'confirm' => array( 'new' ), 'decline' => array( 'new' ), 'cancel' => array( 'new', 'confirmed' ) );
$to = array( 'confirm' => 'confirmed', 'decline' => 'declined', 'cancel' => 'cancelled' );
global $wpdb;
$table = $wpdb->prefix . 'acme_bookings';
foreach ( $ids as $id ) {
if ( 'delete' === $action ) {
$wpdb->delete( $table, array( 'id' => $id ), array( '%d' ) );
continue;
}
if ( ! isset( $map[ $action ] ) ) {
break;
}
$row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $id ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
if ( ! $row || ! in_array( $row->status, $map[ $action ], true ) ) {
continue; // invalid transition, e.g. confirming a declined request
}
$wpdb->update( $table, array( 'status' => $to[ $action ] ), array( 'id' => $id ), array( '%s' ), array( '%d' ) );
acme_booking_notify_customer( $row, $to[ $action ] );
}
wp_safe_redirect( add_query_arg( 'updated', count( $ids ), admin_url( 'admin.php?page=acme-bookings' ) ) );
exit;
}
/**
* Columns, views (All | New | Confirmed...), sorting and bulk actions. The table class extends WP_List_Table;
* the important part is that every value it prints is escaped and every query is prepared.
*/
function acme_booking_column_name( object $item ): string {
$url = static fn( string $a ) => wp_nonce_url(
admin_url( 'admin-post.php?action=acme_booking_status&booking_action=' . $a . '&booking[]=' . absint( $item->id ) ),
'bulk-bookings'
);
$actions = array();
if ( 'new' === $item->status ) {
$actions['confirm'] = sprintf( '<a href="%s">%s</a>', esc_url( $url( 'confirm' ) ), esc_html__( 'Approve', 'acme-booking' ) );
$actions['decline'] = sprintf( '<a href="%s">%s</a>', esc_url( $url( 'decline' ) ), esc_html__( 'Decline', 'acme-booking' ) );
}
if ( in_array( $item->status, array( 'new', 'confirmed' ), true ) ) {
$actions['cancel'] = sprintf( '<a href="%s">%s</a>', esc_url( $url( 'cancel' ) ), esc_html__( 'Cancel', 'acme-booking' ) );
}
return sprintf( '<strong>%s</strong><br>%s', esc_html( $item->name ), esc_html( $item->email ) ) . ( new WP_List_Table() )->row_actions( $actions );
}
/* ------------------------------------------------------------------ */
/* 5. Settings API, emails, uninstall */
/* ------------------------------------------------------------------ */
add_action( 'admin_init', 'acme_booking_register_settings' );
function acme_booking_register_settings(): void {
register_setting( 'acme_booking', 'acme_booking_settings', array( 'sanitize_callback' => 'acme_booking_sanitize_settings' ) );
}
function acme_booking_sanitize_settings( $input ): array {
$old = acme_booking_settings();
$slots = array_filter( array_map( 'trim', preg_split( '/[\s,]+/', (string) ( $input['slots'] ?? '' ) ) ) );
$valid = array_values( array_filter( $slots, static fn( $t ) => preg_match( '/^([01]\d|2[0-3]):[0-5]\d$/', $t ) ) );
if ( count( $valid ) !== count( $slots ) ) {
add_settings_error( 'acme_booking_settings', 'slots', __( 'Some times were not in HH:MM format and were ignored.', 'acme-booking' ) );
}
$email = sanitize_email( $input['admin_email'] ?? '' );
return array(
'slots' => $valid ? array_values( array_unique( $valid ) ) : $old['slots'],
'open_days' => array_values( array_intersect( array_map( 'absint', (array) ( $input['open_days'] ?? array() ) ), range( 0, 6 ) ) ),
'horizon_days' => max( 1, min( 60, absint( $input['horizon_days'] ?? 14 ) ) ),
'admin_email' => is_email( $email ) ? $email : $old['admin_email'],
'delete_on_uninstall' => ! empty( $input['delete_on_uninstall'] ),
);
}
function acme_booking_notify_new( int $id, array $s ): void {
wp_mail(
$s['admin_email'],
/* translators: %d: booking id */
sprintf( __( '[%1$s] New booking request #%2$d', 'acme-booking' ), wp_specialchars_decode( get_bloginfo( 'name' ) ), $id ),
admin_url( 'admin.php?page=acme-bookings' )
);
}
function acme_booking_notify_customer( object $row, string $status ): void {
/* translators: 1: date, 2: time, 3: new status */
wp_mail( $row->email, __( 'Your booking was updated', 'acme-booking' ), sprintf( __( 'Your request for %1$s at %2$s is now %3$s.', 'acme-booking' ), $row->slot_date, $row->slot_time, $status ) );
}
// uninstall.php (runs only when the plugin is deleted from wp-admin):
// defined( 'WP_UNINSTALL_PLUGIN' ) || exit;
// if ( ! empty( get_option( 'acme_booking_settings' )['delete_on_uninstall'] ) ) {
// global $wpdb;
// $wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}acme_bookings" );
// delete_option( 'acme_booking_settings' );
// delete_option( 'acme_booking_db_version' );
// }app/lab/wordpress-booking-plugin/Demo.tsx689 lines
"use client";
import { useEffect, useMemo, useState } from "react";
import { CodePanel, LogPanel, pushLine, type LogLine, type LogTone } from "@/components/lab/wp/LogPanel";
import { usePersistentState } from "@/components/lab/wp/usePersistentState";
import { WpAdminFrame, WpNotice, wpBtn, wpBtnPrimary } from "@/components/lab/wp/WpAdminFrame";
import {
USERS,
applyAction,
blockMarkup,
bookableDates,
createBooking,
current_user_can,
listBookings,
saveSettings,
seedDb,
shortcodeAtts,
takenSlots,
wp_create_nonce,
type BookingInput,
type Db,
type ListQuery,
type Result,
type RowAction,
type Status,
type User,
} from "./booking";
const KEY = "lab:wordpress-booking-plugin:v1";
type Persisted = { db: Db | null; shortcode: string };
const seed = (): Persisted => ({ db: null, shortcode: '[acme_booking days="10" title="Book a physio visit"]' });
type Tab = "front" | "admin" | "settings" | "database";
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const EMPTY_FORM = { name: "", email: "", phone: "", service: "", slot_date: "", slot_time: "", note: "", website: "" };
const SCHEMA = `CREATE TABLE wp_acme_bookings (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
name varchar(80) NOT NULL,
email varchar(190) NOT NULL,
phone varchar(20) NOT NULL,
service varchar(40) NOT NULL,
slot_date date NOT NULL,
slot_time time NOT NULL,
note text NULL,
status varchar(12) NOT NULL DEFAULT 'new',
created_at datetime NOT NULL,
active_slot varchar(20) GENERATED ALWAYS AS
(IF(status IN ('new','confirmed'), CONCAT(slot_date,' ',slot_time), NULL)) STORED,
PRIMARY KEY (id),
UNIQUE KEY active_slot (active_slot),
KEY status_date (status, slot_date),
KEY email (email)
) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci;`;
const field = "w-full rounded border border-border bg-bg px-2.5 py-1.5 text-sm outline-none focus:border-accent";
const STATUS_STYLE: Record<Status, string> = {
new: "bg-warn-bg text-warn-fg",
confirmed: "bg-accent-soft text-accent",
declined: "bg-bg-muted text-fg-muted",
cancelled: "bg-bg-muted text-fg-muted line-through",
};
export default function Demo() {
const [state, setState, resetState, loaded] = usePersistentState<Persisted>(KEY, seed);
const db = state.db;
const [user, setUser] = useState<User>(USERS[0]!);
const [tab, setTab] = useState<Tab>("front");
const [log, setLog] = useState<LogLine[]>([]);
const [issuedAt, setIssuedAt] = useState(0); // when the page that holds the nonces was rendered
const [staleNonce, setStaleNonce] = useState(false);
const [race, setRace] = useState(false);
// front-end form
const [form, setForm] = useState(EMPTY_FORM);
const [errors, setErrors] = useState<Record<string, string>>({});
const [formMsg, setFormMsg] = useState<{ ok: boolean; text: string } | null>(null);
const [busy, setBusy] = useState(false);
// admin
const [q, setQ] = useState<ListQuery>({ view: "all", search: "", orderby: "slot", order: "asc", page: 1, perPage: 5 });
const [checked, setChecked] = useState<number[]>([]);
const [bulk, setBulk] = useState<RowAction | "">("");
const [notice, setNotice] = useState<{ type: "success" | "error"; text: string } | null>(null);
const [searchBox, setSearchBox] = useState("");
// settings form
const [sf, setSf] = useState({ slots: "", horizonDays: "", openDays: [] as number[], adminEmail: "", deleteOnUninstall: false });
const push = (text: string, tone: LogTone) => setLog((l) => pushLine(l, text, tone));
const today = () => {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
};
// Seed with dates relative to today, after mount (keeps the server render deterministic).
useEffect(() => {
if (!loaded) return;
if (!state.db) setState((s) => ({ ...s, db: seedDb(today()) }));
setIssuedAt(Date.now());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loaded]);
useEffect(() => {
if (db && tab === "settings") {
setSf({ slots: db.settings.slots.join(", "), horizonDays: String(db.settings.horizonDays), openDays: db.settings.openDays, adminEmail: db.settings.adminEmail, deleteOnUninstall: db.settings.deleteOnUninstall });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tab]);
/** The nonce printed into the page when it was rendered for this user. "Stale" pretends the tab sat open for 25 hours. */
const nonceFor = (action: string) => wp_create_nonce(action, user, staleNonce ? issuedAt - 25 * 3_600_000 : issuedAt);
function switchUser(id: number) {
const u = USERS.find((x) => x.id === id)!;
setUser(u);
setIssuedAt(Date.now()); // logging in or out reloads the page, so new nonces are printed
setNotice(null);
setChecked([]);
push(`wp_set_current_user(${u.id}) → ${u.login}; page reloaded with fresh nonces`, "dim");
}
const sc = useMemo(() => (db ? shortcodeAtts(state.shortcode, db.settings) : null), [db, state.shortcode]);
const atts = sc?.atts;
const service = atts?.service || form.service;
const dates = db && atts ? bookableDates(db, atts.days) : [];
const taken = db && form.slot_date ? takenSlots(db, form.slot_date) : new Set<string>();
function logResult(prefix: string, r: Result) {
const tone: LogTone = r.status < 300 ? "ok" : r.status === 409 || r.status === 429 ? "warn" : "err";
push(`${prefix} → ${r.status} ${JSON.stringify(r.body)}`, tone);
}
function submit(e: React.FormEvent) {
e.preventDefault();
if (!db) return;
setBusy(true);
const input: BookingInput = { ...form, service, _wpnonce: nonceFor("acme_booking_submit") };
push(`POST /wp-json/acme-booking/v1/bookings _wpnonce=${input._wpnonce}${race ? " (app-level slot check skipped)" : ""}`, "info");
// A short delay so the loading state is visible, like a real fetch.
setTimeout(() => {
const r = createBooking(db, input, { user, now: Date.now(), skipAppCheck: race });
logResult(" ", r);
setBusy(false);
const body = r.body as { message?: string; data?: { params?: Record<string, string>; db_error?: string }; id?: number };
if (r.status === 201) {
setState((s) => ({ ...s, db: r.db }));
setErrors({});
setForm(EMPTY_FORM);
setFormMsg({ ok: true, text: body.id ? `Request #${body.id} received. We will confirm by email.` : "Thanks, we will confirm by email." });
if (r.db.outbox.length > db.outbox.length) push(` wp_mail × ${r.db.outbox.length - db.outbox.length} (admin + customer)`, "dim");
} else {
setErrors(body.data?.params ?? {});
if (body.data?.db_error) push(` MySQL: ${body.data.db_error}`, "err");
setFormMsg({ ok: false, text: body.message ?? "Something went wrong." });
}
}, 350);
}
function act(ids: number[], action: RowAction, crafted = false) {
if (!db) return;
const nonce = nonceFor("bulk-bookings");
push(`${crafted ? "GET (crafted URL) " : ""}admin-post.php?action=acme_booking_status&booking_action=${action}&booking[]=${ids.join(",")}&_wpnonce=${nonce}`, "info");
const r = applyAction(db, ids, action, { user, nonce, now: Date.now() });
logResult(" ", r);
if (r.status !== 200) {
setNotice({ type: "error", text: (r.body as { message: string }).message });
return;
}
setState((s) => ({ ...s, db: r.db }));
setChecked([]);
const b = r.body as { updated?: number[]; skipped?: number[]; deleted?: number[]; status?: string };
const mails = r.db.outbox.length - db.outbox.length;
if (mails > 0) push(` wp_mail × ${mails} to customers`, "dim");
setNotice({
type: "success",
text: b.deleted
? `${b.deleted.length} booking${b.deleted.length === 1 ? "" : "s"} deleted.`
: `${b.updated!.length} booking${b.updated!.length === 1 ? "" : "s"} ${b.status}.${b.skipped!.length ? ` ${b.skipped!.length} skipped (status did not allow it).` : ""}`,
});
}
function onSaveSettings(e: React.FormEvent) {
e.preventDefault();
if (!db) return;
const nonce = nonceFor("acme_booking-options");
push(`POST options.php option_page=acme_booking _wpnonce=${nonce}`, "info");
const r = saveSettings(db, sf, { user, nonce, now: Date.now() });
logResult(" ", r);
if (r.status === 200) {
setState((s) => ({ ...s, db: r.db }));
setNotice({ type: "success", text: "Settings saved." });
} else {
const b = r.body as { errors?: string[]; message?: string };
setNotice({ type: "error", text: b.errors?.join(" ") ?? b.message ?? "Error" });
}
}
function reset() {
resetState();
setState({ ...seed(), db: seedDb(today()) });
setLog([]);
setForm(EMPTY_FORM);
setErrors({});
setFormMsg(null);
setNotice(null);
setChecked([]);
setStaleNonce(false);
setRace(false);
setIssuedAt(Date.now());
}
if (!db || !atts) return <p className="p-6 text-sm text-fg-muted">Loading the demo site…</p>;
const isAdmin = current_user_can(user, "manage_options");
const list = listBookings(db, q);
const svcLabel = (slug: string) => db.settings.services.find((s) => s.slug === slug)?.label ?? slug;
const errId = (k: string) => (errors[k] ? `bk-${k}-err` : undefined);
return (
<div className="space-y-4 p-3 sm:p-5">
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-xs">
<label className="flex items-center gap-1.5">
Current user
<select value={user.id} onChange={(e) => switchUser(Number(e.target.value))} className="rounded border border-border bg-bg-elev px-1.5 py-1">
{USERS.map((u) => (
<option key={u.id} value={u.id}>
{u.login}
</option>
))}
</select>
</label>
<label className="flex items-center gap-1.5">
<input type="checkbox" checked={staleNonce} onChange={(e) => setStaleNonce(e.target.checked)} />
Page left open 25 h (stale nonces)
</label>
<button type="button" onClick={reset} className="rounded border border-border px-2 py-1 font-mono hover:border-accent">
reset demo
</button>
</div>
<div role="tablist" aria-label="Demo views" className="flex flex-wrap gap-1 border-b border-border">
{(
[
["front", "Front-end page"],
["admin", `wp-admin › Bookings${list.counts.new ? ` (${list.counts.new})` : ""}`],
["settings", "Settings & shortcode"],
["database", "Database & mail"],
] as const
).map(([id, label]) => (
<button
key={id}
type="button"
role="tab"
aria-selected={tab === id}
onClick={() => {
setTab(id);
setNotice(null);
}}
className={`-mb-px border-b-2 px-3 py-1.5 font-mono text-xs ${tab === id ? "border-accent text-accent" : "border-transparent text-fg-muted hover:text-fg"}`}
>
{label}
</button>
))}
</div>
<div className="grid gap-4 xl:grid-cols-[1.5fr_1fr]">
<div className="min-w-0">
{/* ---------------- Front end ---------------- */}
{tab === "front" && (
<div className="rounded-md border border-border bg-bg-elev">
<div className="border-b border-border px-4 py-2 text-sm">
<span className="font-semibold">Acme Physio</span> <span className="text-fg-muted">/ book</span>
<code className="ml-2 font-mono text-[11px] text-fg-muted">{state.shortcode}</code>
</div>
<form onSubmit={submit} noValidate className="space-y-4 p-4" aria-busy={busy}>
<h4 className="text-lg font-semibold">{atts.title}</h4>
{sc?.error && <p className="text-sm text-danger">{sc.error}</p>}
{!atts.service ? (
<label className="block">
<span className="text-sm font-medium">Service</span>
<select value={form.service} onChange={(e) => setForm({ ...form, service: e.target.value })} className={`${field} mt-1`} aria-invalid={!!errors.service} aria-describedby={errId("service")}>
<option value="">Choose…</option>
{db.settings.services.map((s) => (
<option key={s.slug} value={s.slug}>
{s.label} ({s.minutes} min)
</option>
))}
</select>
<FieldError id="service" errors={errors} />
</label>
) : (
<p className="text-sm">
Service: <strong>{svcLabel(atts.service)}</strong> <span className="text-fg-muted">(set by the shortcode)</span>
</p>
)}
<fieldset aria-describedby={errId("slot_date")}>
<legend className="text-sm font-medium">Date</legend>
<div className="mt-1 flex gap-1.5 overflow-x-auto pb-1">
{dates.map((d) => {
const dt = new Date(d.date + "T12:00:00Z");
return (
<button
key={d.date}
type="button"
disabled={!d.open}
aria-pressed={form.slot_date === d.date}
aria-label={`${dt.toLocaleDateString("en-GB", { weekday: "long", day: "numeric", month: "long", timeZone: "UTC" })}${d.open ? "" : ", closed"}`}
onClick={() => setForm({ ...form, slot_date: d.date, slot_time: "" })}
className={`w-14 shrink-0 rounded border py-1.5 text-center text-xs disabled:cursor-not-allowed disabled:opacity-35 ${form.slot_date === d.date ? "border-accent bg-accent-soft" : "border-border hover:border-accent"}`}
>
<span className="block text-fg-muted">{DAYS[dt.getUTCDay()]}</span>
<span className="block text-base font-semibold">{dt.getUTCDate()}</span>
</button>
);
})}
</div>
<FieldError id="slot_date" errors={errors} />
</fieldset>
<fieldset aria-describedby={errId("slot_time")}>
<legend className="text-sm font-medium">Time</legend>
{!form.slot_date ? (
<p className="mt-1 text-xs text-fg-muted">Pick a date to see free times.</p>
) : (
<div className="mt-1 flex flex-wrap gap-1.5">
{db.settings.slots.map((t) => {
const isTaken = taken.has(t);
return (
<button
key={t}
type="button"
disabled={isTaken && !race}
aria-pressed={form.slot_time === t}
onClick={() => setForm({ ...form, slot_time: t })}
className={`rounded border px-2.5 py-1 font-mono text-xs disabled:cursor-not-allowed disabled:opacity-35 disabled:line-through ${form.slot_time === t ? "border-accent bg-accent-soft" : isTaken ? "border-dashed border-danger" : "border-border hover:border-accent"}`}
>
{t}
{isTaken && <span className="sr-only"> (already requested)</span>}
</button>
);
})}
</div>
)}
<FieldError id="slot_time" errors={errors} />
</fieldset>
<div className="grid gap-3 sm:grid-cols-3">
{(
[
["name", "Name", "text", "name"],
["email", "Email", "email", "email"],
["phone", "Phone", "tel", "tel"],
] as const
).map(([k, label, type, ac]) => (
<label key={k} className="block">
<span className="text-sm font-medium">{label}</span>
<input
type={type}
autoComplete={ac}
value={form[k]}
onChange={(e) => setForm({ ...form, [k]: e.target.value })}
aria-invalid={!!errors[k]}
aria-describedby={errId(k)}
className={`${field} mt-1 ${errors[k] ? "border-danger" : ""}`}
/>
<FieldError id={k} errors={errors} />
</label>
))}
</div>
<label className="block">
<span className="text-sm font-medium">Note (optional)</span>
<textarea value={form.note} onChange={(e) => setForm({ ...form, note: e.target.value })} rows={2} maxLength={500} className={`${field} mt-1`} />
</label>
{/* Honeypot: hidden from people and screen readers; bots fill it. Exposed here as a toggle. */}
<div className="flex flex-wrap gap-x-4 gap-y-1 rounded border border-dashed border-border p-2 text-xs text-fg-muted">
<span className="w-full font-mono">tamper panel (not part of the real form)</span>
<label className="flex items-center gap-1.5">
<input type="checkbox" checked={!!form.website} onChange={(e) => setForm({ ...form, website: e.target.checked ? "http://spam.example" : "" })} />
Fill the honeypot like a bot
</label>
<label className="flex items-center gap-1.5">
<input type="checkbox" checked={race} onChange={(e) => setRace(e.target.checked)} />
Skip the PHP slot check (two visitors racing)
</label>
<button type="button" className="underline" onClick={() => setForm({ ...form, name: "Test Patient", email: "patient@example.com", phone: "0300 1234567" })}>
Fill contact details
</button>
</div>
<div className="flex flex-wrap items-center gap-3">
<button type="submit" disabled={busy} className="rounded-md bg-accent px-4 py-2 text-sm font-medium text-accent-fg hover:opacity-90 disabled:opacity-50">
{busy ? "Sending…" : "Request booking"}
</button>
<p role="status" aria-live="polite" className={`text-sm ${formMsg?.ok ? "text-accent" : "text-danger"}`}>
{formMsg?.text}
</p>
</div>
</form>
</div>
)}
{/* ---------------- wp-admin ---------------- */}
{(tab === "admin" || tab === "settings") && (
<WpAdminFrame
site="Acme Physio"
user={user.login.split(" ")[0]!}
active={tab}
onSelect={(id) => setTab(id as Tab)}
menu={
isAdmin
? [
{ id: "admin", label: "Bookings", icon: "▦", badge: list.counts.new },
{ id: "settings", label: "Settings", icon: "⚙" },
]
: [{ id: "admin", label: "Dashboard", icon: "⌂" }]
}
title={tab === "admin" ? (isAdmin ? "Booking requests" : "Bookings") : "Booking settings"}
>
{notice && <WpNotice type={notice.type}>{notice.text}</WpNotice>}
{!isAdmin ? (
<div className="space-y-3 text-sm">
<p className="rounded border border-border bg-bg-elev p-3">
Sorry, you are not allowed to access this page. <span className="text-fg-muted">(menu registered with the <code>manage_options</code> capability)</span>
</p>
<p className="text-fg-muted">The menu is hidden, but hiding is not security. Try calling the action URL directly:</p>
<button type="button" className={wpBtn} onClick={() => act([db.rows[0]?.id ?? 0], "confirm", true)}>
Send a crafted “approve #{db.rows[0]?.id}” request
</button>
</div>
) : tab === "admin" ? (
<div className="space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2 text-[13px]">
<ul className="flex flex-wrap gap-1 text-fg-muted">
{(["all", "new", "confirmed", "declined", "cancelled"] as const).map((v, i) => (
<li key={v}>
{i > 0 && "| "}
<button type="button" onClick={() => setQ({ ...q, view: v, page: 1 })} className={q.view === v ? "font-semibold text-fg" : "text-accent-2 hover:underline"} aria-current={q.view === v ? "true" : undefined}>
{v[0]!.toUpperCase() + v.slice(1)} ({list.counts[v]})
</button>
</li>
))}
</ul>
<form
role="search"
onSubmit={(e) => {
e.preventDefault();
setQ({ ...q, search: searchBox, page: 1 });
}}
className="flex gap-1"
>
<label htmlFor="bk-search" className="sr-only">
Search bookings
</label>
<input id="bk-search" value={searchBox} onChange={(e) => setSearchBox(e.target.value)} className="w-36 rounded border border-border bg-bg-elev px-2 py-1 text-[13px]" />
<button type="submit" className={wpBtn}>
Search
</button>
</form>
</div>
<div className="flex flex-wrap items-center gap-2">
<label htmlFor="bk-bulk" className="sr-only">
Bulk action
</label>
<select id="bk-bulk" value={bulk} onChange={(e) => setBulk(e.target.value as RowAction | "")} className="rounded border border-border bg-bg-elev px-2 py-1 text-[13px]">
<option value="">Bulk actions</option>
<option value="confirm">Approve</option>
<option value="decline">Decline</option>
<option value="cancel">Cancel</option>
<option value="delete">Delete</option>
</select>
<button type="button" className={wpBtn} disabled={!bulk || !checked.length} onClick={() => bulk && act(checked, bulk)}>
Apply
</button>
<span className="ml-auto text-[12px] text-fg-muted">{list.total} items</span>
</div>
<div className="overflow-x-auto">
<table className="w-full border border-border bg-bg-elev text-left text-[13px]">
<caption className="sr-only">Booking requests</caption>
<thead className="border-b border-border text-fg-muted">
<tr>
<td className="w-8 px-2 py-1.5">
<input
type="checkbox"
aria-label="Select all"
checked={list.rows.length > 0 && list.rows.every((r) => checked.includes(r.id))}
onChange={(e) => setChecked(e.target.checked ? list.rows.map((r) => r.id) : [])}
/>
</td>
<th className="px-2 py-1.5 font-normal">Patient</th>
<th className="px-2 py-1.5 font-normal" aria-sort={q.orderby === "slot" ? (q.order === "asc" ? "ascending" : "descending") : "none"}>
<button type="button" className="hover:text-fg" onClick={() => setQ({ ...q, orderby: "slot", order: q.orderby === "slot" && q.order === "asc" ? "desc" : "asc" })}>
Appointment {q.orderby === "slot" ? (q.order === "asc" ? "▲" : "▼") : ""}
</button>
</th>
<th className="px-2 py-1.5 font-normal">Status</th>
<th className="px-2 py-1.5 font-normal" aria-sort={q.orderby === "created" ? (q.order === "asc" ? "ascending" : "descending") : "none"}>
<button type="button" className="hover:text-fg" onClick={() => setQ({ ...q, orderby: "created", order: q.orderby === "created" && q.order === "desc" ? "asc" : "desc" })}>
Received {q.orderby === "created" ? (q.order === "asc" ? "▲" : "▼") : ""}
</button>
</th>
</tr>
</thead>
<tbody>
{list.rows.length === 0 && (
<tr>
<td colSpan={5} className="px-2 py-4 text-center text-fg-muted">
No bookings found.
</td>
</tr>
)}
{list.rows.map((r) => (
<tr key={r.id} className="group border-b border-border align-top">
<td className="px-2 py-1.5">
<input
type="checkbox"
aria-label={`Select booking #${r.id}`}
checked={checked.includes(r.id)}
onChange={(e) => setChecked(e.target.checked ? [...checked, r.id] : checked.filter((x) => x !== r.id))}
/>
</td>
<td className="px-2 py-1.5">
<strong>{r.name}</strong>
<div className="text-[12px] text-fg-muted">{r.email}</div>
<div className="mt-0.5 flex flex-wrap gap-x-2 text-[12px] opacity-100 sm:opacity-0 sm:group-focus-within:opacity-100 sm:group-hover:opacity-100">
{r.status === "new" && (
<>
<button type="button" className="text-accent-2 hover:underline" onClick={() => act([r.id], "confirm")}>
Approve
</button>
<button type="button" className="text-accent-2 hover:underline" onClick={() => act([r.id], "decline")}>
Decline
</button>
</>
)}
{(r.status === "new" || r.status === "confirmed") && (
<button type="button" className="text-accent-2 hover:underline" onClick={() => act([r.id], "cancel")}>
Cancel
</button>
)}
<button type="button" className="text-danger hover:underline" onClick={() => act([r.id], "delete")} aria-label={`Delete booking #${r.id}`}>
Delete
</button>
</div>
</td>
<td className="px-2 py-1.5 whitespace-nowrap">
{r.slot_date} {r.slot_time}
<div className="text-[12px] text-fg-muted">{svcLabel(r.service)}</div>
</td>
<td className="px-2 py-1.5">
<span className={`rounded px-1.5 py-0.5 text-[11px] ${STATUS_STYLE[r.status]}`}>{r.status}</span>
</td>
<td className="px-2 py-1.5 text-[12px] whitespace-nowrap text-fg-muted">{r.created_at.slice(0, 16)}</td>
</tr>
))}
</tbody>
</table>
</div>
<nav aria-label="Pagination" className="flex items-center justify-end gap-2 text-[13px]">
<button type="button" className={wpBtn} disabled={list.page <= 1} onClick={() => setQ({ ...q, page: list.page - 1 })} aria-label="Previous page">
‹
</button>
<span>
{list.page} of {list.pages}
</span>
<button type="button" className={wpBtn} disabled={list.page >= list.pages} onClick={() => setQ({ ...q, page: list.page + 1 })} aria-label="Next page">
›
</button>
</nav>
</div>
) : (
<div className="space-y-5">
<form onSubmit={onSaveSettings} className="space-y-3 text-[13px]" noValidate>
<label className="block">
<span>Time slots (HH:MM, comma separated)</span>
<input value={sf.slots} onChange={(e) => setSf({ ...sf, slots: e.target.value })} className={`${field} mt-1 font-mono`} />
</label>
<fieldset>
<legend>Open on</legend>
<div className="mt-1 flex flex-wrap gap-3">
{DAYS.map((d, i) => (
<label key={d} className="flex items-center gap-1">
<input type="checkbox" checked={sf.openDays.includes(i)} onChange={(e) => setSf({ ...sf, openDays: e.target.checked ? [...sf.openDays, i] : sf.openDays.filter((x) => x !== i) })} />
{d}
</label>
))}
</div>
</fieldset>
<div className="grid gap-3 sm:grid-cols-2">
<label className="block">
<span>Booking window (days)</span>
<input type="number" value={sf.horizonDays} onChange={(e) => setSf({ ...sf, horizonDays: e.target.value })} className={`${field} mt-1`} />
</label>
<label className="block">
<span>Notification email</span>
<input type="email" value={sf.adminEmail} onChange={(e) => setSf({ ...sf, adminEmail: e.target.value })} className={`${field} mt-1`} />
</label>
</div>
<label className="flex items-center gap-2">
<input type="checkbox" checked={sf.deleteOnUninstall} onChange={(e) => setSf({ ...sf, deleteOnUninstall: e.target.checked })} />
Delete the bookings table and settings when the plugin is deleted
</label>
<button type="submit" className={wpBtnPrimary}>
Save Changes
</button>
</form>
<div className="space-y-2 border-t border-border pt-4 text-[13px]">
<label className="block">
<span className="font-semibold">Shortcode</span>
<span className="block text-fg-muted">Edit it, then open the front-end tab. Attributes: service, days, title.</span>
<input value={state.shortcode} onChange={(e) => setState((s) => ({ ...s, shortcode: e.target.value }))} className={`${field} mt-1 font-mono`} aria-invalid={!!sc?.error} />
</label>
{sc?.error && <p className="text-danger">{sc.error}</p>}
<p className="text-fg-muted">
Parsed by <code>shortcode_atts()</code>: <code className="break-all">{JSON.stringify(atts)}</code>
</p>
<CodePanel label="Same widget as a block (post_content)" code={blockMarkup(atts)} className="max-h-24" />
</div>
</div>
)}
</WpAdminFrame>
)}
{/* ---------------- Database ---------------- */}
{tab === "database" && (
<div className="space-y-4">
<CodePanel label="Created on activation with dbDelta()" code={SCHEMA} />
<div className="overflow-x-auto">
<table className="w-full text-left font-mono text-[11px]">
<caption className="mb-1 text-left font-sans text-xs text-fg-muted">SELECT id, name, slot_date, slot_time, status, active_slot FROM wp_acme_bookings;</caption>
<thead className="text-fg-muted">
<tr className="border-b border-border">
{["id", "name", "slot_date", "slot_time", "status", "active_slot"].map((h) => (
<th key={h} className="py-1 pr-3 font-normal">
{h}
</th>
))}
</tr>
</thead>
<tbody>
{db.rows.map((r) => (
<tr key={r.id} className="border-b border-border">
<td className="py-1 pr-3">{r.id}</td>
<td className="py-1 pr-3">{r.name}</td>
<td className="py-1 pr-3">{r.slot_date}</td>
<td className="py-1 pr-3">{r.slot_time}:00</td>
<td className="py-1 pr-3">{r.status}</td>
<td className={`py-1 pr-3 ${r.status === "new" || r.status === "confirmed" ? "text-accent" : "text-fg-muted"}`}>{r.status === "new" || r.status === "confirmed" ? `${r.slot_date} ${r.slot_time}` : "NULL"}</td>
</tr>
))}
</tbody>
</table>
<p className="mt-1 text-xs text-fg-muted">AUTO_INCREMENT = {db.autoIncrement}. Declined and cancelled rows have NULL active_slot, so their slot can be requested again.</p>
</div>
</div>
)}
</div>
<div className="min-w-0 space-y-4">
<LogPanel lines={log} label="request log" empty="$ submit the form or approve a booking…" className="h-64" />
<section aria-label="Outbox">
<h3 className="font-mono text-sm font-semibold">wp_mail outbox ({db.outbox.length})</h3>
<ul className="mt-2 max-h-64 space-y-2 overflow-y-auto">
{db.outbox.length === 0 && <li className="text-xs text-fg-muted">No emails sent yet.</li>}
{db.outbox.map((m) => (
<li key={m.id} className="rounded border border-border bg-bg-elev p-2 text-xs">
<p className="font-mono text-fg-muted">To: {m.to}</p>
<p className="font-medium">{m.subject}</p>
<p className="whitespace-pre-line text-fg-muted">{m.body}</p>
</li>
))}
</ul>
</section>
</div>
</div>
</div>
);
}
function FieldError({ id, errors }: { id: string; errors: Record<string, string> }) {
if (!errors[id]) return null;
return (
<span id={`bk-${id}-err`} className="mt-0.5 block text-xs text-danger">
{errors[id]}
</span>
);
}