A parent registers once and manages lessons for one or more children, who need no login of their own. A child is a real wp_users row with the student role but no usable login — so student_id keeps meaning "a WordPress user" on every table, and booking, credits, policies and enrolments work unchanged. A us_guardians link table maps guardian to child. The signup form gains a parent/guardian tick that reveals a block per child, with the account-signup questions asked per child rather than per guardian — they describe the student, not the account holder. Signup policies are recorded once per child with the guardian as the acceptor, which is the record that actually means something. A family that half-creates is rolled back entirely rather than leaving a guardian who cannot re-register. The booking and enrolment forms gain a "Who is this for?" picker listing children first, so the default selection is never the parent — booking for the wrong child is correctable, quietly billing a parent for their kid's lesson is not. POST /bookings and POST /enrollments take an optional student_id honoured only for that child's guardian; anything else is a 403. That check is the authorisation boundary of the feature. Payments and credits gain a payer: the charge names the child it was for and the guardian who owes it, so per-child reporting is unchanged while notices, receipts and the payment step reach the parent. Credit is held by the payer, so one child's cancellation can settle a sibling's charge, and the daily billing scan sends a guardian one notice covering every child. Closes #132 Co-Authored-By: Claude Opus 5 <[email protected]>
718 lines
29 KiB
JavaScript
718 lines
29 KiB
JavaScript
/* global usScheduler */
|
||
(function () {
|
||
'use strict';
|
||
|
||
const app = document.getElementById('us-booking-app');
|
||
if (!app) return;
|
||
|
||
const slotList = document.getElementById('us-slot-list');
|
||
const myLessons = document.getElementById('us-my-lessons');
|
||
const confirm = document.getElementById('us-booking-confirmation');
|
||
const errorBox = document.getElementById('us-booking-error');
|
||
const { restUrl, nonce } = usScheduler;
|
||
|
||
// Per-instance options from the block/shortcode: pin the page to a single
|
||
// lesson type, and whether the "Show Only" filter is offered at all.
|
||
const pinnedTypeId = Number(app.dataset.lessonType) || 0;
|
||
const filterEnabled = app.dataset.typeFilter !== '0';
|
||
|
||
// Who this account may book for — children first, the account holder last,
|
||
// so a guardian's default selection is a child rather than themselves. A
|
||
// single-student account has one entry and gets no picker.
|
||
const students = window.usGuardian.parseStudents(app.dataset.students);
|
||
|
||
function apiFetch(path, options = {}) {
|
||
return fetch(restUrl + path, {
|
||
...options,
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-WP-Nonce': nonce,
|
||
...(options.headers || {}),
|
||
},
|
||
}).then(async (res) => {
|
||
const data = await res.json();
|
||
if (!res.ok) throw new Error(data.message || 'Request failed');
|
||
return data;
|
||
});
|
||
}
|
||
|
||
function showError(message) {
|
||
errorBox.textContent = message;
|
||
errorBox.style.display = 'block';
|
||
}
|
||
|
||
function clearError() {
|
||
errorBox.style.display = 'none';
|
||
}
|
||
|
||
function escHtml(str) {
|
||
return String(str)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|
||
const dayKey = (dt) => String(dt).slice(0, 10);
|
||
|
||
// "2026-07-06 14:30:00" → "2:30 PM"
|
||
function timeOf(dt) {
|
||
const hours = Number(String(dt).slice(11, 13));
|
||
const minutes = String(dt).slice(14, 16);
|
||
return `${hours % 12 || 12}:${minutes} ${hours < 12 ? 'AM' : 'PM'}`;
|
||
}
|
||
|
||
function dayLabel(key) {
|
||
const date = new Date(key + 'T00:00:00');
|
||
if (Number.isNaN(date.getTime())) return key;
|
||
return date.toLocaleDateString(undefined, {
|
||
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
|
||
});
|
||
}
|
||
|
||
function shortDayLabel(key) {
|
||
const date = new Date(key + 'T00:00:00');
|
||
if (Number.isNaN(date.getTime())) return key;
|
||
return date.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
|
||
}
|
||
|
||
function groupByDay(slots) {
|
||
const groups = new Map();
|
||
slots.forEach((slot) => {
|
||
const key = dayKey(slot.start_dt);
|
||
if (!groups.has(key)) groups.set(key, []);
|
||
groups.get(key).push(slot);
|
||
});
|
||
return [...groups.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
||
}
|
||
|
||
// --- calendar view state (week is the default; week keeps its position) ---
|
||
let allSlots = [];
|
||
let view = 'week';
|
||
let weekStart = null;
|
||
|
||
// Every active private-lesson type the student may book, across instructors.
|
||
let catalog = [];
|
||
|
||
// Lesson types the student has filtered the calendar down to; empty means
|
||
// "no filter" — every open slot is shown. The list starts collapsed behind
|
||
// the "Show Only" button and stays open across re-renders once revealed.
|
||
const selectedTypeIds = new Set();
|
||
let filterOpen = false;
|
||
|
||
// Whether an offering can be booked into a slot — the client-side mirror of
|
||
// the rule `POST /bookings` enforces: a slot tied to an offering takes that
|
||
// offering only, and a generic slot takes any of its instructor's types
|
||
// whose length fits.
|
||
function offeringFitsSlot(offering, slot) {
|
||
if (Number(offering.instructor_id) !== Number(slot.instructor_id)) return false;
|
||
|
||
const tiedId = Number(slot.offering_id) || 0;
|
||
if (tiedId) return Number(offering.id) === tiedId;
|
||
|
||
return !offering.duration_minutes
|
||
|| Number(offering.duration_minutes) === Number(slot.duration_minutes);
|
||
}
|
||
|
||
const filterActive = () => selectedTypeIds.size > 0;
|
||
|
||
const typeSelected = (offering) => !filterActive() || selectedTypeIds.has(Number(offering.id));
|
||
|
||
// The lesson types this slot could be booked as, honouring the filter.
|
||
function slotChoices(slot) {
|
||
return catalog.filter((o) => offeringFitsSlot(o, slot) && typeSelected(o));
|
||
}
|
||
|
||
// With a filter set, a slot is only shown when one of the chosen lesson
|
||
// types can actually be booked into it.
|
||
function visibleSlots() {
|
||
if (!filterActive()) return allSlots;
|
||
return allSlots.filter((slot) => slotChoices(slot).length > 0);
|
||
}
|
||
|
||
const pad = (n) => String(n).padStart(2, '0');
|
||
const toKey = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||
|
||
function addDays(key, days) {
|
||
const date = new Date(key + 'T00:00:00');
|
||
date.setDate(date.getDate() + days);
|
||
return toKey(date);
|
||
}
|
||
|
||
// First day of the week containing `key`, honouring the site's
|
||
// start-of-week setting (0 = Sunday … 6 = Saturday).
|
||
function weekStartOf(key) {
|
||
const startOfWeek = Number(usScheduler.startOfWeek) || 0;
|
||
const date = new Date(key + 'T00:00:00');
|
||
return addDays(key, -((date.getDay() - startOfWeek + 7) % 7));
|
||
}
|
||
|
||
// The calendar's control row: the view toggle, and the button that reveals
|
||
// the lesson-type filter beneath it.
|
||
function controlsHtml() {
|
||
return `
|
||
<div class="us-calendar-controls">
|
||
<div class="us-view-toggle" role="group" aria-label="Calendar view">
|
||
<button type="button" id="us-view-list" class="${view === 'list' ? 'us-active' : ''}">List</button>
|
||
<button type="button" id="us-view-week" class="${view === 'week' ? 'us-active' : ''}">Week</button>
|
||
</div>
|
||
${filterToggleHtml()}
|
||
</div>`;
|
||
}
|
||
|
||
// Nothing to filter with a single bookable type, so the control only
|
||
// appears once there is a choice to make.
|
||
function filterToggleHtml() {
|
||
if (!filterEnabled || catalog.length < 2) return '';
|
||
|
||
const count = filterActive() ? ` (${selectedTypeIds.size})` : '';
|
||
|
||
return `
|
||
<button type="button" id="us-filter-toggle" class="us-filter-toggle${filterActive() ? ' us-active' : ''}"
|
||
aria-expanded="${filterOpen}" aria-controls="us-type-filter">Show Only${count}</button>`;
|
||
}
|
||
|
||
// "Piano Lesson (30 min)" — the instructor's name is only worth the space
|
||
// when the catalog spans more than one of them.
|
||
function filterLabel(offering) {
|
||
const duration = offering.duration_minutes ? ` (${offering.duration_minutes} min)` : '';
|
||
const instructors = new Set(catalog.map((o) => Number(o.instructor_id)));
|
||
const who = instructors.size > 1 && offering.instructor_name
|
||
? ` — ${offering.instructor_name}`
|
||
: '';
|
||
return `${offering.title}${duration}${who}`;
|
||
}
|
||
|
||
// The lesson-type list itself — collapsed until the student opens it, and
|
||
// rendered between the control row and the calendar.
|
||
function filterHtml() {
|
||
if (!filterEnabled || catalog.length < 2 || !filterOpen) return '';
|
||
|
||
const choices = catalog.map((o) => `
|
||
<label class="us-type-filter-choice">
|
||
<input type="checkbox" class="us-type-filter-option" value="${o.id}" ${selectedTypeIds.has(Number(o.id)) ? 'checked' : ''}>
|
||
${escHtml(filterLabel(o))}
|
||
</label>
|
||
`).join('');
|
||
|
||
return `
|
||
<div class="us-type-filter" id="us-type-filter" role="group" aria-label="Filter by lesson type">
|
||
<span class="us-type-filter-heading">Lesson type</span>
|
||
<div class="us-type-filter-choices">
|
||
${choices}
|
||
${filterActive() ? '<button type="button" id="us-type-filter-clear" class="us-type-filter-clear">Show all types</button>' : ''}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
// Agenda-style calendar: available slots grouped by day.
|
||
function listHtml(slots) {
|
||
return groupByDay(slots).map(([key, daySlots]) => `
|
||
<div class="us-day">
|
||
<h3 class="us-day-heading">${escHtml(dayLabel(key))}</h3>
|
||
${daySlots.map((slot) => `
|
||
<div class="us-slot">
|
||
<span>${escHtml(timeOf(slot.start_dt))}–${escHtml(timeOf(slot.end_dt))} (${escHtml(String(slot.duration_minutes))} min)</span>
|
||
<button data-slot-id="${slot.id}" class="us-book-btn">Book</button>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
// Weekly calendar: seven day columns with a bookable button per slot.
|
||
function weekHtml(slots) {
|
||
const byDay = new Map(groupByDay(slots));
|
||
const days = [...Array(7).keys()].map((i) => addDays(weekStart, i));
|
||
|
||
const columns = days.map((key) => {
|
||
const daySlots = byDay.get(key) || [];
|
||
const buttons = daySlots.map((slot) => `
|
||
<button data-slot-id="${slot.id}" class="us-book-btn us-week-slot" title="${escHtml(String(slot.duration_minutes))} min">
|
||
${escHtml(timeOf(slot.start_dt))}
|
||
</button>
|
||
`).join('');
|
||
|
||
return `
|
||
<div class="us-week-day">
|
||
<h4 class="us-week-day-heading">${escHtml(shortDayLabel(key))}</h4>
|
||
${buttons || '<span class="us-week-empty" aria-hidden="true">—</span>'}
|
||
</div>`;
|
||
}).join('');
|
||
|
||
return `
|
||
<div class="us-week-nav">
|
||
<button type="button" id="us-week-prev">‹ Previous week</button>
|
||
<strong class="us-week-label">Week of ${escHtml(shortDayLabel(weekStart))}</strong>
|
||
<button type="button" id="us-week-next">Next week ›</button>
|
||
</div>
|
||
<div class="us-week-grid">${columns}</div>`;
|
||
}
|
||
|
||
function render() {
|
||
const slots = visibleSlots();
|
||
|
||
// The pinned lesson type is no longer on offer (deactivated or
|
||
// deleted), so this page has nothing it is allowed to book.
|
||
if (pinnedTypeId && !catalog.length) {
|
||
slotList.innerHTML = '<p>This lesson type is not available for booking right now.</p>';
|
||
return;
|
||
}
|
||
|
||
// Nothing open at all: there is nothing for the controls to act on.
|
||
if (!allSlots.length) {
|
||
slotList.innerHTML = '<p>No available lesson slots at this time.</p>';
|
||
return;
|
||
}
|
||
|
||
if (!slots.length) {
|
||
const message = pinnedTypeId
|
||
? '<p>No open times for this lesson type right now.</p>'
|
||
: '<p>No open times match the selected lesson types.</p>';
|
||
|
||
slotList.innerHTML = controlsHtml() + filterHtml() + message;
|
||
wireControlEvents();
|
||
return;
|
||
}
|
||
|
||
// Anchor the week view to the week of the earliest matching slot (the
|
||
// API returns slots ordered by start), so the first look is never empty.
|
||
if (view === 'week' && !weekStart) weekStart = weekStartOf(dayKey(slots[0].start_dt));
|
||
|
||
slotList.innerHTML = controlsHtml() + filterHtml() + (view === 'week' ? weekHtml(slots) : listHtml(slots));
|
||
wireControlEvents();
|
||
wireCalendarEvents();
|
||
}
|
||
|
||
function wireControlEvents() {
|
||
document.getElementById('us-view-list').addEventListener('click', () => {
|
||
view = 'list';
|
||
render();
|
||
});
|
||
document.getElementById('us-view-week').addEventListener('click', () => {
|
||
view = 'week';
|
||
render();
|
||
});
|
||
|
||
const toggle = document.getElementById('us-filter-toggle');
|
||
if (toggle) {
|
||
toggle.addEventListener('click', () => {
|
||
filterOpen = !filterOpen;
|
||
render();
|
||
});
|
||
}
|
||
|
||
slotList.querySelectorAll('.us-type-filter-option').forEach((input) => {
|
||
input.addEventListener('change', () => {
|
||
const id = Number(input.value);
|
||
if (input.checked) {
|
||
selectedTypeIds.add(id);
|
||
} else {
|
||
selectedTypeIds.delete(id);
|
||
}
|
||
// The nearest matching time may be weeks away, so re-anchor the
|
||
// week view instead of leaving the student on an empty week.
|
||
weekStart = null;
|
||
render();
|
||
});
|
||
});
|
||
|
||
const clear = document.getElementById('us-type-filter-clear');
|
||
if (clear) {
|
||
clear.addEventListener('click', () => {
|
||
selectedTypeIds.clear();
|
||
weekStart = null;
|
||
render();
|
||
});
|
||
}
|
||
}
|
||
|
||
function wireCalendarEvents() {
|
||
const prev = document.getElementById('us-week-prev');
|
||
const next = document.getElementById('us-week-next');
|
||
if (prev) prev.addEventListener('click', () => { weekStart = addDays(weekStart, -7); render(); });
|
||
if (next) next.addEventListener('click', () => { weekStart = addDays(weekStart, 7); render(); });
|
||
|
||
slotList.querySelectorAll('.us-book-btn[data-slot-id]').forEach((btn) => {
|
||
const slot = allSlots.find((s) => String(s.id) === btn.dataset.slotId);
|
||
if (slot) btn.addEventListener('click', () => openRegistration(slot));
|
||
});
|
||
}
|
||
|
||
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 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>`;
|
||
}
|
||
|
||
// "Piano Lesson (60 min — 50.00 CAD at booking)" / "Trial Lesson (Free)"
|
||
function offeringLabel(o) {
|
||
const duration = o.duration_minutes ? `${o.duration_minutes} min — ` : '';
|
||
return `${o.title} (${duration}${window.usPricing.priceLabel(o)})`;
|
||
}
|
||
|
||
// How many lessons a weekly reservation can claim, mirroring
|
||
// BookingEndpoint::MAX_WEEKLY_OCCURRENCES so the quoted total is never
|
||
// higher than the server will actually charge for.
|
||
const MAX_WEEKLY_OCCURRENCES = 12;
|
||
|
||
// The open times a weekly reservation of this slot would claim: every
|
||
// still-unbooked slot of its recurring group, capped the way the server
|
||
// caps it. Some may be taken by another student first, so this is the
|
||
// upper bound on what will be booked, not a guarantee.
|
||
function weeklyOccurrences(slot) {
|
||
if (!slot.recurrence_group) return 1;
|
||
|
||
const inGroup = allSlots.filter((s) => s.recurrence_group === slot.recurrence_group).length;
|
||
|
||
return Math.min(Math.max(inGroup, 1), MAX_WEEKLY_OCCURRENCES);
|
||
}
|
||
|
||
function openRegistration(slot) {
|
||
clearError();
|
||
|
||
apiFetch('policies?scope=booking')
|
||
.then((policies) => renderRegistration(slot, policies))
|
||
.catch((err) => showError(err.message));
|
||
}
|
||
|
||
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>`;
|
||
}
|
||
|
||
// Only one type is left to book this slot as — usually because the
|
||
// filter narrowed it down — so it is chosen for the student.
|
||
if (choices.length === 1) {
|
||
return `
|
||
<p class="us-offering">
|
||
<label>Lesson type<br>
|
||
<select id="us-offering" required>
|
||
<option value="${choices[0].id}" selected>${escHtml(offeringLabel(choices[0]))}</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, policies) {
|
||
const tiedId = Number(slot.offering_id) || 0;
|
||
const tied = tiedId ? catalog.find((o) => Number(o.id) === tiedId) : null;
|
||
|
||
// Generic slots offer every lesson type that fits the slot — narrowed to
|
||
// the filtered types when the student has set a filter.
|
||
const choices = tiedId ? [] : slotChoices(slot);
|
||
|
||
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>`
|
||
: '';
|
||
|
||
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">
|
||
${window.usGuardian.selectorHtml(students, 'us-booking-student')}
|
||
${offeringFieldHtml(tied, tiedId, choices)}
|
||
<div id="us-questions"></div>
|
||
${policies.map(policyField).join('')}
|
||
${weekly}
|
||
<div id="us-price-summary"></div>
|
||
<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>`;
|
||
|
||
// The intake questions belong to the selected offering, so they follow
|
||
// the picker instead of being fixed at render time. A tied slot — or a
|
||
// lone remaining type — is already decided, so its questions load
|
||
// straight away.
|
||
let selectedId = tiedId || (choices.length === 1 ? Number(choices[0].id) : 0);
|
||
let questions = [];
|
||
|
||
const questionsBox = document.getElementById('us-questions');
|
||
const priceBox = document.getElementById('us-price-summary');
|
||
const weeklyEl = document.getElementById('us-weekly');
|
||
|
||
// What the booking will cost and the agreement to pay it, restated
|
||
// whenever the choices that decide the amount change: the lesson type
|
||
// carries the price, and a weekly reservation multiplies a per-lesson
|
||
// one-time price by every week it claims. A slot tied to a type the
|
||
// catalog no longer carries has no price to quote, so it shows nothing
|
||
// rather than a figure it cannot stand behind.
|
||
function renderPrice() {
|
||
const offering = selectedId ? catalog.find((o) => Number(o.id) === selectedId) : null;
|
||
priceBox.innerHTML = offering
|
||
? window.usPricing.summaryHtml({
|
||
price: offering.price,
|
||
currency: offering.currency,
|
||
billing_mode: offering.billing_mode,
|
||
kind: offering.kind,
|
||
occurrences: weeklyEl && weeklyEl.checked ? weeklyOccurrences(slot) : 1,
|
||
})
|
||
: '';
|
||
}
|
||
|
||
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();
|
||
renderPrice();
|
||
});
|
||
}
|
||
|
||
if (weeklyEl) weeklyEl.addEventListener('change', renderPrice);
|
||
|
||
loadQuestions();
|
||
renderPrice();
|
||
|
||
document.getElementById('us-cancel').addEventListener('click', loadSlots);
|
||
document.getElementById('us-register-form').addEventListener('submit', (e) => {
|
||
e.preventDefault();
|
||
if (!selectedId) {
|
||
showError('Please choose a lesson type.');
|
||
return;
|
||
}
|
||
if (!window.usPricing.agreed(e.target)) {
|
||
showError(window.usPricing.AGREE_REQUIRED);
|
||
return;
|
||
}
|
||
submitBooking(e.target, slot, selectedId, 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,
|
||
student_id: window.usGuardian.selectedId('us-booking-student'),
|
||
recurrence: weeklyEl && weeklyEl.checked ? 'weekly' : 'single',
|
||
answers,
|
||
accepted_policy_version_ids: accepted,
|
||
}),
|
||
})
|
||
// A booking with nothing owed has no payment, so there is no payment
|
||
// step to run — the booking is already confirmed server-side.
|
||
.then((res) => (res.payment
|
||
? window.usPayment.collect('lesson', (res.ids || [])[0], slotList)
|
||
: null))
|
||
.then((result) => {
|
||
loadMyLessons();
|
||
showConfirmation(window.usPayment.message(result));
|
||
})
|
||
.catch((err) => showError(err.message));
|
||
}
|
||
|
||
function lessonStatusLabel(status) {
|
||
if (status === 'pending') return 'Pending payment';
|
||
if (status === 'confirmed') return 'Confirmed';
|
||
return status.charAt(0).toUpperCase() + status.slice(1);
|
||
}
|
||
|
||
// How many upcoming lessons to show before the "Show all" reveal.
|
||
const INITIAL_LESSON_COUNT = 5;
|
||
|
||
// Whose lesson this is. Only shown on an account that books for more than
|
||
// one person — on a single-student account the name is on every row and says
|
||
// nothing.
|
||
function lessonWhoHtml(l) {
|
||
if (students.length < 2 || !l.student_name) return '';
|
||
|
||
return ` <span class="us-my-lesson-who">— ${escHtml(String(l.student_name))}</span>`;
|
||
}
|
||
|
||
function lessonRowHtml(l) {
|
||
const title = l.offering_title ? escHtml(String(l.offering_title)) : 'Lesson';
|
||
const duration = l.duration_minutes ? ` <span class="us-my-lesson-duration">(${escHtml(String(l.duration_minutes))} min)</span>` : '';
|
||
// The two columns are divs, not spans: as spans the layout only held up
|
||
// while the stylesheet's display:flex won, and a theme rule on span
|
||
// collapsed the row onto itself.
|
||
return `
|
||
<div class="us-my-lesson">
|
||
<div class="us-my-lesson-info">
|
||
<strong class="us-my-lesson-title">${title}${duration}${lessonWhoHtml(l)}</strong>
|
||
<span class="us-my-lesson-when">${escHtml(dayLabel(dayKey(l.start_dt)))} · ${escHtml(timeOf(l.start_dt))}–${escHtml(timeOf(l.end_dt))}</span>
|
||
</div>
|
||
<div class="us-my-lesson-actions">
|
||
<span class="us-lesson-status us-lesson-status-${escHtml(String(l.status))}">${escHtml(lessonStatusLabel(String(l.status)))}</span>
|
||
<button type="button" class="us-cancel-lesson" data-lesson-id="${l.id}">Cancel</button>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function renderMyLessons(lessons) {
|
||
const upcoming = lessons.filter((l) => l.start_dt);
|
||
if (!upcoming.length) {
|
||
myLessons.innerHTML = '';
|
||
return;
|
||
}
|
||
|
||
// Show only the soonest few by default; the rest sit hidden behind a
|
||
// reveal so a busy student's list stays short.
|
||
const visible = upcoming.slice(0, INITIAL_LESSON_COUNT);
|
||
const hidden = upcoming.slice(INITIAL_LESSON_COUNT);
|
||
|
||
myLessons.innerHTML = `
|
||
<div class="us-my-lessons">
|
||
<h3>Your upcoming lessons</h3>
|
||
${visible.map(lessonRowHtml).join('')}
|
||
${hidden.length ? `
|
||
<div class="us-my-lessons-more" hidden>${hidden.map(lessonRowHtml).join('')}</div>
|
||
<button type="button" class="us-show-all-lessons">Show all ${upcoming.length} lessons</button>
|
||
` : ''}
|
||
</div>`;
|
||
|
||
const moreBox = myLessons.querySelector('.us-my-lessons-more');
|
||
const showAll = myLessons.querySelector('.us-show-all-lessons');
|
||
if (showAll && moreBox) {
|
||
showAll.addEventListener('click', () => {
|
||
moreBox.hidden = false;
|
||
showAll.remove();
|
||
});
|
||
}
|
||
|
||
myLessons.querySelectorAll('.us-cancel-lesson').forEach((btn) => {
|
||
btn.addEventListener('click', () => cancelLesson(Number(btn.dataset.lessonId)));
|
||
});
|
||
}
|
||
|
||
function cancelLesson(id) {
|
||
if (!window.confirm('Cancel this lesson? The time will be released for other students.')) {
|
||
return;
|
||
}
|
||
clearError();
|
||
apiFetch(`bookings/${id}/cancel`, { method: 'POST' })
|
||
.then(loadSlots)
|
||
.catch((err) => showError(err.message));
|
||
}
|
||
|
||
function loadMyLessons() {
|
||
if (!myLessons) return;
|
||
// The lesson list is a bonus panel: never let it break slot browsing.
|
||
apiFetch('bookings')
|
||
.then(renderMyLessons)
|
||
.catch(() => { myLessons.innerHTML = ''; });
|
||
}
|
||
|
||
function showConfirmation(message) {
|
||
confirm.textContent = message;
|
||
slotList.style.display = 'none';
|
||
confirm.style.display = 'block';
|
||
}
|
||
|
||
// The private-lesson catalog drives both the filter and the registration
|
||
// form's lesson-type picker, and it does not change while the student
|
||
// browses — so it is fetched once and kept.
|
||
let catalogLoaded = false;
|
||
|
||
function loadCatalog() {
|
||
if (catalogLoaded) return Promise.resolve(catalog);
|
||
return apiFetch('offerings?kind=private_lesson').then((list) => {
|
||
// A pinned lesson type is the only one this page may book, so the
|
||
// catalog is narrowed to it and the filter is fixed on it. With a
|
||
// single type left the "Show Only" control hides itself.
|
||
catalog = pinnedTypeId
|
||
? list.filter((o) => Number(o.id) === pinnedTypeId)
|
||
: list;
|
||
|
||
if (pinnedTypeId) selectedTypeIds.add(pinnedTypeId);
|
||
|
||
catalogLoaded = true;
|
||
return catalog;
|
||
});
|
||
}
|
||
|
||
function loadSlots() {
|
||
clearError();
|
||
loadMyLessons();
|
||
|
||
// An upcoming-lessons-only embed has no calendar to fill.
|
||
if (!slotList) return;
|
||
|
||
slotList.style.display = 'block';
|
||
confirm.style.display = 'none';
|
||
Promise.all([apiFetch('availability'), loadCatalog()])
|
||
.then(([slots]) => {
|
||
allSlots = slots;
|
||
render();
|
||
})
|
||
.catch((err) => showError(err.message));
|
||
}
|
||
|
||
loadSlots();
|
||
}());
|