/* 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, '&')
.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 `
`;
}
// Agenda-style calendar: available slots grouped by day.
function listHtml() {
return groupByDay(allSlots).map(([key, daySlots]) => `
${escHtml(dayLabel(key))}
${daySlots.map((slot) => `
${escHtml(timeOf(slot.start_dt))}–${escHtml(timeOf(slot.end_dt))} (${escHtml(String(slot.duration_minutes))} min)
`).join('')}
`).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) => `
`).join('');
return `
${escHtml(shortDayLabel(key))}
${buttons || '—'}
`;
}).join('');
return `
Week of ${escHtml(shortDayLabel(weekStart))}
${columns}
`;
}
function render() {
if (!allSlots.length) {
slotList.innerHTML = 'No available lesson slots at this time.
';
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 = ``;
} else if (q.field_type === 'select') {
const opts = (q.options || []).map((o) => ``).join('');
input = ``;
} else if (q.field_type === 'checkbox') {
input = ``;
} else {
input = ``;
}
return ``;
}
function policyField(p) {
return `
`;
}
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
? ``
: '';
slotList.innerHTML = `
${escHtml(dayLabel(dayKey(slot.start_dt)))} · ${escHtml(timeOf(slot.start_dt))}–${escHtml(timeOf(slot.end_dt))}
`;
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,
}),
})
// 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 = `
Your upcoming lessons
${upcoming.map((l) => `
${escHtml(dayLabel(dayKey(l.start_dt)))} · ${escHtml(timeOf(l.start_dt))}–${escHtml(timeOf(l.end_dt))}
${escHtml(lessonStatusLabel(String(l.status)))}
`).join('')}
`;
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();
}());