CI / Build Plugin Zip (pull_request) Has been skipped
CI / Tests (PHP 8.2) (pull_request) Successful in 45s
CI / PHPStan (pull_request) Successful in 2m48s
CI / Tests (PHP 8.1) (pull_request) Successful in 41s
CI / No Debug Code (pull_request) Failing after 2s
CI / Coding Standards (pull_request) Successful in 52s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m36s
Availability windows were stored and served as a single bookable row, so a 9:00 AM-4:00 PM window showed to students as one giant slot and booking it consumed the whole day; past and multi-day windows also leaked into the booking page as nonsense entries. - Split windows into consecutive lesson-length slots on save (REST and admin form); each chunk is independently bookable and weekly recurrence creates a series per chunk so "reserve this time weekly" holds the same hour each week - Reject windows spanning multiple days or shorter than the lesson length (400 invalid_window) - Never return slots whose start has passed from GET /availability - Migrate pre-split rows: Plugin::boot re-runs the Installer on version change and AvailabilityRepository::splitOversizedWindows() rewrites unbooked same-day oversized windows in place - Display all times in 12-hour AM/PM form (booking page, wp-admin lists, editor previews) - Add a List | Week view toggle to the student booking page and the instructor availability page, with previous/next-week navigation honouring the site's start_of_week option (new WeekCalendar helper) Co-Authored-By: Claude Fable 5 <[email protected]>
302 lines
12 KiB
JavaScript
302 lines
12 KiB
JavaScript
/* global usScheduler */
|
||
(function () {
|
||
'use strict';
|
||
|
||
const app = document.getElementById('us-booking-app');
|
||
if (!app) return;
|
||
|
||
const slotList = document.getElementById('us-slot-list');
|
||
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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|
||
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 (list is the default; week keeps its position) ---
|
||
let allSlots = [];
|
||
let view = 'list';
|
||
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">‹ Previous week</button>
|
||
<strong class="us-week-label">Week of ${escHtml(shortDayLabel(weekStart))}</strong>
|
||
<button type="button" id="us-week-next">Next week ›</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;
|
||
}
|
||
|
||
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';
|
||
// Default to the week of the earliest open slot (the API returns
|
||
// slots ordered by start), so the first look is never empty.
|
||
if (!weekStart) weekStart = weekStartOf(dayKey(allSlots[0].start_dt));
|
||
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>`;
|
||
}
|
||
|
||
function openRegistration(slot) {
|
||
clearError();
|
||
|
||
const offeringId = Number(slot.offering_id) || 0;
|
||
const qPath = offeringId ? `offerings/${offeringId}/questions` : null;
|
||
|
||
Promise.all([
|
||
qPath ? apiFetch(qPath) : Promise.resolve([]),
|
||
apiFetch('policies?scope=booking'),
|
||
])
|
||
.then(([questions, policies]) => {
|
||
renderRegistration(slot, offeringId, questions, policies);
|
||
})
|
||
.catch((err) => showError(err.message));
|
||
}
|
||
|
||
function renderRegistration(slot, offeringId, questions, policies) {
|
||
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">
|
||
${questions.map(questionField).join('')}
|
||
${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>`;
|
||
|
||
document.getElementById('us-cancel').addEventListener('click', loadSlots);
|
||
document.getElementById('us-register-form').addEventListener('submit', (e) => {
|
||
e.preventDefault();
|
||
submitBooking(e.target, slot, offeringId, 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,
|
||
}),
|
||
})
|
||
.then((res) => window.usPayment.collect('lesson', (res.ids || [])[0], slotList))
|
||
.then((result) => showConfirmation(window.usPayment.message(result)))
|
||
.catch((err) => showError(err.message));
|
||
}
|
||
|
||
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';
|
||
apiFetch('availability')
|
||
.then((slots) => {
|
||
allSlots = slots;
|
||
render();
|
||
})
|
||
.catch((err) => showError(err.message));
|
||
}
|
||
|
||
loadSlots();
|
||
}());
|