Files
unsupervised-scheduler/assets/js/group-classes.js
T
thatguygriffandClaude Opus 5 9344ab7193
CI / Tests (PHP 8.2) (pull_request) Successful in 46s
CI / Tests (PHP 8.1) (pull_request) Successful in 56s
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 2m56s
CI / Coding Standards (pull_request) Successful in 2m59s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m41s
CI / Build Plugin Zip (pull_request) Skipped
Show price cadence and require a pay agreement at booking
Every price a student meets on the front end now carries the cadence it is
billed on — at booking, up front, weekly, monthly — so a bare amount can no
longer read as a one-off when it is a recurring charge.

Both registration forms then restate the price and require a second, separate
tick agreeing to pay it, distinct from the policy acceptances above it. The
agreed figure includes the studio HST so it matches Payment::total(), the amount
actually billed; the rate reaches the browser as a new localized `taxRate`.

A weekly reservation is charged per lesson for every week it claims, and a week
another student takes first is simply not claimed, so its total is quoted as a
ceiling ("up to 12 lessons") rather than a promise. Free offerings have nothing
to agree to and show no price block at all.

The formatting and the agreement live in one shared helper (`window.usPricing`,
registered as `us-scheduler-pricing`) so a price reads the same in the booking
form, the class catalogue and the editor preview.

Closes #124

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-28 14:43:38 -03:00

284 lines
12 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);
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', () => 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">
${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,
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 Map(enrollments
.filter((e) => e.status === 'active')
.map((e) => [Number(e.offering_id), e.id]))
))
.catch((err) => showError(err.message));
}
loadClasses();
}());