CI / Tests (PHP 8.2) (pull_request) Successful in 45s
CI / Tests (PHP 8.1) (pull_request) Successful in 48s
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 1m14s
CI / PHPStan (pull_request) Successful in 1m16s
CI / Tests (PHP 8.3) (pull_request) Successful in 37s
CI / Build Plugin Zip (pull_request) Has been skipped
Group class offerings now carry real dates: the add/edit form takes a start date plus a sessions control (one-off, or weekly for N sessions; the end date is computed as start + (N-1) weeks via Offering::weeklyTermEnd). Dates are validated strictly (Y-m-d) and shown in the offerings list and on the student-facing class card, including the weekly session count. [us_group_classes offering="<id>"] (block attribute offeringId, chosen from a dropdown of active classes fetched from the public offerings endpoint) restricts the page to a single class so the enrolment flow can be embedded on a page dedicated to that class; a pinned class that is no longer offered reports itself closed instead of falling back to the catalog. Offerings are now editable from the admin screen: an Edit button prefills the shared add/edit form and saving posts usc_action=update. Updates always preserve the original owner and currency, and non-admin instructors can only load and update their own offerings. The form also gains the previously missing description field and an Active toggle (the admin-UI counterpart of the REST is_active flag) so an edit cannot wipe data the form never collected. Closes #59 Co-Authored-By: Claude Fable 5 <[email protected]>
198 lines
7.5 KiB
JavaScript
198 lines
7.5 KiB
JavaScript
/* 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, '&')
|
||
.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 = `<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)`;
|
||
}
|
||
|
||
function renderClasses(offerings) {
|
||
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>
|
||
${termLabel(o) ? `<p>${escHtml(termLabel(o))}</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>
|
||
<button data-offering-id="${o.id}" class="us-enrol-btn">Enrol</button>
|
||
</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';
|
||
apiFetch('offerings?kind=group_class')
|
||
.then(renderClasses)
|
||
.catch((err) => showError(err.message));
|
||
}
|
||
|
||
loadClasses();
|
||
}());
|