Collapse the lesson-type filter behind a Show Only button #120

Merged
thatguygriff merged 2 commits from feature/lesson-type-filter-collapsed into main 2026-07-28 16:20:25 +00:00
5 changed files with 95 additions and 37 deletions
Showing only changes of commit 9d11cc3b01 - Show all commits
+1 -1
View File
@@ -14,7 +14,7 @@ each change under the current top section as you work.
## [1.2.2] ## [1.2.2]
### Added ### 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 booking calendar now has a **Show Only** button beside the List/Week toggle that opens a lesson-type filter, 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. The type list starts collapsed and can be tucked away again without losing the filter; the button shows how many types are ticked. 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 **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. - 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.
+23 -1
View File
@@ -117,6 +117,29 @@
color: #8a6d1a; color: #8a6d1a;
} }
.us-calendar-controls {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.us-filter-toggle {
padding: 6px 16px;
border: 1px solid #ccc;
border-radius: 4px;
background: transparent;
cursor: pointer;
}
.us-filter-toggle.us-active {
background: #333;
border-color: #333;
color: #fff;
}
.us-type-filter { .us-type-filter {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -150,7 +173,6 @@
.us-view-toggle { .us-view-toggle {
display: flex; display: flex;
gap: 8px; gap: 8px;
margin-bottom: 12px;
} }
.us-view-toggle button { .us-view-toggle button {
+56 -25
View File
@@ -85,8 +85,10 @@
let catalog = []; let catalog = [];
// Lesson types the student has filtered the calendar down to; empty means // Lesson types the student has filtered the calendar down to; empty means
// "no filter" — every open slot is shown. // "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(); const selectedTypeIds = new Set();
let filterOpen = false;
// Whether an offering can be booked into a slot — the client-side mirror of // 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 // the rule `POST /bookings` enforces: a slot tied to an offering takes that
@@ -135,14 +137,31 @@
return addDays(key, -((date.getDay() - startOfWeek + 7) % 7)); return addDays(key, -((date.getDay() - startOfWeek + 7) % 7));
} }
function toggleHtml() { // The calendar's control row: the view toggle, and the button that reveals
// the lesson-type filter beneath it.
function controlsHtml() {
return ` return `
<div class="us-view-toggle" role="group" aria-label="Calendar view"> <div class="us-calendar-controls">
<button type="button" id="us-view-list" class="${view === 'list' ? 'us-active' : ''}">List</button> <div class="us-view-toggle" role="group" aria-label="Calendar view">
<button type="button" id="us-view-week" class="${view === 'week' ? 'us-active' : ''}">Week</button> <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>
${filterToggleHtml()}
</div>`; </div>`;
} }
// Nothing to filter with a single bookable type, so the control only
// appears once there is a choice to make.
function filterToggleHtml() {
if (catalog.length < 2) return '';
const count = filterActive() ? ` (${selectedTypeIds.size})` : '';
return `
<button type="button" id="us-filter-toggle" class="us-filter-toggle${filterActive() ? ' us-active' : ''}"
aria-expanded="${filterOpen}" aria-controls="us-type-filter">Show Only${count}</button>`;
}
// "Piano Lesson (30 min)" — the instructor's name is only worth the space // "Piano Lesson (30 min)" — the instructor's name is only worth the space
// when the catalog spans more than one of them. // when the catalog spans more than one of them.
function filterLabel(offering) { function filterLabel(offering) {
@@ -154,10 +173,10 @@
return `${offering.title}${duration}${who}`; return `${offering.title}${duration}${who}`;
} }
// Lesson-type filter. Pointless with a single bookable type, so it is only // The lesson-type list itself — collapsed until the student opens it, and
// rendered once there is a choice to make. // rendered between the control row and the calendar.
function filterHtml() { function filterHtml() {
if (catalog.length < 2) return ''; if (catalog.length < 2 || !filterOpen) return '';
const choices = catalog.map((o) => ` const choices = catalog.map((o) => `
<label class="us-type-filter-choice"> <label class="us-type-filter-choice">
@@ -167,7 +186,7 @@
`).join(''); `).join('');
return ` return `
<div class="us-type-filter" role="group" aria-label="Filter by lesson type"> <div class="us-type-filter" id="us-type-filter" role="group" aria-label="Filter by lesson type">
<span class="us-type-filter-heading">Lesson type</span> <span class="us-type-filter-heading">Lesson type</span>
${choices} ${choices}
${filterActive() ? '<button type="button" id="us-type-filter-clear" class="us-type-filter-clear">Show all types</button>' : ''} ${filterActive() ? '<button type="button" id="us-type-filter-clear" class="us-type-filter-clear">Show all types</button>' : ''}
@@ -221,11 +240,15 @@
function render() { function render() {
const slots = visibleSlots(); const slots = visibleSlots();
// Nothing open at all: there is nothing for the controls to act on.
if (!allSlots.length) {
slotList.innerHTML = '<p>No available lesson slots at this time.</p>';
return;
}
if (!slots.length) { if (!slots.length) {
slotList.innerHTML = filterHtml() + (allSlots.length slotList.innerHTML = controlsHtml() + filterHtml() + '<p>No open times match the selected lesson types.</p>';
? '<p>No open times match the selected lesson types.</p>' wireControlEvents();
: '<p>No available lesson slots at this time.</p>');
wireFilterEvents();
return; return;
} }
@@ -233,12 +256,29 @@
// API returns slots ordered by start), so the first look is never empty. // API returns slots ordered by start), so the first look is never empty.
if (view === 'week' && !weekStart) weekStart = weekStartOf(dayKey(slots[0].start_dt)); if (view === 'week' && !weekStart) weekStart = weekStartOf(dayKey(slots[0].start_dt));
slotList.innerHTML = filterHtml() + toggleHtml() + (view === 'week' ? weekHtml(slots) : listHtml(slots)); slotList.innerHTML = controlsHtml() + filterHtml() + (view === 'week' ? weekHtml(slots) : listHtml(slots));
wireFilterEvents(); wireControlEvents();
wireCalendarEvents(); wireCalendarEvents();
} }
function wireFilterEvents() { 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) => { slotList.querySelectorAll('.us-type-filter-option').forEach((input) => {
input.addEventListener('change', () => { input.addEventListener('change', () => {
const id = Number(input.value); const id = Number(input.value);
@@ -265,15 +305,6 @@
} }
function wireCalendarEvents() { function wireCalendarEvents() {
document.getElementById('us-view-list').addEventListener('click', () => {
view = 'list';
render();
});
document.getElementById('us-view-week').addEventListener('click', () => {
view = 'week';
render();
});
const prev = document.getElementById('us-week-prev'); const prev = document.getElementById('us-week-prev');
const next = document.getElementById('us-week-next'); const next = document.getElementById('us-week-next');
if (prev) prev.addEventListener('click', () => { weekStart = addDays(weekStart, -7); render(); }); if (prev) prev.addEventListener('click', () => { weekStart = addDays(weekStart, -7); render(); });
+1 -1
View File
@@ -50,7 +50,7 @@ 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 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). 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 Both views can be narrowed to the slots bookable as chosen private-lesson types
with the lesson-type filter — see `lesson-booking.md`. with the **Show Only** lesson-type filter — see `lesson-booking.md`.
## REST API ## REST API
| Method | Endpoint | Permission | | Method | Endpoint | Permission |
+14 -9
View File
@@ -20,7 +20,7 @@ Students register for a private lesson by choosing an offering, picking a time (
| `created_at` | DATETIME | Insertion time | | `created_at` | DATETIME | Insertion time |
## Registration Flow ## 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). A **lesson-type filter** above the calendar narrows the open times to those bookable as the chosen types (see **Lesson-Type Filter**). 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 **Show Only** button beside the view toggle opens a lesson-type filter that 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. 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. 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`). 4. Student answers the offering's questions (`GET /offerings/{id}/questions`).
@@ -34,18 +34,23 @@ Students register for a private lesson by choosing an offering, picking a time (
## Lesson-Type Filter ## Lesson-Type Filter
Not every open slot can be booked as every private-lesson type — a slot tied to 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 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 length fits. The booking calendar therefore carries a lesson-type filter,
the view toggle: a checkbox per active private-lesson type (from collapsed behind a **Show Only** button that sits in the calendar's control row
beside the List/Week toggle. Opening it reveals the type list between that row
and the calendar: a checkbox per active private-lesson type (from
`GET /offerings?kind=private_lesson`, fetched once per page load), showing the `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's name alongside the title when the catalog spans more than one
instructor. The filter is hidden when there is only one bookable type. instructor. The button carries the number of ticked types and stays highlighted
while the filter is on, so a collapsed filter is never invisible. Both button and
list are hidden when there is only one bookable type.
Ticking one or more types narrows the calendar to the slots bookable as one of 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 them; no ticks means no filter, and collapsing the list leaves the filter
form's **Lesson type** picker the same way, and when exactly one type remains it applied. Picking a filtered slot narrows the registration form's **Lesson type**
is pre-selected and its intake questions load immediately. Changing the filter picker the same way, and when exactly one type remains it is pre-selected and its
re-anchors the week view on the earliest matching slot, so the student never intake questions load immediately. Changing the filter re-anchors the week view
lands on an empty week. **Show all types** clears the filter. 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 Bookability is decided client-side by `offeringFitsSlot()` in
`assets/js/booking.js` — the mirror of the rule `POST /bookings` enforces (same `assets/js/booking.js` — the mirror of the rule `POST /bookings` enforces (same