Files
unsupervised-scheduler/assets/js/group-classes.js
T
thatguygriffandClaude Opus 5 7181a80537 Return to a bookable calendar after a booking is confirmed
showConfirmation() hid the slot list and put the confirmation in its place,
which is a dead end: a student wanting a second lesson had nothing to click
and no way back short of reloading the page. Enrolling in a group class did
the same thing.

The confirmation is now a dismissible notice above the calendar. The
calendar is reloaded first — so the slot just taken is already gone and the
upcoming-lessons panel is current — and the notice is shown over it, which
is why loadSlots() had to start returning its promise. "It worked" and "book
another" are the same screen.

The notice clears when dismissed, when another slot's form is opened, and on
any reload of the calendar. group-classes.js gets the identical treatment.

It is built from DOM nodes rather than innerHTML because the message can
carry a studio's e-transfer address, and it is toggled with the `hidden`
attribute rather than an inline display — an inline style would outrank the
stylesheet's display:flex and stack the notice's parts. `hidden` needs the
!important guard for the same reason the upcoming-lessons panel does: the
div{display:block} theme reset outranks the UA sheet.

The slotList/list `display = 'block'` lines went with it. Nothing hides
those any more, so restoring them each load only implied otherwise.

Verified in a headless browser against a stubbed REST API: booking twice in
a row without a reload, the booked slot leaving the calendar, the upcoming
panel updating, dismissal, the notice clearing when the next form opens, and
the notice staying hidden under div{display:block}.

Closes #143

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-29 22:55:55 -03:00

