Files
unsupervised-scheduler/assets/js/booking.js
T
thatguygriffandClaude Fable 5 14f43232c9
CI / Tests (PHP 8.2) (pull_request) Successful in 1m15s
CI / Tests (PHP 8.1) (pull_request) Successful in 1m16s
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 3m15s
CI / PHPStan (pull_request) Successful in 3m14s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m37s
CI / Build Plugin Zip (pull_request) Skipped
Default lessons to a week view on the booking page and in wp-admin
The front-end booking calendar now opens in the Week view (anchored to the
week of the earliest open slot) with List still available. The Scheduler and
My Lessons admin pages gain a week calendar (usc_view/usc_week, bucketed via
a new generic WeekCalendar::bucket()) and open in it by default; the original
table remains as the List view since it carries the HST / e-transfer forms.

Closes #76

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-22 10:13:04 -03:00

456 lines
18 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-booking-app');
if (!app) return;
const slotList = document.getElementById('us-slot-list');
const myLessons = document.getElementById('us-my-lessons');
const confirm = document.getElementById('us-booking-confirmation');
const errorBox = document.getElementById('us-booking-error');
const { restUrl, nonce } = usScheduler;
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;');
}
const dayKey = (dt) => String(dt).slice(0, 10);
// "2026-07-06 14:30:00" → "2:30 PM"
function timeOf(dt) {
const hours = Number(String(dt).slice(11, 13));
const minutes = String(dt).slice(14, 16);
return `${hours % 12 || 12}:${minutes} ${hours < 12 ? 'AM' : 'PM'}`;
}
function dayLabel(key) {
const date = new Date(key + 'T00:00:00');
if (Number.isNaN(date.getTime())) return key;
return date.toLocaleDateString(undefined, {
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
});
}
function shortDayLabel(key) {
const date = new Date(key + 'T00:00:00');
if (Number.isNaN(date.getTime())) return key;
return date.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
}
function groupByDay(slots) {
const groups = new Map();
slots.forEach((slot) => {
const key = dayKey(slot.start_dt);
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(slot);
});
return [...groups.entries()].sort((a, b) => a[0].localeCompare(b[0]));
}
// --- calendar view state (week is the default; week keeps its position) ---
let allSlots = [];
let view = 'week';
let weekStart = null;
const pad = (n) => String(n).padStart(2, '0');
const toKey = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
function addDays(key, days) {
const date = new Date(key + 'T00:00:00');
date.setDate(date.getDate() + days);
return toKey(date);
}
// First day of the week containing `key`, honouring the site's
// start-of-week setting (0 = Sunday … 6 = Saturday).
function weekStartOf(key) {
const startOfWeek = Number(usScheduler.startOfWeek) || 0;
const date = new Date(key + 'T00:00:00');
return addDays(key, -((date.getDay() - startOfWeek + 7) % 7));
}
function toggleHtml() {
return `
<div class="us-view-toggle" role="group" aria-label="Calendar view">
<button type="button" id="us-view-list" class="${view === 'list' ? 'us-active' : ''}">List</button>
<button type="button" id="us-view-week" class="${view === 'week' ? 'us-active' : ''}">Week</button>
</div>`;
}
// Agenda-style calendar: available slots grouped by day.
function listHtml() {
return groupByDay(allSlots).map(([key, daySlots]) => `
<div class="us-day">
<h3 class="us-day-heading">${escHtml(dayLabel(key))}</h3>
${daySlots.map((slot) => `
<div class="us-slot">
<span>${escHtml(timeOf(slot.start_dt))}${escHtml(timeOf(slot.end_dt))} (${escHtml(String(slot.duration_minutes))} min)</span>
<button data-slot-id="${slot.id}" class="us-book-btn">Book</button>
</div>
`).join('')}
</div>
`).join('');
}
// Weekly calendar: seven day columns with a bookable button per slot.
function weekHtml() {
const byDay = new Map(groupByDay(allSlots));
const days = [...Array(7).keys()].map((i) => addDays(weekStart, i));
const columns = days.map((key) => {
const daySlots = byDay.get(key) || [];
const buttons = daySlots.map((slot) => `
<button data-slot-id="${slot.id}" class="us-book-btn us-week-slot" title="${escHtml(String(slot.duration_minutes))} min">
${escHtml(timeOf(slot.start_dt))}
</button>
`).join('');
return `
<div class="us-week-day">
<h4 class="us-week-day-heading">${escHtml(shortDayLabel(key))}</h4>
${buttons || '<span class="us-week-empty" aria-hidden="true">—</span>'}
</div>`;
}).join('');
return `
<div class="us-week-nav">
<button type="button" id="us-week-prev">&lsaquo; Previous week</button>
<strong class="us-week-label">Week of ${escHtml(shortDayLabel(weekStart))}</strong>
<button type="button" id="us-week-next">Next week &rsaquo;</button>
</div>
<div class="us-week-grid">${columns}</div>`;
}
function render() {
if (!allSlots.length) {
slotList.innerHTML = '<p>No available lesson slots at this time.</p>';
return;
}
// Anchor the week view to the week of the earliest open slot (the API
// returns slots ordered by start), so the first look is never empty.
if (view === 'week' && !weekStart) weekStart = weekStartOf(dayKey(allSlots[0].start_dt));
slotList.innerHTML = toggleHtml() + (view === 'week' ? weekHtml() : listHtml());
wireCalendarEvents();
}
function wireCalendarEvents() {
document.getElementById('us-view-list').addEventListener('click', () => {
view = 'list';
render();
});
document.getElementById('us-view-week').addEventListener('click', () => {
view = 'week';
render();
});
const prev = document.getElementById('us-week-prev');
const next = document.getElementById('us-week-next');
if (prev) prev.addEventListener('click', () => { weekStart = addDays(weekStart, -7); render(); });
if (next) next.addEventListener('click', () => { weekStart = addDays(weekStart, 7); render(); });
slotList.querySelectorAll('.us-book-btn[data-slot-id]').forEach((btn) => {
const slot = allSlots.find((s) => String(s.id) === btn.dataset.slotId);
if (slot) btn.addEventListener('click', () => openRegistration(slot));
});
}
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>`;
}
// Active private-lesson offerings per instructor, so revisiting the
// registration form does not refetch the same catalog.
const offeringCache = new Map();
function instructorOfferings(instructorId) {
if (offeringCache.has(instructorId)) {
return Promise.resolve(offeringCache.get(instructorId));
}
return apiFetch(`offerings?instructor_id=${instructorId}&kind=private_lesson`).then((list) => {
offeringCache.set(instructorId, list);
return list;
});
}
// "Piano Lesson (60 min — $50.00 CAD)" / "Trial Lesson (Free)"
function offeringLabel(o) {
const duration = o.duration_minutes ? `${o.duration_minutes} min — ` : '';
const price = Number(o.price) > 0
? `$${Number(o.price).toFixed(2)} ${o.currency}`
: 'Free';
return `${o.title} (${duration}${price})`;
}
function openRegistration(slot) {
clearError();
Promise.all([
instructorOfferings(Number(slot.instructor_id)),
apiFetch('policies?scope=booking'),
])
.then(([offerings, policies]) => {
renderRegistration(slot, offerings, policies);
})
.catch((err) => showError(err.message));
}
function offeringFieldHtml(tied, tiedId, choices) {
if (tiedId) {
// The slot is tied to one offering: show it locked so the student
// sees exactly what they are booking.
const label = tied ? offeringLabel(tied) : `Offering #${tiedId}`;
return `
<p class="us-offering">
<label>Lesson type<br>
<select id="us-offering" disabled><option>${escHtml(label)}</option></select></label>
</p>`;
}
return `
<p class="us-offering">
<label>Lesson type<br>
<select id="us-offering" required>
<option value="">— Choose a lesson type —</option>
${choices.map((o) => `<option value="${o.id}">${escHtml(offeringLabel(o))}</option>`).join('')}
</select></label>
</p>`;
}
function renderRegistration(slot, offerings, policies) {
const tiedId = Number(slot.offering_id) || 0;
const tied = tiedId ? offerings.find((o) => Number(o.id) === tiedId) : null;
// Generic slots offer every lesson type that fits the slot's length.
const choices = tiedId
? []
: offerings.filter((o) => !o.duration_minutes || Number(o.duration_minutes) === Number(slot.duration_minutes));
if (!tiedId && !choices.length) {
// The server rejects offering-less bookings, so without a matching
// lesson type this time cannot be booked online.
slotList.innerHTML = `
<div class="us-register">
<p>This time cannot be booked online right now. Please contact the instructor.</p>
<p><button type="button" id="us-cancel" class="us-cancel-btn">Back</button></p>
</div>`;
document.getElementById('us-cancel').addEventListener('click', loadSlots);
return;
}
const weekly = slot.recurrence_group
? `<p><label><input type="checkbox" id="us-weekly"> Reserve this time weekly for the term</label></p>`
: '';
slotList.innerHTML = `
<div class="us-register">
<h3>${escHtml(dayLabel(dayKey(slot.start_dt)))} · ${escHtml(timeOf(slot.start_dt))}${escHtml(timeOf(slot.end_dt))}</h3>
<form id="us-register-form">
${offeringFieldHtml(tied, tiedId, choices)}
<div id="us-questions"></div>
${policies.map(policyField).join('')}
${weekly}
<p>
<button type="submit" class="us-book-btn">Confirm Booking</button>
<button type="button" id="us-cancel" class="us-cancel-btn">Back</button>
</p>
</form>
</div>`;
// The intake questions belong to the selected offering, so they follow
// the picker instead of being fixed at render time.
let selectedId = tiedId;
let questions = [];
const questionsBox = document.getElementById('us-questions');
function loadQuestions() {
questions = [];
questionsBox.innerHTML = '';
if (!selectedId) return;
apiFetch(`offerings/${selectedId}/questions`)
.then((qs) => {
questions = qs;
questionsBox.innerHTML = qs.map(questionField).join('');
})
.catch((err) => showError(err.message));
}
if (!tiedId) {
document.getElementById('us-offering').addEventListener('change', (e) => {
selectedId = Number(e.target.value) || 0;
loadQuestions();
});
}
loadQuestions();
document.getElementById('us-cancel').addEventListener('click', loadSlots);
document.getElementById('us-register-form').addEventListener('submit', (e) => {
e.preventDefault();
if (!selectedId) {
showError('Please choose a lesson type.');
return;
}
submitBooking(e.target, slot, selectedId, questions);
});
}
function submitBooking(form, slot, offeringId, 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));
const weeklyEl = document.getElementById('us-weekly');
apiFetch('bookings', {
method: 'POST',
body: JSON.stringify({
slot_id: slot.id,
offering_id: offeringId,
recurrence: weeklyEl && weeklyEl.checked ? 'weekly' : 'single',
answers,
accepted_policy_version_ids: accepted,
}),
})
// A booking with nothing owed has no payment, so there is no payment
// step to run — the booking is already confirmed server-side.
.then((res) => (res.payment
? window.usPayment.collect('lesson', (res.ids || [])[0], slotList)
: null))
.then((result) => {
loadMyLessons();
showConfirmation(window.usPayment.message(result));
})
.catch((err) => showError(err.message));
}
function lessonStatusLabel(status) {
if (status === 'pending') return 'Pending payment';
if (status === 'confirmed') return 'Confirmed';
return status.charAt(0).toUpperCase() + status.slice(1);
}
function renderMyLessons(lessons) {
const upcoming = lessons.filter((l) => l.start_dt);
if (!upcoming.length) {
myLessons.innerHTML = '';
return;
}
myLessons.innerHTML = `
<div class="us-my-lessons">
<h3>Your upcoming lessons</h3>
${upcoming.map((l) => `
<div class="us-my-lesson">
<span>${escHtml(dayLabel(dayKey(l.start_dt)))} · ${escHtml(timeOf(l.start_dt))}${escHtml(timeOf(l.end_dt))}</span>
<span class="us-my-lesson-actions">
<span class="us-lesson-status us-lesson-status-${escHtml(String(l.status))}">${escHtml(lessonStatusLabel(String(l.status)))}</span>
<button type="button" class="us-cancel-lesson" data-lesson-id="${l.id}">Cancel</button>
</span>
</div>
`).join('')}
</div>`;
myLessons.querySelectorAll('.us-cancel-lesson').forEach((btn) => {
btn.addEventListener('click', () => cancelLesson(Number(btn.dataset.lessonId)));
});
}
function cancelLesson(id) {
if (!window.confirm('Cancel this lesson? The time will be released for other students.')) {
return;
}
clearError();
apiFetch(`bookings/${id}/cancel`, { method: 'POST' })
.then(loadSlots)
.catch((err) => showError(err.message));
}
function loadMyLessons() {
if (!myLessons) return;
// The lesson list is a bonus panel: never let it break slot browsing.
apiFetch('bookings')
.then(renderMyLessons)
.catch(() => { myLessons.innerHTML = ''; });
}
function showConfirmation(message) {
confirm.textContent = message;
slotList.style.display = 'none';
confirm.style.display = 'block';
}
function loadSlots() {
clearError();
slotList.style.display = 'block';
confirm.style.display = 'none';
loadMyLessons();
apiFetch('availability')
.then((slots) => {
allSlots = slots;
render();
})
.catch((err) => showError(err.message));
}
loadSlots();
}());