CI / Tests (PHP 8.1) (pull_request) Successful in 46s
CI / Tests (PHP 8.2) (pull_request) Successful in 49s
CI / PHPStan (pull_request) Successful in 2m54s
CI / Coding Standards (pull_request) Successful in 2m57s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m43s
CI / Build Plugin Zip (pull_request) Skipped
CI / No Debug Code (pull_request) Successful in 2s
The lesson details and the actions rendered on top of each other. Three things left the panel fragile, all fixed here. The rules were bare class selectors while the visually identical .us-slot row next to them was written as `#us-booking-app .us-slot`. That inconsistency looks accidental, and it means a theme rule on div/span/strong outranks the panel's layout and flattens it. Every booking-page rule is now scoped under #us-booking-app. The row's two columns were spans carrying display:flex, so the layout only held while that declaration won. They are divs now — the layout no longer depends on overriding the inline default. The row had no flex-wrap and its title column no min-width:0, so a long offering title could not shrink and shoved the status pill and Cancel button out of the row. The 640px media query covered only the week grid, leaving the busier lesson rows with no narrow-viewport handling at all; they now stack details above actions. BlockPreview mirrors the markup change so the editor preview matches. Closes #133
702 lines
28 KiB
JavaScript
702 lines
28 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';
|
||
|
||
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">
|
||
${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,
|
||
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;
|
||
|
||
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}</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();
|
||
}());
|