Files
unsupervised-scheduler/assets/js/group-classes.js
T
thatguygriffandClaude Opus 5 8017dbb9ff
CI / No Debug Code (pull_request) Successful in 26s
CI / Tests (PHP 8.2) (pull_request) Successful in 48s
CI / Tests (PHP 8.1) (pull_request) Successful in 1m5s
CI / PHPStan (pull_request) Successful in 2m55s
CI / Coding Standards (pull_request) Successful in 3m49s
CI / Tests (PHP 8.3) (pull_request) Successful in 6m9s
CI / Build Plugin Zip (pull_request) Skipped
Let a guardian enrol every child in the same group class
The group-class page matched enrolments to the account instead of to the
student: the lookup it built from GET /enrollments was keyed by offering id
alone, so the first household enrolment marked the class as "yours" and took
the Enrol button away from everyone else on the account. A parent could enrol
one child and was then offered nothing but Withdraw.

The server was never the constraint — hasActiveEnrollment() checks the
(offering, student) pair and GET /enrollments deliberately returns the whole
household — so the fix is to stop discarding student_id on the way in. Active
enrolments are now grouped per class as a list, each student gets their own
"… is enrolled in this class." line and their own named Withdraw button, and
the Enrol button stays (as "Enrol another student") while anyone the account
may enrol is still out.

The enrolment form offers only the students not yet enrolled. When exactly one
of them is left the picker collapses, and that case needed care: an omitted
student_id reads as "enrol the account holder" server-side, so a hidden field
carries the id rather than posting nothing and signing up the parent instead of
the last child.

Closes #170

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Jy9UPhmpLUAfimsyecHN2Z
2026-08-11 17:36:01 -03:00

404 lines
17 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-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;
}
// Active enrolments grouped by class. A household can hold several in the
// same class — one per student — so the value is a list, never a single id.
function activeByOffering(enrollments) {
const map = new Map();
enrollments
.filter((e) => e.status === 'active')
.forEach((e) => {
const key = Number(e.offering_id);
const held = map.get(key) || [];
held.push({ id: e.id, studentId: Number(e.student_id) });
map.set(key, held);
});
return map;
}
// Who on this account could still be enrolled in a class: everyone the
// account may enrol, minus those already holding an active enrolment in it.
// The per-student check is the point — the account used to be treated as a
// single enrollee, so enrolling one child hid the Enrol button from the rest
// of the household even though the server would have taken them happily.
function availableStudents(offeringId, enrolled) {
const held = enrolled.get(Number(offeringId)) || [];
// Degraded case: an unparseable student list leaves no id to compare
// against, so any existing enrolment is read as covering the account.
if (!students.length) return held.length ? [] : [{ id: 0, name: '', is_self: true }];
const taken = new Set(held.map((e) => e.studentId));
return students.filter((s) => !taken.has(Number(s.id)));
}
// The enrolled student's name, or '' when there is nobody to tell them apart
// from: an account with a single student reads better in the second person.
function studentName(studentId) {
if (students.length < 2) return '';
const s = students.find((st) => Number(st.id) === Number(studentId));
return s && !s.is_self ? s.name : '';
}
function enrolledRow(o, e) {
const name = studentName(e.studentId);
return `
<p class="us-enrolled"><strong>${name ? `${escHtml(name)} is` : 'You are'} enrolled in this class.</strong></p>
${isWithdrawalOpen(o)
? `<button data-enrollment-id="${e.id}" data-student="${escHtml(name)}" class="us-withdraw-btn">Withdraw${name ? ` ${escHtml(name)}` : ''}</button>`
: `<p class="us-withdraw-closed">Withdrawal${name ? ` for ${escHtml(name)}` : ''} has closed — contact the studio to withdraw.</p>`}`;
}
function classCard(o, enrolled) {
const held = enrolled.get(Number(o.id)) || [];
const available = availableStudents(o.id, enrolled);
const canEnrol = available.length > 0 && isEnrollmentOpen(o);
return `
<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>
${canEnrol && enrolmentDeadline(o)
? `<p class="us-enrol-deadline">Enrol by ${escHtml(formatDate(enrolmentDeadline(o)))}</p>`
: ''}
${held.map((e) => enrolledRow(o, e)).join('')}
${canEnrol
? `<button data-offering-id="${o.id}" class="us-enrol-btn">${held.length ? 'Enrol another student' : 'Enrol'}</button>`
: (available.length ? '<p class="us-enrol-closed"><strong>Enrolment has closed.</strong></p>' : '')}
</div>`;
}
function renderClasses(offerings, enrolled) {
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) => classCard(o, enrolled)).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, availableStudents(offering.id, enrolled));
});
});
list.querySelectorAll('.us-withdraw-btn').forEach((btn) => {
btn.addEventListener('click', () => withdraw(btn.dataset.enrollmentId, btn.dataset.student || ''));
});
}
function withdraw(enrollmentId, studentName) {
clearError();
// Named, because a household can hold more than one enrolment in the
// same class and "this class" alone would not say whose seat is going.
const prompt = studentName
? `Withdraw ${studentName} from this class? Their seat is released and any pending payment is cancelled.`
: 'Withdraw from this class? Your seat is released and any pending payment is cancelled.';
if (!window.confirm(prompt)) {
return;
}
apiFetch(`enrollments/${enrollmentId}/withdraw`, { method: 'POST' })
.then(loadClasses)
.catch((err) => showError(err.message));
}
function openEnrolment(offering, available) {
clearError();
Promise.all([
apiFetch(`offerings/${offering.id}/questions`),
apiFetch('policies?scope=booking'),
])
.then(([questions, policies]) => renderEnrolment(offering, questions, policies, available))
.catch((err) => showError(err.message));
}
/**
* The "who is this for?" control for one class, offering only the students
* who are not already enrolled in it.
*
* When exactly one is left there is nothing to choose, but the id still has
* to reach the server: an omitted picker posts no student_id, which the
* server reads as "enrol the account holder" — and would enrol the parent
* instead of the one child still to be signed up.
*/
function studentFieldHtml(available) {
if (available.length > 1) {
return window.usGuardian.selectorHtml(available, 'us-enrol-student');
}
const only = available[0];
if (!only) return '';
return `<input type="hidden" id="us-enrol-student" value="${Number(only.id)}">
${students.length > 1
? `<p class="us-student-picker">For ${only.is_self ? 'yourself' : escHtml(only.name)}.</p>`
: ''}`;
}
function renderEnrolment(offering, questions, policies, available) {
list.innerHTML = `
<div class="us-register">
<h3>${escHtml(offering.title)}</h3>
<form id="us-enrol-form">
${studentFieldHtml(available)}
${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 household's enrolments are fetched alongside the catalog so a
// class a student already has an active enrolment in shows their status
// instead of offering to enrol them again (the API would reject the
// duplicate anyway). Each student is tracked separately: one child being
// enrolled says nothing about their siblings, who can still be signed up
// for the same class. A cancelled enrolment does not block re-enrolling.
return Promise.all([
apiFetch('offerings?kind=group_class'),
apiFetch('enrollments'),
])
.then(([offerings, enrollments]) => renderClasses(offerings, activeByOffering(enrollments)))
.catch((err) => showError(err.message));
}
loadClasses();
}());