/* 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)))}

` : ''} ${held.map((e) => enrolledRow(o, e)).join('')} ${canEnrol ? `` : (available.length ? '

Enrolment has closed.

' : '')}
`; } 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)}.

` : ''}`; } function renderEnrolment(offering, questions, policies, available) { list.innerHTML = `

${escHtml(offering.title)}

${studentFieldHtml(available)} ${questions.map(questionField).join('')} ${policies.map(policyField).join('')} ${window.usPricing.summaryHtml(offering)}

`; 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(); }());