328 lines
13 KiB
JavaScript
Raw 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-group-app');
if (!app) return;
const list = document.getElementById('us-group-list');
const confirm = document.getElementById('us-group-confirmation');
const errorBox = document.getElementById('us-group-error');
const { restUrl, nonce } = usScheduler;
// When the shortcode/block pins a single offering, only that class is
// shown, so the page can be embedded alongside a full class description.
// The class's own description is then omitted from the card — the page it
// sits on already describes the class — leaving the schedule, price and
// enrolment controls.
const singleOfferingId = Number(app.dataset.offering || 0);
// Who this account may enrol — children first, the account holder last, so a
// guardian's default selection is a child. One entry means 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;');
}
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>`;
}
// Parse a Y-m-d date into local time; new Date('Y-m-d') would parse as
// UTC midnight and can display as the previous day in western timezones.
function formatDate(ymd) {
const [y, m, d] = ymd.split('-').map(Number);
return new Date(y, m - 1, d).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
}
function termLabel(o) {
if (!o.term_start) return '';
if (!o.term_end || o.term_end === o.term_start) {
return formatDate(o.term_start);
}
const weekMs = 7 * 24 * 60 * 60 * 1000;
const sessions = Math.round((new Date(o.term_end) - new Date(o.term_start)) / weekMs) + 1;
return `${formatDate(o.term_start)} ${formatDate(o.term_end)} (${sessions} weekly sessions)`;
}
// Format a stored H:i(:s) class time as a friendly local-clock label.
function timeLabel(o) {
if (!o.class_time) return '';
const [h, m] = o.class_time.split(':').map(Number);
const d = new Date();
d.setHours(h, m, 0, 0);
return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
}
// The "when" line combines the date (or date range) with the class time.
function whenLabel(o) {
return [termLabel(o), timeLabel(o)].filter(Boolean).join(' · ');
}
// Today as a Y-m-d string in the visitor's local timezone, for lexicographic
// comparison against the class's Y-m-d enrolment deadline.
function todayYmd() {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
}
// The effective enrolment deadline: the instructor's set deadline, or the
// first class day by default. Empty when the class has no dates at all.
function enrolmentDeadline(o) {
return o.enrollment_deadline || o.term_start || '';
}
// Enrolment closes at the end of the deadline day. Mirrors the server-side
// Offering::isEnrollmentOpen() gate.
function isEnrollmentOpen(o) {
const deadline = enrolmentDeadline(o);
return !deadline || todayYmd() <= deadline;
}
// Self-withdrawal closes at the end of the withdrawal-deadline day. Unlike
// enrolment there is no implicit default: an unset deadline keeps withdrawal
// open. Mirrors the server-side Offering::isWithdrawalOpen() gate.
function isWithdrawalOpen(o) {
return !o.withdrawal_deadline || todayYmd() <= o.withdrawal_deadline;
}
function renderClasses(offerings, enrolledMap) {
let groups = offerings.filter((o) => o.kind === 'group_class');
if (singleOfferingId) {
groups = groups.filter((o) => Number(o.id) === singleOfferingId);
}
if (!groups.length) {
list.innerHTML = singleOfferingId
? '<p>This class is not open for enrolment right now.</p>'
: '<p>No group classes are open for enrolment right now.</p>';
return;
}
list.innerHTML = groups.map((o) => `
<div class="us-class">
<h3>${escHtml(o.title)}</h3>
${whenLabel(o) ? `<p class="us-class-when">${escHtml(whenLabel(o))}</p>` : ''}
${o.instructor_name ? `<p class="us-class-instructor">With ${escHtml(o.instructor_name)}</p>` : ''}
${o.schedule_note ? `<p>${escHtml(o.schedule_note)}</p>` : ''}
${!singleOfferingId && o.description ? `<p>${escHtml(o.description)}</p>` : ''}
<p class="us-class-price">${escHtml(window.usPricing.priceLabel(o))}</p>
${!enrolledMap.has(Number(o.id)) && isEnrollmentOpen(o) && enrolmentDeadline(o)
? `<p class="us-enrol-deadline">Enrol by ${escHtml(formatDate(enrolmentDeadline(o)))}</p>`
: ''}
${enrolledMap.has(Number(o.id))
? `<p class="us-enrolled"><strong>You are enrolled in this class.</strong></p>
${isWithdrawalOpen(o)
? `<button data-enrollment-id="${enrolledMap.get(Number(o.id))}" class="us-withdraw-btn">Withdraw</button>`
: '<p class="us-withdraw-closed">Withdrawal has closed — contact the studio to withdraw.</p>'}`
: (isEnrollmentOpen(o)
? `<button data-offering-id="${o.id}" class="us-enrol-btn">Enrol</button>`
: '<p class="us-enrol-closed"><strong>Enrolment has closed.</strong></p>')}
</div>
`).join('');
list.querySelectorAll('.us-enrol-btn').forEach((btn) => {
const offering = groups.find((o) => String(o.id) === btn.dataset.offeringId);
btn.addEventListener('click', () => {
hideConfirmation();
openEnrolment(offering);
});
});
list.querySelectorAll('.us-withdraw-btn').forEach((btn) => {
btn.addEventListener('click', () => withdraw(btn.dataset.enrollmentId));
});
}
function withdraw(enrollmentId) {
clearError();
if (!window.confirm('Withdraw from this class? Your seat is released and any pending payment is cancelled.')) {
return;
}
apiFetch(`enrollments/${enrollmentId}/withdraw`, { method: 'POST' })
.then(loadClasses)
.catch((err) => showError(err.message));
}
function openEnrolment(offering) {
clearError();
Promise.all([
apiFetch(`offerings/${offering.id}/questions`),
apiFetch('policies?scope=booking'),
])
.then(([questions, policies]) => renderEnrolment(offering, questions, policies))
.catch((err) => showError(err.message));
}
function renderEnrolment(offering, questions, policies) {
list.innerHTML = `
<div class="us-register">
<h3>${escHtml(offering.title)}</h3>
<form id="us-enrol-form">
${window.usGuardian.selectorHtml(students, 'us-enrol-student')}
${questions.map(questionField).join('')}
${policies.map(policyField).join('')}
${window.usPricing.summaryHtml(offering)}
<p>
<button type="submit" class="us-enrol-btn">Confirm Enrolment</button>
<button type="button" id="us-group-cancel" class="us-cancel-btn">Back</button>
</p>
</form>
</div>`;
document.getElementById('us-group-cancel').addEventListener('click', loadClasses);
document.getElementById('us-enrol-form').addEventListener('submit', (e) => {
e.preventDefault();
if (!window.usPricing.agreed(e.target)) {
showError(window.usPricing.AGREE_REQUIRED);
return;
}
submitEnrolment(e.target, offering, questions);
});
}
function submitEnrolment(form, offering, 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));
apiFetch('enrollments', {
method: 'POST',
body: JSON.stringify({
offering_id: offering.id,
student_id: window.usGuardian.selectedId('us-enrol-student'),
answers,
accepted_policy_version_ids: accepted,
}),
})
// An enrolment with nothing owed has no payment, so there is no
// payment step to run.
.then((res) => (res.payment
? window.usPayment.collect('enrollment', res.id, list)
: null))
.then((result) => {
const message = window.usPayment.message(result);
// Order matters: loadClasses() clears any standing notice, and
// it is what puts the list back showing the new enrolment.
return loadClasses().then(() => showConfirmation(message));
})
.catch((err) => showError(err.message));
}
/**
* Report a completed enrolment without taking the class list away. Hiding
* the list left the student on a dead-end screen with no way back to
* browsing short of a reload; the notice now sits above a freshly loaded
* list instead. Mirrors booking.js.
*
* Built from nodes rather than innerHTML because the message can carry a
* studio's e-transfer address.
*/
function showConfirmation(message) {
confirm.textContent = '';
const text = document.createElement('p');
text.textContent = message;
const dismiss = document.createElement('button');
dismiss.type = 'button';
dismiss.className = 'us-notice-dismiss';
dismiss.textContent = 'Dismiss';
dismiss.addEventListener('click', hideConfirmation);
confirm.appendChild(text);
confirm.appendChild(dismiss);
// The `hidden` attribute rather than an inline display, which would
// outrank the stylesheet's `display: flex` and stack the notice's
// parts instead of laying them out in a row.
confirm.hidden = false;
}
function hideConfirmation() {
confirm.hidden = true;
confirm.textContent = '';
}
/** Returns the load, so a caller can act once the list is back. */
function loadClasses() {
clearError();
hideConfirmation();
// The student's own enrolments are fetched alongside the catalog so a
// class they already have an active enrolment in shows its status
// instead of offering to enrol them again (the API would reject the
// duplicate anyway). A cancelled enrolment does not block re-enrolling.
return Promise.all([
apiFetch('offerings?kind=group_class'),
apiFetch('enrollments'),
])
.then(([offerings, enrollments]) => renderClasses(
offerings,
new Map(enrollments
.filter((e) => e.status === 'active')
.map((e) => [Number(e.offering_id), e.id]))
))
.catch((err) => showError(err.message));
}
loadClasses();
}());