Files
unsupervised-scheduler/assets/js/booking.js
T
thatguygriff 19e663d6fa
CI / Coding Standards (pull_request) Successful in 50s
CI / PHPStan (pull_request) Successful in 1m2s
CI / Tests (PHP 8.1) (pull_request) Successful in 47s
CI / Tests (PHP 8.2) (pull_request) Successful in 48s
CI / Tests (PHP 8.3) (pull_request) Successful in 46s
CI / No Debug Code (pull_request) Successful in 2s
CI / Build Plugin Zip (pull_request) Has been skipped
Extend availability (durations, weekly recurrence, calendar); price offerings in dollars
Availability (#2):
- us_availability gains offering_id, duration_minutes (default 60), and
  recurrence_group; AvailabilitySlot carries the new fields.
- AvailabilityRepository::createWeeklySeries() generates N weekly rows
  sharing a recurrence_group; findAvailable() filters by offering and
  duration. Date math uses DateTimeImmutable::modify() (the no-debug CI
  regex `dd\(` matches `->add(`).
- REST GET filters by offering_id/duration_minutes; POST accepts
  duration_minutes, offering_id, recurrence (single|weekly) + weeks.
- Admin form adds duration, an offering picker, and one-off/weekly options
  (OfferingRepository wired into AvailabilityController).
- booking.js renders an agenda calendar (slots grouped by day, with
  duration). The richer booking UX lands with the booking-flow work.

Offering price in dollars:
- Switch us_offerings.price_cents (INT) to price DECIMAL(10,2); Offering
  uses float $price. Admin form and REST take dollars.
- Fix a pre-existing misalignment in the Offering insert/update $wpdb
  format arrays (billing_mode/capacity/is_active were mapped to the wrong
  specifiers, which would corrupt values) via a single COLUMN_FORMATS list.

Also bump PHPStan to --memory-limit=1G in the lint script; 128M now
crashes analysis as the codebase has grown.

Refs #2

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 15:43:48 -03:00

111 lines
3.5 KiB
JavaScript
Raw 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 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 dayKey(dt) {
return String(dt).slice(0, 10);
}
function timeOf(dt) {
return String(dt).slice(11, 16);
}
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 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]));
}
// Agenda-style calendar: available slots grouped by day. The richer booking
// UX (offering selection, intake questions, policy acceptance) lands with the
// booking-flow work.
function renderSlots(slots) {
if (!slots.length) {
slotList.innerHTML = '<p>No available lesson slots at this time.</p>';
return;
}
slotList.innerHTML = groupByDay(slots).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('');
slotList.querySelectorAll('.us-book-btn').forEach((btn) => {
btn.addEventListener('click', () => bookSlot(Number(btn.dataset.slotId)));
});
}
function escHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function bookSlot(slotId) {
errorBox.style.display = 'none';
apiFetch('bookings', {
method: 'POST',
body: JSON.stringify({ slot_id: slotId }),
})
.then(() => {
slotList.style.display = 'none';
confirm.style.display = 'block';
})
.catch((err) => showError(err.message));
}
apiFetch('availability')
.then(renderSlots)
.catch((err) => showError(err.message));
}());