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
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:
+118
-29
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
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();
|
||||
}());
|
||||
|
||||
Reference in New Issue
Block a user