Files
unsupervised-scheduler/assets/js/booking.js
T
thatguygriffandClaude Opus 5 b772e1811e Let parents register once and book for their children
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]>
2026-07-29 16:07:52 -03:00

718 lines
29 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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">&lsaquo; Previous week</button>
<strong class="us-week-label">Week of ${escHtml(shortDayLabel(weekStart))}</strong>
<button type="button" id="us-week-next">Next week &rsaquo;</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();
}());