Add lesson booking registration flow (offering, questions, policies)
CI / Coding Standards (pull_request) Successful in 1m51s
CI / PHPStan (pull_request) Successful in 2m17s
CI / Tests (PHP 8.1) (pull_request) Successful in 2m24s
CI / No Debug Code (pull_request) Successful in 2s
CI / Tests (PHP 8.2) (pull_request) Successful in 42s
CI / Tests (PHP 8.3) (pull_request) Successful in 47s
CI / Build Plugin Zip (pull_request) Has been skipped

Implements #3: students register for a private lesson by picking a slot,
answering the offering's intake questions, and accepting booking-scoped
policies. Payment is a clean seam for #7 (lessons land pending; payment_id
null; instructor confirms via PATCH /bookings/{id}/status).

- Schema: us_lessons += offering_id, recurrence, series_id, payment_id.
- Lesson: new fields + recurrence constants.
- BookingRepository::insertSeries() builds a weekly series sharing a
  series_id; AvailabilityRepository::findUnbookedInGroup() reserves a group.
- RegistrationGate (src/Registration/): validate + record intake answers and
  booking-scoped policy acceptances. Reused by group enrolment (#4).
- BookingEndpoint::book(): offering_id, recurrence, answers,
  accepted_policy_version_ids; single or weekly; records answers/acceptances
  (type lesson).
- GET /policies?scope=booking filter.
- Front-end booking.js: slot -> questions + policies -> submit.
- Wiring: RegistrationGate built in Plugin, passed via RestRegistrar.
- Test-only WP_Error stub in tests/bootstrap.php for gate testing.

Refs #3

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 11:25:30 -03:00
parent d0dddd9075
commit 6d163e5d0e
15 changed files with 649 additions and 68 deletions
+118 -29
View File
@@ -5,9 +5,9 @@
const app = document.getElementById('us-booking-app');
if (!app) return;
const slotList = document.getElementById('us-slot-list');
const confirm = document.getElementById('us-booking-confirmation');
const errorBox = document.getElementById('us-booking-error');
const slotList = document.getElementById('us-slot-list');
const confirm = document.getElementById('us-booking-confirmation');
const errorBox = document.getElementById('us-booking-error');
const { restUrl, nonce } = usScheduler;
function apiFetch(path, options = {}) {
@@ -30,14 +30,21 @@
errorBox.style.display = 'block';
}
function dayKey(dt) {
return String(dt).slice(0, 10);
function clearError() {
errorBox.style.display = 'none';
}
function timeOf(dt) {
return String(dt).slice(11, 16);
function escHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
const dayKey = (dt) => String(dt).slice(0, 10);
const timeOf = (dt) => String(dt).slice(11, 16);
function dayLabel(key) {
const date = new Date(key + 'T00:00:00');
if (Number.isNaN(date.getTime())) return key;
@@ -56,9 +63,7 @@
return [...groups.entries()].sort((a, b) => a[0].localeCompare(b[0]));
}
// Agenda-style calendar: available slots grouped by day. The richer booking
// UX (offering selection, intake questions, policy acceptance) lands with the
// booking-flow work.
// Agenda-style calendar: available slots grouped by day.
function renderSlots(slots) {
if (!slots.length) {
slotList.innerHTML = '<p>No available lesson slots at this time.</p>';
@@ -78,33 +83,117 @@
`).join('');
slotList.querySelectorAll('.us-book-btn').forEach((btn) => {
btn.addEventListener('click', () => bookSlot(Number(btn.dataset.slotId)));
const slot = slots.find((s) => String(s.id) === btn.dataset.slotId);
btn.addEventListener('click', () => openRegistration(slot));
});
}
function escHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
function questionField(q) {
const name = `q_${q.id}`;
const required = q.is_required ? 'required' : '';
let input;
if (q.field_type === 'textarea') {
input = `<textarea name="${name}" ${required}></textarea>`;
} else if (q.field_type === 'select') {
const opts = (q.options || []).map((o) => `<option value="${escHtml(o)}">${escHtml(o)}</option>`).join('');
input = `<select name="${name}" ${required}><option value="">—</option>${opts}</select>`;
} else if (q.field_type === 'checkbox') {
input = `<input type="checkbox" name="${name}" value="1">`;
} else {
input = `<input type="text" name="${name}" ${required}>`;
}
return `<p class="us-question"><label>${escHtml(q.label)}<br>${input}</label></p>`;
}
function bookSlot(slotId) {
errorBox.style.display = 'none';
function policyField(p) {
return `
<div class="us-policy">
<h4>${escHtml(p.title)}</h4>
<div class="us-policy-body">${p.body || ''}</div>
<label><input type="checkbox" class="us-policy-accept" value="${p.policy_version_id}" required> I have read and agree to the ${escHtml(p.title)}.</label>
</div>`;
}
apiFetch('bookings', {
method: 'POST',
body: JSON.stringify({ slot_id: slotId }),
})
.then(() => {
slotList.style.display = 'none';
confirm.style.display = 'block';
function openRegistration(slot) {
clearError();
const offeringId = Number(slot.offering_id) || 0;
const qPath = offeringId ? `offerings/${offeringId}/questions` : null;
Promise.all([
qPath ? apiFetch(qPath) : Promise.resolve([]),
apiFetch('policies?scope=booking'),
])
.then(([questions, policies]) => {
renderRegistration(slot, offeringId, questions, policies);
})
.catch((err) => showError(err.message));
}
apiFetch('availability')
.then(renderSlots)
.catch((err) => showError(err.message));
function renderRegistration(slot, offeringId, questions, policies) {
const weekly = slot.recurrence_group
? `<p><label><input type="checkbox" id="us-weekly"> Reserve this time weekly for the term</label></p>`
: '';
slotList.innerHTML = `
<div class="us-register">
<h3>${escHtml(dayLabel(dayKey(slot.start_dt)))} · ${escHtml(timeOf(slot.start_dt))}${escHtml(timeOf(slot.end_dt))}</h3>
<form id="us-register-form">
${questions.map(questionField).join('')}
${policies.map(policyField).join('')}
${weekly}
<p>
<button type="submit" class="us-book-btn">Confirm Booking</button>
<button type="button" id="us-cancel" class="us-cancel-btn">Back</button>
</p>
</form>
</div>`;
document.getElementById('us-cancel').addEventListener('click', loadSlots);
document.getElementById('us-register-form').addEventListener('submit', (e) => {
e.preventDefault();
submitBooking(e.target, slot, offeringId, questions);
});
}
function submitBooking(form, slot, offeringId, questions) {
clearError();
const answers = {};
questions.forEach((q) => {
const field = form.elements[`q_${q.id}`];
if (!field) return;
answers[q.id] = field.type === 'checkbox' ? (field.checked ? '1' : '0') : field.value;
});
const accepted = [...form.querySelectorAll('.us-policy-accept:checked')].map((c) => Number(c.value));
const weeklyEl = document.getElementById('us-weekly');
apiFetch('bookings', {
method: 'POST',
body: JSON.stringify({
slot_id: slot.id,
offering_id: offeringId,
recurrence: weeklyEl && weeklyEl.checked ? 'weekly' : 'single',
answers,
accepted_policy_version_ids: accepted,
}),
})
.then(() => {
slotList.style.display = 'none';
confirm.style.display = 'block';
})
.catch((err) => showError(err.message));
}
function loadSlots() {
clearError();
slotList.style.display = 'block';
confirm.style.display = 'none';
apiFetch('availability')
.then(renderSlots)
.catch((err) => showError(err.message));
}
loadSlots();
}());