/* 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; // Per-instance options from the block/shortcode: pin the page to a single // lesson type, and whether the "Show Only" filter is offered at all. const pinnedTypeId = Number(app.dataset.lessonType) || 0; const filterEnabled = app.dataset.typeFilter !== '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, '"'); } 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; // Every active private-lesson type the student may book, across instructors. let catalog = []; // Lesson types the student has filtered the calendar down to; empty means // "no filter" — every open slot is shown. The list starts collapsed behind // the "Show Only" button and stays open across re-renders once revealed. const selectedTypeIds = new Set(); let filterOpen = false; // Whether an offering can be booked into a slot — the client-side mirror of // the rule `POST /bookings` enforces: a slot tied to an offering takes that // offering only, and a generic slot takes any of its instructor's types // whose length fits. function offeringFitsSlot(offering, slot) { if (Number(offering.instructor_id) !== Number(slot.instructor_id)) return false; const tiedId = Number(slot.offering_id) || 0; if (tiedId) return Number(offering.id) === tiedId; return !offering.duration_minutes || Number(offering.duration_minutes) === Number(slot.duration_minutes); } const filterActive = () => selectedTypeIds.size > 0; const typeSelected = (offering) => !filterActive() || selectedTypeIds.has(Number(offering.id)); // The lesson types this slot could be booked as, honouring the filter. function slotChoices(slot) { return catalog.filter((o) => offeringFitsSlot(o, slot) && typeSelected(o)); } // With a filter set, a slot is only shown when one of the chosen lesson // types can actually be booked into it. function visibleSlots() { if (!filterActive()) return allSlots; return allSlots.filter((slot) => slotChoices(slot).length > 0); } 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)); } // The calendar's control row: the view toggle, and the button that reveals // the lesson-type filter beneath it. function controlsHtml() { return `
${filterToggleHtml()}
`; } // Nothing to filter with a single bookable type, so the control only // appears once there is a choice to make. function filterToggleHtml() { if (!filterEnabled || catalog.length < 2) return ''; const count = filterActive() ? ` (${selectedTypeIds.size})` : ''; return ` `; } // "Piano Lesson (30 min)" — the instructor's name is only worth the space // when the catalog spans more than one of them. function filterLabel(offering) { const duration = offering.duration_minutes ? ` (${offering.duration_minutes} min)` : ''; const instructors = new Set(catalog.map((o) => Number(o.instructor_id))); const who = instructors.size > 1 && offering.instructor_name ? ` — ${offering.instructor_name}` : ''; return `${offering.title}${duration}${who}`; } // The lesson-type list itself — collapsed until the student opens it, and // rendered between the control row and the calendar. function filterHtml() { if (!filterEnabled || catalog.length < 2 || !filterOpen) return ''; const choices = catalog.map((o) => ` `).join(''); return `
Lesson type
${choices} ${filterActive() ? '' : ''}
`; } // Agenda-style calendar: available slots grouped by day. function listHtml(slots) { return groupByDay(slots).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(slots) { const byDay = new Map(groupByDay(slots)); 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() { const slots = visibleSlots(); // The pinned lesson type is no longer on offer (deactivated or // deleted), so this page has nothing it is allowed to book. if (pinnedTypeId && !catalog.length) { slotList.innerHTML = '

This lesson type is not available for booking right now.

'; return; } // Nothing open at all: there is nothing for the controls to act on. if (!allSlots.length) { slotList.innerHTML = '

No available lesson slots at this time.

'; return; } if (!slots.length) { const message = pinnedTypeId ? '

No open times for this lesson type right now.

' : '

No open times match the selected lesson types.

'; slotList.innerHTML = controlsHtml() + filterHtml() + message; wireControlEvents(); return; } // Anchor the week view to the week of the earliest matching slot (the // API returns slots ordered by start), so the first look is never empty. if (view === 'week' && !weekStart) weekStart = weekStartOf(dayKey(slots[0].start_dt)); slotList.innerHTML = controlsHtml() + filterHtml() + (view === 'week' ? weekHtml(slots) : listHtml(slots)); wireControlEvents(); wireCalendarEvents(); } function wireControlEvents() { document.getElementById('us-view-list').addEventListener('click', () => { view = 'list'; render(); }); document.getElementById('us-view-week').addEventListener('click', () => { view = 'week'; render(); }); const toggle = document.getElementById('us-filter-toggle'); if (toggle) { toggle.addEventListener('click', () => { filterOpen = !filterOpen; render(); }); } slotList.querySelectorAll('.us-type-filter-option').forEach((input) => { input.addEventListener('change', () => { const id = Number(input.value); if (input.checked) { selectedTypeIds.add(id); } else { selectedTypeIds.delete(id); } // The nearest matching time may be weeks away, so re-anchor the // week view instead of leaving the student on an empty week. weekStart = null; render(); }); }); const clear = document.getElementById('us-type-filter-clear'); if (clear) { clear.addEventListener('click', () => { selectedTypeIds.clear(); weekStart = null; render(); }); } } function wireCalendarEvents() { 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 `

${escHtml(p.title)}

${p.body || ''}
`; } // "Piano Lesson (60 min — 50.00 CAD at booking)" / "Trial Lesson (Free)" function offeringLabel(o) { const duration = o.duration_minutes ? `${o.duration_minutes} min — ` : ''; return `${o.title} (${duration}${window.usPricing.priceLabel(o)})`; } // How many lessons a weekly reservation can claim, mirroring // BookingEndpoint::MAX_WEEKLY_OCCURRENCES so the quoted total is never // higher than the server will actually charge for. const MAX_WEEKLY_OCCURRENCES = 12; // The open times a weekly reservation of this slot would claim: every // still-unbooked slot of its recurring group, capped the way the server // caps it. Some may be taken by another student first, so this is the // upper bound on what will be booked, not a guarantee. function weeklyOccurrences(slot) { if (!slot.recurrence_group) return 1; const inGroup = allSlots.filter((s) => s.recurrence_group === slot.recurrence_group).length; return Math.min(Math.max(inGroup, 1), MAX_WEEKLY_OCCURRENCES); } function openRegistration(slot) { clearError(); apiFetch('policies?scope=booking') .then((policies) => renderRegistration(slot, 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 `

`; } // Only one type is left to book this slot as — usually because the // filter narrowed it down — so it is chosen for the student. if (choices.length === 1) { return `

`; } return `

`; } function renderRegistration(slot, policies) { const tiedId = Number(slot.offering_id) || 0; const tied = tiedId ? catalog.find((o) => Number(o.id) === tiedId) : null; // Generic slots offer every lesson type that fits the slot — narrowed to // the filtered types when the student has set a filter. const choices = tiedId ? [] : slotChoices(slot); if (!tiedId && !choices.length) { // The server rejects offering-less bookings, so without a matching // lesson type this time cannot be booked online. slotList.innerHTML = `

This time cannot be booked online right now. Please contact the instructor.

`; document.getElementById('us-cancel').addEventListener('click', loadSlots); return; } const weekly = slot.recurrence_group ? `

` : ''; slotList.innerHTML = `

${escHtml(dayLabel(dayKey(slot.start_dt)))} · ${escHtml(timeOf(slot.start_dt))}–${escHtml(timeOf(slot.end_dt))}

${offeringFieldHtml(tied, tiedId, choices)}
${policies.map(policyField).join('')} ${weekly}

`; // The intake questions belong to the selected offering, so they follow // the picker instead of being fixed at render time. A tied slot — or a // lone remaining type — is already decided, so its questions load // straight away. let selectedId = tiedId || (choices.length === 1 ? Number(choices[0].id) : 0); let questions = []; const questionsBox = document.getElementById('us-questions'); const priceBox = document.getElementById('us-price-summary'); const weeklyEl = document.getElementById('us-weekly'); // What the booking will cost and the agreement to pay it, restated // whenever the choices that decide the amount change: the lesson type // carries the price, and a weekly reservation multiplies a per-lesson // one-time price by every week it claims. A slot tied to a type the // catalog no longer carries has no price to quote, so it shows nothing // rather than a figure it cannot stand behind. function renderPrice() { const offering = selectedId ? catalog.find((o) => Number(o.id) === selectedId) : null; priceBox.innerHTML = offering ? window.usPricing.summaryHtml({ price: offering.price, currency: offering.currency, billing_mode: offering.billing_mode, kind: offering.kind, occurrences: weeklyEl && weeklyEl.checked ? weeklyOccurrences(slot) : 1, }) : ''; } 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(); renderPrice(); }); } if (weeklyEl) weeklyEl.addEventListener('change', renderPrice); loadQuestions(); renderPrice(); 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; } if (!window.usPricing.agreed(e.target)) { showError(window.usPricing.AGREE_REQUIRED); 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); } // How many upcoming lessons to show before the "Show all" reveal. const INITIAL_LESSON_COUNT = 5; function lessonRowHtml(l) { const title = l.offering_title ? escHtml(String(l.offering_title)) : 'Lesson'; const duration = l.duration_minutes ? ` (${escHtml(String(l.duration_minutes))} min)` : ''; return `
${title}${duration} ${escHtml(dayLabel(dayKey(l.start_dt)))} · ${escHtml(timeOf(l.start_dt))}–${escHtml(timeOf(l.end_dt))} ${escHtml(lessonStatusLabel(String(l.status)))}
`; } function renderMyLessons(lessons) { const upcoming = lessons.filter((l) => l.start_dt); if (!upcoming.length) { myLessons.innerHTML = ''; return; } // Show only the soonest few by default; the rest sit hidden behind a // reveal so a busy student's list stays short. const visible = upcoming.slice(0, INITIAL_LESSON_COUNT); const hidden = upcoming.slice(INITIAL_LESSON_COUNT); myLessons.innerHTML = `

Your upcoming lessons

${visible.map(lessonRowHtml).join('')} ${hidden.length ? ` ` : ''}
`; const moreBox = myLessons.querySelector('.us-my-lessons-more'); const showAll = myLessons.querySelector('.us-show-all-lessons'); if (showAll && moreBox) { showAll.addEventListener('click', () => { moreBox.hidden = false; showAll.remove(); }); } 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'; } // The private-lesson catalog drives both the filter and the registration // form's lesson-type picker, and it does not change while the student // browses — so it is fetched once and kept. let catalogLoaded = false; function loadCatalog() { if (catalogLoaded) return Promise.resolve(catalog); return apiFetch('offerings?kind=private_lesson').then((list) => { // A pinned lesson type is the only one this page may book, so the // catalog is narrowed to it and the filter is fixed on it. With a // single type left the "Show Only" control hides itself. catalog = pinnedTypeId ? list.filter((o) => Number(o.id) === pinnedTypeId) : list; if (pinnedTypeId) selectedTypeIds.add(pinnedTypeId); catalogLoaded = true; return catalog; }); } function loadSlots() { clearError(); loadMyLessons(); // An upcoming-lessons-only embed has no calendar to fill. if (!slotList) return; slotList.style.display = 'block'; confirm.style.display = 'none'; Promise.all([apiFetch('availability'), loadCatalog()]) .then(([slots]) => { allSlots = slots; render(); }) .catch((err) => showError(err.message)); } loadSlots(); }());