Files
unsupervised-scheduler/assets/js/group-classes.js
T
thatguygriffandClaude Opus 4.8 fc7c0fa966
CI / Build Plugin Zip (pull_request) Skipped
CI / Tests (PHP 8.1) (pull_request) Successful in 46s
CI / Tests (PHP 8.2) (pull_request) Successful in 48s
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 2m47s
CI / Coding Standards (pull_request) Successful in 3m1s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m40s
Show "Enrol by <date>" on the group-class card while enrolment is open
The front end used the deadline only to gate the Enrol button; students had
no way to see when enrolment closes. Add an "Enrol by <date>" line to each
class card, shown while enrolment is still open, for the effective deadline
(the instructor's date, or the first class day by default).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-24 10:36:51 -03:00

252 lines
10 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.
const singleOfferingId = Number(app.dataset.offering || 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, '&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;
}
function renderClasses(offerings, enrolledOfferingIds) {
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>` : ''}
${o.description ? `<p>${escHtml(o.description)}</p>` : ''}
<p>${escHtml(Number(o.price).toFixed(2))} ${escHtml(o.currency)}</p>
${!enrolledOfferingIds.has(Number(o.id)) && isEnrollmentOpen(o) && enrolmentDeadline(o)
? `<p class="us-enrol-deadline">Enrol by ${escHtml(formatDate(enrolmentDeadline(o)))}</p>`
: ''}
${enrolledOfferingIds.has(Number(o.id))
? '<p class="us-enrolled"><strong>You are enrolled in this class.</strong></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', () => openEnrolment(offering));
});
}
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">
${questions.map(questionField).join('')}
${policies.map(policyField).join('')}
<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();
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,
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) => showConfirmation(window.usPayment.message(result)))
.catch((err) => showError(err.message));
}
function showConfirmation(message) {
confirm.textContent = message;
list.style.display = 'none';
confirm.style.display = 'block';
}
function loadClasses() {
clearError();
list.style.display = 'block';
confirm.style.display = 'none';
// 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.
Promise.all([
apiFetch('offerings?kind=group_class'),
apiFetch('enrollments'),
])
.then(([offerings, enrollments]) => renderClasses(
offerings,
new Set(enrollments
.filter((e) => e.status === 'active')
.map((e) => Number(e.offering_id)))
))
.catch((err) => showError(err.message));
}
loadClasses();
}());