Filter booking calendar slots by available lesson type #118
@@ -14,6 +14,7 @@ each change under the current top section as you work.
|
||||
## [1.2.2]
|
||||
|
||||
### Added
|
||||
- The booking calendar now has a **lesson type** filter above it, so a student browsing open times can narrow them to the types they actually want. Because not every open time can be booked as every private-lesson type — some times are tied to a specific type, others only take types of a matching length — the filter shows just the times bookable as the ticked types, and re-anchors the week view on the earliest one so it never opens on an empty week. Picking one of those times narrows the **Lesson type** picker on the registration form to the same list, and when only one type is left it is chosen automatically with its questions loaded. Tick nothing (or use **Show all types**) to see every open time as before. The filter is hidden when the studio only offers one private-lesson type.
|
||||
- The **Group Classes** block can now be pinned to a single class, under **Classes shown → Class** in the block sidebar (shortcode: `[us_group_classes offering="…"]`). Pick a class and the block shows only that one, so it can be embedded on a page that describes the class. In this mode the class's own description is left out to avoid repeating the page copy — the card shows the schedule, instructor, price, enrolment deadline and the enrol/withdraw controls. Leaving it on **All classes** keeps the full browsable catalog with descriptions.
|
||||
- The **Student Registration** block can now send students onward to a page of your choosing once they finish registering. Its **After email confirmation** panel is now **After registration**: the page you pick there is where the link shown to a newly registered student points — the "Sign in to your account" link after they confirm their email, and a "Continue to your account" link for an invited student, who is signed in immediately. A new **Redirect automatically** option takes them straight there instead of showing the link. Registration errors are never skipped — a failed sign-up and an expired confirmation link still show their message on the page, as does the "check your email to confirm your address" step. The redirect needs a page to be chosen; with none set, students see the link (or, for invited students, just the confirmation) as before.
|
||||
|
||||
|
||||
@@ -117,6 +117,36 @@
|
||||
color: #8a6d1a;
|
||||
}
|
||||
|
||||
.us-type-filter {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 16px;
|
||||
margin-bottom: 12px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.us-type-filter-heading {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.us-type-filter-choice {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.us-type-filter-clear {
|
||||
margin-left: auto;
|
||||
padding: 4px 12px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.us-view-toggle {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
+147
-41
@@ -81,6 +81,43 @@
|
||||
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.
|
||||
const selectedTypeIds = new Set();
|
||||
|
||||
// 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())}`;
|
||||
|
||||
@@ -106,9 +143,40 @@
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// "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}`;
|
||||
}
|
||||
|
||||
// Lesson-type filter. Pointless with a single bookable type, so it is only
|
||||
// rendered once there is a choice to make.
|
||||
function filterHtml() {
|
||||
if (catalog.length < 2) return '';
|
||||
|
||||
const choices = catalog.map((o) => `
|
||||
<label class="us-type-filter-choice">
|
||||
<input type="checkbox" class="us-type-filter-option" value="${o.id}" ${selectedTypeIds.has(Number(o.id)) ? 'checked' : ''}>
|
||||
${escHtml(filterLabel(o))}
|
||||
</label>
|
||||
`).join('');
|
||||
|
||||
return `
|
||||
<div class="us-type-filter" role="group" aria-label="Filter by lesson type">
|
||||
<span class="us-type-filter-heading">Lesson type</span>
|
||||
${choices}
|
||||
${filterActive() ? '<button type="button" id="us-type-filter-clear" class="us-type-filter-clear">Show all types</button>' : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Agenda-style calendar: available slots grouped by day.
|
||||
function listHtml() {
|
||||
return groupByDay(allSlots).map(([key, daySlots]) => `
|
||||
function listHtml(slots) {
|
||||
return groupByDay(slots).map(([key, daySlots]) => `
|
||||
<div class="us-day">
|
||||
<h3 class="us-day-heading">${escHtml(dayLabel(key))}</h3>
|
||||
${daySlots.map((slot) => `
|
||||
@@ -122,8 +190,8 @@
|
||||
}
|
||||
|
||||
// Weekly calendar: seven day columns with a bookable button per slot.
|
||||
function weekHtml() {
|
||||
const byDay = new Map(groupByDay(allSlots));
|
||||
function weekHtml(slots) {
|
||||
const byDay = new Map(groupByDay(slots));
|
||||
const days = [...Array(7).keys()].map((i) => addDays(weekStart, i));
|
||||
|
||||
const columns = days.map((key) => {
|
||||
@@ -151,19 +219,51 @@
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!allSlots.length) {
|
||||
slotList.innerHTML = '<p>No available lesson slots at this time.</p>';
|
||||
const slots = visibleSlots();
|
||||
|
||||
if (!slots.length) {
|
||||
slotList.innerHTML = filterHtml() + (allSlots.length
|
||||
? '<p>No open times match the selected lesson types.</p>'
|
||||
: '<p>No available lesson slots at this time.</p>');
|
||||
wireFilterEvents();
|
||||
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));
|
||||
// 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 = toggleHtml() + (view === 'week' ? weekHtml() : listHtml());
|
||||
slotList.innerHTML = filterHtml() + toggleHtml() + (view === 'week' ? weekHtml(slots) : listHtml(slots));
|
||||
wireFilterEvents();
|
||||
wireCalendarEvents();
|
||||
}
|
||||
|
||||
function wireFilterEvents() {
|
||||
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() {
|
||||
document.getElementById('us-view-list').addEventListener('click', () => {
|
||||
view = 'list';
|
||||
@@ -211,20 +311,6 @@
|
||||
</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 — ` : '';
|
||||
@@ -237,13 +323,8 @@
|
||||
function openRegistration(slot) {
|
||||
clearError();
|
||||
|
||||
Promise.all([
|
||||
instructorOfferings(Number(slot.instructor_id)),
|
||||
apiFetch('policies?scope=booking'),
|
||||
])
|
||||
.then(([offerings, policies]) => {
|
||||
renderRegistration(slot, offerings, policies);
|
||||
})
|
||||
apiFetch('policies?scope=booking')
|
||||
.then((policies) => renderRegistration(slot, policies))
|
||||
.catch((err) => showError(err.message));
|
||||
}
|
||||
|
||||
@@ -258,6 +339,19 @@
|
||||
<select id="us-offering" disabled><option>${escHtml(label)}</option></select></label>
|
||||
</p>`;
|
||||
}
|
||||
|
||||
// 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 `
|
||||
<p class="us-offering">
|
||||
<label>Lesson type<br>
|
||||
<select id="us-offering" required>
|
||||
<option value="${choices[0].id}" selected>${escHtml(offeringLabel(choices[0]))}</option>
|
||||
</select></label>
|
||||
</p>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<p class="us-offering">
|
||||
<label>Lesson type<br>
|
||||
@@ -268,14 +362,13 @@
|
||||
</p>`;
|
||||
}
|
||||
|
||||
function renderRegistration(slot, offerings, policies) {
|
||||
function renderRegistration(slot, policies) {
|
||||
const tiedId = Number(slot.offering_id) || 0;
|
||||
const tied = tiedId ? offerings.find((o) => Number(o.id) === tiedId) : null;
|
||||
const tied = tiedId ? catalog.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));
|
||||
// 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
|
||||
@@ -309,8 +402,10 @@
|
||||
</div>`;
|
||||
|
||||
// The intake questions belong to the selected offering, so they follow
|
||||
// the picker instead of being fixed at render time.
|
||||
let selectedId = tiedId;
|
||||
// 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');
|
||||
@@ -467,13 +562,24 @@
|
||||
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.
|
||||
function loadCatalog() {
|
||||
if (catalog.length) return Promise.resolve(catalog);
|
||||
return apiFetch('offerings?kind=private_lesson').then((list) => {
|
||||
catalog = list;
|
||||
return catalog;
|
||||
});
|
||||
}
|
||||
|
||||
function loadSlots() {
|
||||
clearError();
|
||||
slotList.style.display = 'block';
|
||||
confirm.style.display = 'none';
|
||||
loadMyLessons();
|
||||
apiFetch('availability')
|
||||
.then((slots) => {
|
||||
Promise.all([apiFetch('availability'), loadCatalog()])
|
||||
.then(([slots]) => {
|
||||
allSlots = slots;
|
||||
render();
|
||||
})
|
||||
|
||||
@@ -49,6 +49,8 @@ The front-end booking shortcode renders open slots from `GET /availability`
|
||||
either as an agenda-style list grouped by day or as a **weekly calendar** with
|
||||
previous/next-week navigation (toggle rendered by `assets/js/booking.js`; the
|
||||
site's `start_of_week` option is passed through the `usScheduler` JS config).
|
||||
Both views can be narrowed to the slots bookable as chosen private-lesson types
|
||||
with the lesson-type filter — see `lesson-booking.md`.
|
||||
|
||||
## REST API
|
||||
| Method | Endpoint | Permission |
|
||||
|
||||
@@ -20,8 +20,8 @@ Students register for a private lesson by choosing an offering, picking a time (
|
||||
| `created_at` | DATETIME | Insertion time |
|
||||
|
||||
## Registration Flow
|
||||
1. Student opens the page with the `[us_booking]` shortcode and browses open slots as a weekly calendar (the default, anchored to the week of the earliest open slot) or an agenda list (view toggle with previous/next-week navigation; times shown in 12-hour AM/PM form).
|
||||
2. Student picks a slot and an **offering** (a 30 or 60-minute private-lesson type). When the slot is tied to an offering the form shows it locked (the student sees exactly what they are booking); otherwise the form presents the instructor's active private-lesson offerings whose duration fits the slot. Every booking requires an offering — a generic slot with no fitting offering cannot be booked online.
|
||||
1. Student opens the page with the `[us_booking]` shortcode and browses open slots as a weekly calendar (the default, anchored to the week of the earliest open slot) or an agenda list (view toggle with previous/next-week navigation; times shown in 12-hour AM/PM form). A **lesson-type filter** above the calendar narrows the open times to those bookable as the chosen types (see **Lesson-Type Filter**).
|
||||
2. Student picks a slot and an **offering** (a 30 or 60-minute private-lesson type). When the slot is tied to an offering the form shows it locked (the student sees exactly what they are booking); otherwise the form presents the instructor's active private-lesson offerings whose duration fits the slot, narrowed to the filtered types. When exactly one type remains it is pre-selected (its intake questions load immediately). Every booking requires an offering — a generic slot with no fitting offering cannot be booked online.
|
||||
3. For a `weekly` reservation, the same weekday/time is held for the rest of the offering's term.
|
||||
4. Student answers the offering's questions (`GET /offerings/{id}/questions`).
|
||||
5. Student accepts the current published policy versions (`GET /policies`) — required to continue.
|
||||
@@ -31,6 +31,28 @@ Students register for a private lesson by choosing an offering, picking a time (
|
||||
9. Instructor sees the booking under **My Lessons** and may update status via `PATCH /bookings/{id}/status`.
|
||||
10. The booking page also shows the student their upcoming lessons (`GET /bookings`) — each with the booked offering's name and length, when it happens, a per-lesson status badge (pending payment / confirmed), and a **Cancel** button. Only the soonest five are shown; a **Show all** control reveals the rest. `GET /bookings` includes `offering_title` and `duration_minutes` for each lesson so the list needs no extra request.
|
||||
|
||||
## Lesson-Type Filter
|
||||
Not every open slot can be booked as every private-lesson type — a slot tied to
|
||||
an offering takes that offering only, and a generic slot only takes types whose
|
||||
length fits. The booking calendar therefore carries a lesson-type filter above
|
||||
the view toggle: a checkbox per active private-lesson type (from
|
||||
`GET /offerings?kind=private_lesson`, fetched once per page load), showing the
|
||||
instructor's name alongside the title when the catalog spans more than one
|
||||
instructor. The filter is hidden when there is only one bookable type.
|
||||
|
||||
Ticking one or more types narrows the calendar to the slots bookable as one of
|
||||
them; no ticks means no filter. Picking a filtered slot narrows the registration
|
||||
form's **Lesson type** picker the same way, and when exactly one type remains it
|
||||
is pre-selected and its intake questions load immediately. Changing the filter
|
||||
re-anchors the week view on the earliest matching slot, so the student never
|
||||
lands on an empty week. **Show all types** clears the filter.
|
||||
|
||||
Bookability is decided client-side by `offeringFitsSlot()` in
|
||||
`assets/js/booking.js` — the mirror of the rule `POST /bookings` enforces (same
|
||||
instructor, the tied offering when there is one, otherwise a matching
|
||||
`duration_minutes`). The filter is a browsing aid only: the server re-checks
|
||||
every booking regardless.
|
||||
|
||||
## Cancellation
|
||||
Students cancel their own lessons via `POST /bookings/{id}/cancel` (idempotent).
|
||||
Cancelling marks the lesson `cancelled`, frees the availability slot for
|
||||
|
||||
Reference in New Issue
Block a user