/* 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, '&')
.replace(//g, '>')
.replace(/"/g, '"');
}
function questionField(q) {
const name = `q_${q.id}`;
const required = q.is_required ? 'required' : '';
let input;
if (q.field_type === 'textarea') {
input = ``;
} else if (q.field_type === 'select') {
const opts = (q.options || []).map((o) => ``).join('');
input = ``;
} else if (q.field_type === 'checkbox') {
input = ``;
} else {
input = ``;
}
return `
`;
}
function policyField(p) {
return `
${escHtml(p.title)}
${p.body || ''}
`;
}
// 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 `
${name ? `${escHtml(name)} is` : 'You are'} enrolled in this class.
${isWithdrawalOpen(o)
? ``
: `
Withdrawal${name ? ` for ${escHtml(name)}` : ''} has closed — contact the studio to withdraw.
`}`;
}
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 `
${escHtml(o.title)}
${whenLabel(o) ? `
${escHtml(whenLabel(o))}
` : ''}
${o.instructor_name ? `
With ${escHtml(o.instructor_name)}
` : ''}
${o.schedule_note ? `
${escHtml(o.schedule_note)}
` : ''}
${!singleOfferingId && o.description ? `
${escHtml(o.description)}
` : ''}
${escHtml(window.usPricing.priceLabel(o))}
${canEnrol && enrolmentDeadline(o)
? `
Enrol by ${escHtml(formatDate(enrolmentDeadline(o)))}
`;
}
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
? '
This class is not open for enrolment right now.
'
: '
No group classes are open for enrolment right now.
';
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 `
${students.length > 1
? `
For ${only.is_self ? 'yourself' : escHtml(only.name)}.
`;
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();
}());