Require an offering on every lesson booking, with a student-facing picker
CI / Tests (PHP 8.1) (pull_request) Successful in 41s
CI / Tests (PHP 8.2) (pull_request) Successful in 45s
CI / No Debug Code (pull_request) Successful in 2s
CI / Tests (PHP 8.3) (pull_request) Successful in 1m40s
CI / PHPStan (pull_request) Successful in 2m24s
CI / Coding Standards (pull_request) Successful in 2m49s
CI / Build Plugin Zip (pull_request) Has been skipped
CI / Tests (PHP 8.1) (pull_request) Successful in 41s
CI / Tests (PHP 8.2) (pull_request) Successful in 45s
CI / No Debug Code (pull_request) Successful in 2s
CI / Tests (PHP 8.3) (pull_request) Successful in 1m40s
CI / PHPStan (pull_request) Successful in 2m24s
CI / Coding Standards (pull_request) Successful in 2m49s
CI / Build Plugin Zip (pull_request) Has been skipped
Generic slots (no tied offering) were bookable with no offering at all: free, instantly confirmed, and with no intake questions. POST /bookings now rejects offering-less bookings (400 offering_required), and a student-chosen offering must be an active private-lesson type owned by the slot's instructor whose duration matches the slot. The registration form gains a Lesson type field: locked to the slot's tied offering (title, duration, price) so the student sees what they are booking, or a required picker of fitting offerings for generic slots, with intake questions following the selection. Fixes #55 Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
+103
-9
@@ -210,23 +210,84 @@
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Active private-lesson offerings per instructor, so revisiting the
|
||||
// registration form does not refetch the same catalog.
|
||||
const offeringCache = new Map();
|
||||
|
||||
function instructorOfferings(instructorId) {
|
||||
if (offeringCache.has(instructorId)) {
|
||||
return Promise.resolve(offeringCache.get(instructorId));
|
||||
}
|
||||
return apiFetch(`offerings?instructor_id=${instructorId}&kind=private_lesson`).then((list) => {
|
||||
offeringCache.set(instructorId, list);
|
||||
return list;
|
||||
});
|
||||
}
|
||||
|
||||
// "Piano Lesson (60 min — $50.00 CAD)" / "Trial Lesson (Free)"
|
||||
function offeringLabel(o) {
|
||||
const duration = o.duration_minutes ? `${o.duration_minutes} min — ` : '';
|
||||
const price = Number(o.price) > 0
|
||||
? `$${Number(o.price).toFixed(2)} ${o.currency}`
|
||||
: 'Free';
|
||||
return `${o.title} (${duration}${price})`;
|
||||
}
|
||||
|
||||
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([]),
|
||||
instructorOfferings(Number(slot.instructor_id)),
|
||||
apiFetch('policies?scope=booking'),
|
||||
])
|
||||
.then(([questions, policies]) => {
|
||||
renderRegistration(slot, offeringId, questions, policies);
|
||||
.then(([offerings, policies]) => {
|
||||
renderRegistration(slot, offerings, policies);
|
||||
})
|
||||
.catch((err) => showError(err.message));
|
||||
}
|
||||
|
||||
function renderRegistration(slot, offeringId, questions, policies) {
|
||||
function offeringFieldHtml(tied, tiedId, choices) {
|
||||
if (tiedId) {
|
||||
// The slot is tied to one offering: show it locked so the student
|
||||
// sees exactly what they are booking.
|
||||
const label = tied ? offeringLabel(tied) : `Offering #${tiedId}`;
|
||||
return `
|
||||
<p class="us-offering">
|
||||
<label>Lesson type<br>
|
||||
<select id="us-offering" disabled><option>${escHtml(label)}</option></select></label>
|
||||
</p>`;
|
||||
}
|
||||
return `
|
||||
<p class="us-offering">
|
||||
<label>Lesson type<br>
|
||||
<select id="us-offering" required>
|
||||
<option value="">— Choose a lesson type —</option>
|
||||
${choices.map((o) => `<option value="${o.id}">${escHtml(offeringLabel(o))}</option>`).join('')}
|
||||
</select></label>
|
||||
</p>`;
|
||||
}
|
||||
|
||||
function renderRegistration(slot, offerings, policies) {
|
||||
const tiedId = Number(slot.offering_id) || 0;
|
||||
const tied = tiedId ? offerings.find((o) => Number(o.id) === tiedId) : null;
|
||||
|
||||
// Generic slots offer every lesson type that fits the slot's length.
|
||||
const choices = tiedId
|
||||
? []
|
||||
: offerings.filter((o) => !o.duration_minutes || Number(o.duration_minutes) === Number(slot.duration_minutes));
|
||||
|
||||
if (!tiedId && !choices.length) {
|
||||
// The server rejects offering-less bookings, so without a matching
|
||||
// lesson type this time cannot be booked online.
|
||||
slotList.innerHTML = `
|
||||
<div class="us-register">
|
||||
<p>This time cannot be booked online right now. Please contact the instructor.</p>
|
||||
<p><button type="button" id="us-cancel" class="us-cancel-btn">Back</button></p>
|
||||
</div>`;
|
||||
document.getElementById('us-cancel').addEventListener('click', loadSlots);
|
||||
return;
|
||||
}
|
||||
|
||||
const weekly = slot.recurrence_group
|
||||
? `<p><label><input type="checkbox" id="us-weekly"> Reserve this time weekly for the term</label></p>`
|
||||
: '';
|
||||
@@ -235,7 +296,8 @@
|
||||
<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('')}
|
||||
${offeringFieldHtml(tied, tiedId, choices)}
|
||||
<div id="us-questions"></div>
|
||||
${policies.map(policyField).join('')}
|
||||
${weekly}
|
||||
<p>
|
||||
@@ -245,10 +307,42 @@
|
||||
</form>
|
||||
</div>`;
|
||||
|
||||
// The intake questions belong to the selected offering, so they follow
|
||||
// the picker instead of being fixed at render time.
|
||||
let selectedId = tiedId;
|
||||
let questions = [];
|
||||
|
||||
const questionsBox = document.getElementById('us-questions');
|
||||
|
||||
function loadQuestions() {
|
||||
questions = [];
|
||||
questionsBox.innerHTML = '';
|
||||
if (!selectedId) return;
|
||||
apiFetch(`offerings/${selectedId}/questions`)
|
||||
.then((qs) => {
|
||||
questions = qs;
|
||||
questionsBox.innerHTML = qs.map(questionField).join('');
|
||||
})
|
||||
.catch((err) => showError(err.message));
|
||||
}
|
||||
|
||||
if (!tiedId) {
|
||||
document.getElementById('us-offering').addEventListener('change', (e) => {
|
||||
selectedId = Number(e.target.value) || 0;
|
||||
loadQuestions();
|
||||
});
|
||||
}
|
||||
|
||||
loadQuestions();
|
||||
|
||||
document.getElementById('us-cancel').addEventListener('click', loadSlots);
|
||||
document.getElementById('us-register-form').addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
submitBooking(e.target, slot, offeringId, questions);
|
||||
if (!selectedId) {
|
||||
showError('Please choose a lesson type.');
|
||||
return;
|
||||
}
|
||||
submitBooking(e.target, slot, selectedId, questions);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user