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
14 changed files with 516 additions and 72 deletions
+2 -1
View File
@@ -14,7 +14,8 @@ 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 **Lesson Booking** block gained three embedding options in its sidebar. **Lesson type** pins the block to a single private-lesson type — only the times bookable as that type are listed and it is the only thing bookable there, auto-selected on the registration form — so a page about one lesson type can carry its own calendar. **Show the lesson-type filter** turns the **Show Only** control on or off. **Sections** embeds just one half of the page: booking calendar only, or the student's upcoming lessons only, so the two can live on different pages. All three are available to the shortcode as `[us_booking lesson_type="…" show_filter="no" show="booking|upcoming"]`, and the block's editor preview follows the chosen sections.
- 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.
+30 -3
View File
@@ -117,11 +117,30 @@
color: #8a6d1a; color: #8a6d1a;
} }
.us-type-filter { .us-calendar-controls {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: space-between;
align-items: center; align-items: center;
gap: 8px 16px; 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 {
margin-bottom: 12px; margin-bottom: 12px;
padding: 8px 12px; padding: 8px 12px;
border: 1px solid #eee; border: 1px solid #eee;
@@ -129,9 +148,18 @@
} }
.us-type-filter-heading { .us-type-filter-heading {
display: block;
margin-bottom: 6px;
font-weight: 600; font-weight: 600;
} }
.us-type-filter-choices {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 16px;
}
.us-type-filter-choice { .us-type-filter-choice {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -150,7 +178,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 {
+94 -17
View File
@@ -83,6 +83,47 @@
}); });
} }
/**
* Dropdown of active private-lesson types fetched from the plugin's public
* offerings endpoint. Values are offering IDs; 0 means every type.
*/
function LessonTypeSelect(props) {
const [offerings, setOfferings] = useState(null);
useEffect(() => {
apiFetch({ path: '/us-scheduler/v1/offerings?kind=private_lesson' })
.then(setOfferings)
.catch(() => setOfferings([]));
}, []);
const options = [{ label: __('All lesson types', 'unsupervised-schedular'), value: '0' }].concat(
(offerings || []).map((o) => ({
label: o.duration_minutes
? `${o.title} (${o.duration_minutes} min)`
: (o.title || __('(no title)', 'unsupervised-schedular')),
value: String(o.id),
}))
);
// A previously chosen type that is no longer offered keeps its stored
// id visible instead of silently pretending "All lesson types" is set.
const value = String(props.value || 0);
if (offerings !== null && !options.some((opt) => opt.value === value)) {
options.push({
label: __('Unavailable lesson type #', 'unsupervised-schedular') + value,
value: value,
});
}
return el(SelectControl, {
label: props.label,
help: props.help,
value: value,
options: options,
onChange: (newValue) => props.onChange(parseInt(newValue, 10) || 0),
});
}
const blocks = [ const blocks = [
{ {
name: 'us-scheduler/booking', name: 'us-scheduler/booking',
@@ -94,24 +135,60 @@
attributes: { attributes: {
loginPageId: { type: 'number', default: 0 }, loginPageId: { type: 'number', default: 0 },
autoRedirect: { type: 'boolean', default: false }, autoRedirect: { type: 'boolean', default: false },
lessonTypeId: { type: 'number', default: 0 },
showTypeFilter: { type: 'boolean', default: true },
displayMode: { type: 'string', default: 'both' },
}, },
inspector: (attributes, setAttributes) => el( inspector: (attributes, setAttributes) => [
PanelBody, el(
{ title: __('Logged-out visitors', 'unsupervised-schedular') }, PanelBody,
el(PageSelect, { { title: __('What to show', 'unsupervised-schedular'), key: 'display' },
label: __('Login page', 'unsupervised-schedular'), el(SelectControl, {
help: __('Where the log-in link sends visitors who are not logged in.', 'unsupervised-schedular'), label: __('Sections', 'unsupervised-schedular'),
defaultLabel: __('WordPress login screen', 'unsupervised-schedular'), help: __('Split the page in two: a booking calendar here, the students upcoming lessons somewhere else.', 'unsupervised-schedular'),
value: attributes.loginPageId, value: attributes.displayMode || 'both',
onChange: (loginPageId) => setAttributes({ loginPageId }), options: [
}), { label: __('Booking and upcoming lessons', 'unsupervised-schedular'), value: 'both' },
el(ToggleControl, { { label: __('Booking only', 'unsupervised-schedular'), value: 'booking' },
label: __('Redirect automatically', 'unsupervised-schedular'), { label: __('Upcoming lessons only', 'unsupervised-schedular'), value: 'upcoming' },
help: __('Send logged-out visitors straight to the login page instead of showing a link.', 'unsupervised-schedular'), ],
checked: !!attributes.autoRedirect, onChange: (displayMode) => setAttributes({ displayMode }),
onChange: (autoRedirect) => setAttributes({ autoRedirect }), })
}) ),
), el(
PanelBody,
{ title: __('Lesson types', 'unsupervised-schedular'), key: 'lesson-types' },
el(LessonTypeSelect, {
label: __('Lesson type', 'unsupervised-schedular'),
help: __('Show only the times bookable as one lesson type, for embedding on a page dedicated to it. That type is then the only one students can book here.', 'unsupervised-schedular'),
value: attributes.lessonTypeId,
onChange: (lessonTypeId) => setAttributes({ lessonTypeId }),
}),
el(ToggleControl, {
label: __('Show the lesson-type filter', 'unsupervised-schedular'),
help: __('Offer students the “Show Only” button that narrows the calendar to chosen lesson types. Not used when a single lesson type is set above.', 'unsupervised-schedular'),
checked: attributes.showTypeFilter !== false,
onChange: (showTypeFilter) => setAttributes({ showTypeFilter }),
})
),
el(
PanelBody,
{ title: __('Logged-out visitors', 'unsupervised-schedular'), key: 'logged-out' },
el(PageSelect, {
label: __('Login page', 'unsupervised-schedular'),
help: __('Where the log-in link sends visitors who are not logged in.', 'unsupervised-schedular'),
defaultLabel: __('WordPress login screen', 'unsupervised-schedular'),
value: attributes.loginPageId,
onChange: (loginPageId) => setAttributes({ loginPageId }),
}),
el(ToggleControl, {
label: __('Redirect automatically', 'unsupervised-schedular'),
help: __('Send logged-out visitors straight to the login page instead of showing a link.', 'unsupervised-schedular'),
checked: !!attributes.autoRedirect,
onChange: (autoRedirect) => setAttributes({ autoRedirect }),
})
),
],
}, },
{ {
name: 'us-scheduler/student-login', name: 'us-scheduler/student-login',
+94 -30
View File
@@ -11,6 +11,11 @@
const errorBox = document.getElementById('us-booking-error'); const errorBox = document.getElementById('us-booking-error');
const { restUrl, nonce } = usScheduler; 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 = {}) { function apiFetch(path, options = {}) {
return fetch(restUrl + path, { return fetch(restUrl + path, {
...options, ...options,
@@ -85,8 +90,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 +142,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 (!filterEnabled || 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 +178,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 (!filterEnabled || 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,10 +191,12 @@
`).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} <div class="us-type-filter-choices">
${filterActive() ? '<button type="button" id="us-type-filter-clear" class="us-type-filter-clear">Show all types</button>' : ''} ${choices}
${filterActive() ? '<button type="button" id="us-type-filter-clear" class="us-type-filter-clear">Show all types</button>' : ''}
</div>
</div>`; </div>`;
} }
@@ -221,11 +247,26 @@
function render() { function render() {
const slots = visibleSlots(); 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 = '<p>This lesson type is not available for booking right now.</p>';
return;
}
// 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 const message = pinnedTypeId
? '<p>No open times match the selected lesson types.</p>' ? '<p>No open times for this lesson type right now.</p>'
: '<p>No available lesson slots at this time.</p>'); : '<p>No open times match the selected lesson types.</p>';
wireFilterEvents();
slotList.innerHTML = controlsHtml() + filterHtml() + message;
wireControlEvents();
return; return;
} }
@@ -233,12 +274,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 +323,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(); });
@@ -565,19 +614,34 @@
// The private-lesson catalog drives both the filter and the registration // The private-lesson catalog drives both the filter and the registration
// form's lesson-type picker, and it does not change while the student // form's lesson-type picker, and it does not change while the student
// browses — so it is fetched once and kept. // browses — so it is fetched once and kept.
let catalogLoaded = false;
function loadCatalog() { function loadCatalog() {
if (catalog.length) return Promise.resolve(catalog); if (catalogLoaded) return Promise.resolve(catalog);
return apiFetch('offerings?kind=private_lesson').then((list) => { return apiFetch('offerings?kind=private_lesson').then((list) => {
catalog = 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; return catalog;
}); });
} }
function loadSlots() { function loadSlots() {
clearError(); clearError();
loadMyLessons();
// An upcoming-lessons-only embed has no calendar to fill.
if (!slotList) return;
slotList.style.display = 'block'; slotList.style.display = 'block';
confirm.style.display = 'none'; confirm.style.display = 'none';
loadMyLessons();
Promise.all([apiFetch('availability'), loadCatalog()]) Promise.all([apiFetch('availability'), loadCatalog()])
.then(([slots]) => { .then(([slots]) => {
allSlots = slots; allSlots = slots;
+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 |
+12 -1
View File
@@ -27,6 +27,9 @@ Four blocks have sidebar (inspector) options:
|---|---|---|---| |---|---|---|---|
| `us-scheduler/booking` | `loginPageId` (number) | `0` | Page the "log in to book a lesson" link points to for logged-out visitors. `0` = the WordPress login screen (with a redirect back to the current page). | | `us-scheduler/booking` | `loginPageId` (number) | `0` | Page the "log in to book a lesson" link points to for logged-out visitors. `0` = the WordPress login screen (with a redirect back to the current page). |
| `us-scheduler/booking` | `autoRedirect` (boolean) | `false` | Send logged-out visitors straight to the login page instead of showing the link. | | `us-scheduler/booking` | `autoRedirect` (boolean) | `false` | Send logged-out visitors straight to the login page instead of showing the link. |
| `us-scheduler/booking` | `lessonTypeId` (number) | `0` | Pin the calendar to a single private-lesson type: only the times bookable as that type are listed, and it is the only type students can book here (auto-selected on the registration form). `0` = every type. Shortcode equivalent: `[us_booking lesson_type="…"]`. |
| `us-scheduler/booking` | `showTypeFilter` (boolean) | `true` | Whether students get the **Show Only** button that narrows the calendar to chosen lesson types. Unused when a single type is pinned (there is nothing to choose). Shortcode equivalent: `[us_booking show_filter="no"]`. |
| `us-scheduler/booking` | `displayMode` (string) | `both` | Which halves of the page to embed: `both`, `booking` (calendar only, no upcoming-lessons panel) or `upcoming` (the student's lessons only, nothing bookable) — so the two halves can live on different pages. Anything unrecognised falls back to `both`. Shortcode equivalent: `[us_booking show="booking"]`. |
| `us-scheduler/student-login` | `bookingPageId` (number) | `0` | Page the "View available lessons" link points to for logged-in visitors, and the post-login redirect target. `0` = the current page. | | `us-scheduler/student-login` | `bookingPageId` (number) | `0` | Page the "View available lessons" link points to for logged-in visitors, and the post-login redirect target. `0` = the current page. |
| `us-scheduler/student-login` | `autoRedirect` (boolean) | `false` | Send logged-in visitors straight to the booking page instead of showing the link. Does nothing until a booking page is chosen. | | `us-scheduler/student-login` | `autoRedirect` (boolean) | `false` | Send logged-in visitors straight to the booking page instead of showing the link. Does nothing until a booking page is chosen. |
| `us-scheduler/student-register` | `loginPageId` (number) | `0` | Page students continue to once registration finishes — the "Sign in to your account" link after they confirm their email, and the "Continue to your account" link an invited student gets on the spot. `0` = the WordPress login screen for the confirmation link, and no link at all for the (already signed-in) invited student. Shortcode equivalent: `[us_student_register login_page_id="…"]`. | | `us-scheduler/student-register` | `loginPageId` (number) | `0` | Page students continue to once registration finishes — the "Sign in to your account" link after they confirm their email, and the "Continue to your account" link an invited student gets on the spot. `0` = the WordPress login screen for the confirmation link, and no link at all for the (already signed-in) invited student. Shortcode equivalent: `[us_student_register login_page_id="…"]`. |
@@ -38,7 +41,15 @@ the blocks fall back to their defaults. The group-classes block's class
select is a dropdown of active group classes fetched from select is a dropdown of active group classes fetched from
`GET /us-scheduler/v1/offerings?kind=group_class`; a stored class that is no `GET /us-scheduler/v1/offerings?kind=group_class`; a stored class that is no
longer offered shows as "Unavailable class #N" rather than silently falling longer offered shows as "Unavailable class #N" rather than silently falling
back to all classes. The link targets are also available back to all classes. The booking block's lesson-type select works the same way
against `?kind=private_lesson` ("Unavailable lesson type #N"), and the live
page says so plainly when the pinned type has been withdrawn.
The booking block's options reach the front end as data attributes on
`#us-booking-app` (`data-lesson-type`, `data-type-filter`) or as omitted
containers (`displayMode`), which `assets/js/booking.js` reads on load — see
`lesson-booking.md`. Its editor preview follows `displayMode`, showing the
calendar, the upcoming-lessons panel, or both. The link targets are also available
to the shortcodes as `[us_booking login_page_id="…"]` and to the shortcodes as `[us_booking login_page_id="…"]` and
`[us_student_login booking_page_id="…"]`; auto-redirect is block-only. `[us_student_login booking_page_id="…"]`; auto-redirect is block-only.
+37 -10
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
@@ -53,6 +58,28 @@ instructor, the tied offering when there is one, otherwise a matching
`duration_minutes`). The filter is a browsing aid only: the server re-checks `duration_minutes`). The filter is a browsing aid only: the server re-checks
every booking regardless. every booking regardless.
Two block/shortcode options change what the filter has to work with (see
`editor-blocks.md`), passed to the script as data attributes on
`#us-booking-app`:
- **A pinned lesson type** (`data-lesson-type`) narrows the catalog to that one
offering, so the page lists only the times bookable as it and books nothing
else — the filter control hides itself, there being one type left. A pinned
type that is no longer offered shows "This lesson type is not available for
booking right now" rather than an empty calendar.
- **Filter off** (`data-type-filter="0"`) drops the **Show Only** button
entirely; every open time is listed, as before the filter existed.
## Embedding Halves of the Page
The page has two halves — the booking calendar and the student's upcoming
lessons — and the block/shortcode can embed either on its own (`displayMode` /
`show`: `both` (default), `booking`, `upcoming`). The template simply omits the
containers of the half that is not wanted, and the script skips the work that
belongs to a missing container: an upcoming-only embed never requests
availability or the offering catalog, and a booking-only embed never requests
`GET /bookings`. An unrecognised value renders the whole page, so a typo cannot
silently hide half of it.
## Cancellation ## Cancellation
Students cancel their own lessons via `POST /bookings/{id}/cancel` (idempotent). Students cancel their own lessons via `POST /bookings/{id}/cancel` (idempotent).
Cancelling marks the lesson `cancelled`, frees the availability slot for Cancelling marks the lesson `cancelled`, frees the availability slot for
@@ -115,7 +142,7 @@ acceptance time and IP), and their intake-question answers. On **My Lessons** an
instructor may only open their own lessons; the studio **Scheduler** may open any. instructor may only open their own lessons; the studio **Scheduler** may open any.
## Frontend Shortcodes ## Frontend Shortcodes
- `[us_booking]` — student calendar + registration flow; requires `book_lesson` capability - `[us_booking]` — student calendar + registration flow; requires `book_lesson` capability. Attributes: `login_page_id`, `lesson_type` (pin one private-lesson offering), `show_filter` (`no` hides the **Show Only** filter), `show` (`both` / `booking` / `upcoming`)
- `[us_student_login]` — front-end login form for students - `[us_student_login]` — front-end login form for students
## Implementation ## Implementation
+45 -2
View File
@@ -15,7 +15,23 @@ namespace Unsupervised\Schedular;
*/ */
class BlockPreview { class BlockPreview {
public static function booking(): string { /**
* Sample booking page.
*
* @param string $mode Which halves the block embeds — one of
* {@see Booking\BookingPage::MODE_BOTH},
* `MODE_BOOKING` or `MODE_UPCOMING`. The preview shows
* the same sections the published page would.
*/
public static function booking( string $mode = Booking\BookingPage::MODE_BOTH ): string {
if ( Booking\BookingPage::MODE_UPCOMING === $mode ) {
return sprintf(
'<div id="us-booking-app">%s<div id="us-my-lessons">%s</div></div>',
self::note( __( 'Editor preview — students see their own lessons on the published page.', 'unsupervised-schedular' ) ),
self::upcomingLessons()
);
}
$days = [ $days = [
[ [
'label' => __( 'Monday', 'unsupervised-schedular' ), 'label' => __( 'Monday', 'unsupervised-schedular' ),
@@ -51,13 +67,40 @@ class BlockPreview {
); );
} }
$lessons = Booking\BookingPage::MODE_BOOKING === $mode
? ''
: sprintf( '<div id="us-my-lessons">%s</div>', self::upcomingLessons() );
return sprintf( return sprintf(
'<div id="us-booking-app">%s<div id="us-slot-list">%s</div></div>', '<div id="us-booking-app">%s%s<div id="us-slot-list">%s</div></div>',
self::note( __( 'Editor preview — students see live availability on the published page.', 'unsupervised-schedular' ) ), self::note( __( 'Editor preview — students see live availability on the published page.', 'unsupervised-schedular' ) ),
$lessons,
$dayHtml $dayHtml
); );
} }
/**
* Sample "your upcoming lessons" panel, shared by the booking preview's
* full and upcoming-only modes.
*/
private static function upcomingLessons(): string {
return sprintf(
'<div class="us-my-lessons"><h3>%s</h3>'
. '<div class="us-my-lesson"><span class="us-my-lesson-info">'
. '<strong class="us-my-lesson-title">%s <span class="us-my-lesson-duration">(30 min)</span></strong>'
. '<span class="us-my-lesson-when">%s</span></span>'
. '<span class="us-my-lesson-actions">'
. '<span class="us-lesson-status us-lesson-status-confirmed">%s</span>'
. '<button type="button" class="us-cancel-lesson" disabled>%s</button>'
. '</span></div></div>',
esc_html__( 'Your upcoming lessons', 'unsupervised-schedular' ),
esc_html__( 'Piano Lesson', 'unsupervised-schedular' ),
esc_html__( 'Monday · 4:00 PM4:30 PM', 'unsupervised-schedular' ),
esc_html__( 'Confirmed', 'unsupervised-schedular' ),
esc_html__( 'Cancel', 'unsupervised-schedular' )
);
}
/** /**
* Sample group-class card. * Sample group-class card.
* *
+19 -3
View File
@@ -85,11 +85,23 @@ class BlockRegistrar {
'us-scheduler/booking' => [ 'us-scheduler/booking' => [
'render' => [ $this, 'renderBooking' ], 'render' => [ $this, 'renderBooking' ],
'attributes' => [ 'attributes' => [
'loginPageId' => [ 'loginPageId' => [
'type' => 'number', 'type' => 'number',
'default' => 0, 'default' => 0,
], ],
'autoRedirect' => $redirectToggle, 'autoRedirect' => $redirectToggle,
'lessonTypeId' => [
'type' => 'number',
'default' => 0,
],
'showTypeFilter' => [
'type' => 'boolean',
'default' => true,
],
'displayMode' => [
'type' => 'string',
'default' => BookingPage::MODE_BOTH,
],
], ],
], ],
'us-scheduler/student-login' => [ 'us-scheduler/student-login' => [
@@ -134,7 +146,11 @@ class BlockRegistrar {
* @param array<string, mixed> $attributes Block attributes. * @param array<string, mixed> $attributes Block attributes.
*/ */
public function renderBooking( array $attributes = [] ): string { public function renderBooking( array $attributes = [] ): string {
return $this->isEditorPreview() ? BlockPreview::booking() : $this->bookingPage->render( $attributes ); if ( ! $this->isEditorPreview() ) {
return $this->bookingPage->render( $attributes );
}
return BlockPreview::booking( Val::string( $attributes['displayMode'] ?? BookingPage::MODE_BOTH ) );
} }
/** /**
+52 -2
View File
@@ -9,11 +9,30 @@ use Unsupervised\Schedular\Val;
class BookingPage { class BookingPage {
/** Booking calendar and the student's upcoming lessons (the default). */
public const MODE_BOTH = 'both';
/** Booking calendar only — no upcoming-lessons panel. */
public const MODE_BOOKING = 'booking';
/** The student's upcoming lessons only — nothing bookable. */
public const MODE_UPCOMING = 'upcoming';
/** /**
* Renders the booking shortcode/block output. * Renders the booking shortcode/block output.
* *
* @param array<int|string, mixed> $atts Block attributes (`loginPageId`) or * Supported attributes (block / shortcode form):
* shortcode attributes (`login_page_id`). * - `loginPageId` / `login_page_id` — where logged-out visitors are sent.
* - `lessonTypeId` / `lesson_type` — a private-lesson offering id that pins
* the calendar to one lesson type: only the times bookable as that type
* are listed, and only it can be booked. 0 or absent shows every type.
* - `showTypeFilter` / `show_filter` — whether the "Show Only" lesson-type
* filter is offered (default true; irrelevant when a type is pinned).
* - `displayMode` / `show` — which halves of the page to embed:
* {@see self::MODE_BOTH} (default), {@see self::MODE_BOOKING} (calendar
* only) or {@see self::MODE_UPCOMING} (the student's lessons only).
*
* @param array<int|string, mixed> $atts Block or shortcode attributes.
*/ */
public function render( array $atts ): string { public function render( array $atts ): string {
if ( ! is_user_logged_in() ) { if ( ! is_user_logged_in() ) {
@@ -38,11 +57,42 @@ class BookingPage {
wp_enqueue_style( 'us-scheduler' ); wp_enqueue_style( 'us-scheduler' );
wp_enqueue_script( 'us-scheduler' ); wp_enqueue_script( 'us-scheduler' );
$lessonTypeId = absint( Val::int( $atts['lessonTypeId'] ?? $atts['lesson_type'] ?? 0 ) );
$showTypeFilter = self::toBool( $atts['showTypeFilter'] ?? $atts['show_filter'] ?? true );
$mode = self::mode( $atts['displayMode'] ?? $atts['show'] ?? self::MODE_BOTH );
$showBooking = self::MODE_UPCOMING !== $mode;
$showUpcoming = self::MODE_BOOKING !== $mode;
ob_start(); ob_start();
include USC_PLUGIN_DIR . 'templates/frontend/booking-page.php'; include USC_PLUGIN_DIR . 'templates/frontend/booking-page.php';
return (string) ob_get_clean(); return (string) ob_get_clean();
} }
/**
* Normalises the display-mode attribute; anything unrecognised embeds the
* whole page, so a typo never silently hides half of it.
*/
private static function mode( mixed $value ): string {
$mode = strtolower( trim( Val::string( $value ) ) );
return in_array( $mode, [ self::MODE_BOOKING, self::MODE_UPCOMING ], true ) ? $mode : self::MODE_BOTH;
}
/**
* Reads a boolean attribute. Block attributes arrive as real booleans,
* shortcode attributes as strings — where the words people actually write
* for "off" ("no", "false", "off") are all truthy to PHP, so they are
* matched explicitly rather than cast.
*/
private static function toBool( mixed $value ): bool {
if ( is_string( $value ) ) {
return ! in_array( strtolower( trim( $value ) ), [ '', '0', 'no', 'false', 'off' ], true );
}
return Val::bool( $value );
}
/** /**
* URL the logged-out prompt sends visitors to: the chosen login page when * URL the logged-out prompt sends visitors to: the chosen login page when
* one is configured (and still exists), otherwise the WordPress login * one is configured (and still exists), otherwise the WordPress login
+10 -1
View File
@@ -4,14 +4,23 @@ declare(strict_types=1);
if (! defined('ABSPATH')) { if (! defined('ABSPATH')) {
exit; exit;
} }
/** @var int $lessonTypeId Offering id when the calendar is pinned to one lesson type; 0 for every type. */
/** @var bool $showTypeFilter Whether the "Show Only" lesson-type filter is offered. */
/** @var bool $showBooking Whether the booking calendar is part of this embed. */
/** @var bool $showUpcoming Whether the student's upcoming-lessons panel is part of this embed. */
?> ?>
<div id="us-booking-app" data-nonce="<?php echo esc_attr(wp_create_nonce('wp_rest')); ?>"> <div id="us-booking-app" data-nonce="<?php echo esc_attr(wp_create_nonce('wp_rest')); ?>"<?php echo $lessonTypeId > 0 ? ' data-lesson-type="' . esc_attr((string) $lessonTypeId) . '"' : ''; ?><?php echo $showTypeFilter ? '' : ' data-type-filter="0"'; ?>>
<?php if ($showUpcoming) : ?>
<div id="us-my-lessons"></div> <div id="us-my-lessons"></div>
<?php endif; ?>
<?php if ($showBooking) : ?>
<div id="us-slot-list"> <div id="us-slot-list">
<p><?php esc_html_e('Loading available slots…', 'unsupervised-schedular'); ?></p> <p><?php esc_html_e('Loading available slots…', 'unsupervised-schedular'); ?></p>
</div> </div>
<div id="us-booking-confirmation" style="display:none;"> <div id="us-booking-confirmation" style="display:none;">
<p><?php esc_html_e('Your lesson has been booked. The instructor will confirm shortly.', 'unsupervised-schedular'); ?></p> <p><?php esc_html_e('Your lesson has been booked. The instructor will confirm shortly.', 'unsupervised-schedular'); ?></p>
</div> </div>
<?php endif; ?>
<div id="us-booking-error" style="display:none;" role="alert"></div> <div id="us-booking-error" style="display:none;" role="alert"></div>
</div> </div>
+20
View File
@@ -5,6 +5,7 @@ namespace Unsupervised\Schedular\Tests\Unit;
use Brain\Monkey\Functions; use Brain\Monkey\Functions;
use Unsupervised\Schedular\BlockPreview; use Unsupervised\Schedular\BlockPreview;
use Unsupervised\Schedular\Booking\BookingPage;
class BlockPreviewTest extends TestCase class BlockPreviewTest extends TestCase
{ {
@@ -18,6 +19,25 @@ class BlockPreviewTest extends TestCase
self::assertStringContainsString('class="us-slot"', $html); self::assertStringContainsString('class="us-slot"', $html);
self::assertStringContainsString('class="us-book-btn" disabled', $html); self::assertStringContainsString('class="us-book-btn" disabled', $html);
self::assertStringContainsString('us-editor-note', $html); self::assertStringContainsString('us-editor-note', $html);
self::assertStringContainsString('id="us-my-lessons"', $html);
}
public function testBookingOnlyPreviewLeavesOutTheUpcomingLessons(): void
{
$html = BlockPreview::booking(BookingPage::MODE_BOOKING);
self::assertStringContainsString('id="us-slot-list"', $html);
self::assertStringNotContainsString('us-my-lessons', $html);
}
public function testUpcomingOnlyPreviewLeavesOutTheCalendar(): void
{
$html = BlockPreview::booking(BookingPage::MODE_UPCOMING);
self::assertStringContainsString('id="us-my-lessons"', $html);
self::assertStringContainsString('Your upcoming lessons', $html);
self::assertStringNotContainsString('us-slot-list', $html);
self::assertStringNotContainsString('us-book-btn', $html);
} }
public function testGroupClassesPreviewMirrorsTheLiveMarkup(): void public function testGroupClassesPreviewMirrorsTheLiveMarkup(): void
+1 -1
View File
@@ -124,7 +124,7 @@ class BlockRegistrarTest extends TestCase
// The link-target and auto-redirect options must be declared // The link-target and auto-redirect options must be declared
// server-side or the block-renderer preview rejects them. // server-side or the block-renderer preview rejects them.
self::assertSame( self::assertSame(
['loginPageId', 'autoRedirect'], ['loginPageId', 'autoRedirect', 'lessonTypeId', 'showTypeFilter', 'displayMode'],
array_keys($registered['us-scheduler/booking']['attributes']) array_keys($registered['us-scheduler/booking']['attributes'])
); );
self::assertSame( self::assertSame(
+99
View File
@@ -17,6 +17,26 @@ class BookingPageTest extends TestCase
$this->page = new BookingPage(); $this->page = new BookingPage();
} }
/**
* Renders the page as a logged-in, approved student — the path that
* includes the template and its data attributes.
*
* @param array<int|string, mixed> $atts
*/
private function renderForStudent(array $atts): string
{
Functions\when('is_user_logged_in')->justReturn(true);
Functions\when('get_current_user_id')->justReturn(3);
Functions\when('get_user_meta')->justReturn('');
Functions\when('current_user_can')->justReturn(true);
Functions\when('wp_enqueue_style')->justReturn(null);
Functions\when('wp_enqueue_script')->justReturn(null);
Functions\when('wp_create_nonce')->justReturn('nonce123');
Functions\when('absint')->alias(static fn ($value) => abs((int) $value));
return $this->page->render($atts);
}
public function testLoggedOutVisitorIsLinkedToTheWordPressLoginByDefault(): void public function testLoggedOutVisitorIsLinkedToTheWordPressLoginByDefault(): void
{ {
Functions\when('is_user_logged_in')->justReturn(false); Functions\when('is_user_logged_in')->justReturn(false);
@@ -59,6 +79,85 @@ class BookingPageTest extends TestCase
self::assertStringContainsString('href="https://example.com/login/"', $html); self::assertStringContainsString('href="https://example.com/login/"', $html);
} }
public function testDefaultEmbedShowsBothHalvesWithTheFilterAndNoPinnedType(): void
{
$html = $this->renderForStudent([]);
self::assertStringContainsString('id="us-booking-app"', $html);
self::assertStringContainsString('id="us-my-lessons"', $html);
self::assertStringContainsString('id="us-slot-list"', $html);
self::assertStringNotContainsString('data-lesson-type', $html);
self::assertStringNotContainsString('data-type-filter', $html);
}
public function testBlockLessonTypeAttributePinsASingleType(): void
{
self::assertStringContainsString(
'data-lesson-type="12"',
$this->renderForStudent(['lessonTypeId' => 12])
);
}
public function testShortcodeLessonTypeAttributePinsASingleType(): void
{
self::assertStringContainsString(
'data-lesson-type="7"',
$this->renderForStudent(['lesson_type' => '7'])
);
}
public function testGarbageLessonTypeAttributeIsIgnored(): void
{
self::assertStringNotContainsString(
'data-lesson-type',
$this->renderForStudent(['lesson_type' => 'banana'])
);
}
public function testFilterCanBeTurnedOffByBlockAndShortcodeAlike(): void
{
self::assertStringContainsString(
'data-type-filter="0"',
$this->renderForStudent(['showTypeFilter' => false])
);
// "no" is truthy to PHP, so the shortcode wording is matched explicitly.
self::assertStringContainsString(
'data-type-filter="0"',
$this->renderForStudent(['show_filter' => 'no'])
);
self::assertStringNotContainsString(
'data-type-filter',
$this->renderForStudent(['show_filter' => 'yes'])
);
}
public function testBookingOnlyEmbedLeavesOutTheUpcomingLessons(): void
{
$html = $this->renderForStudent(['displayMode' => 'booking']);
self::assertStringContainsString('id="us-slot-list"', $html);
self::assertStringNotContainsString('us-my-lessons', $html);
}
public function testUpcomingOnlyEmbedLeavesOutTheBookingCalendar(): void
{
$html = $this->renderForStudent(['show' => 'upcoming']);
self::assertStringContainsString('id="us-my-lessons"', $html);
self::assertStringNotContainsString('us-slot-list', $html);
self::assertStringNotContainsString('us-booking-confirmation', $html);
}
public function testUnknownDisplayModeShowsTheWholePage(): void
{
$html = $this->renderForStudent(['displayMode' => 'sideways']);
self::assertStringContainsString('id="us-my-lessons"', $html);
self::assertStringContainsString('id="us-slot-list"', $html);
}
public function testLoginUrlFallsBackToWordPressLoginWhenThePageIsGone(): void public function testLoginUrlFallsBackToWordPressLoginWhenThePageIsGone(): void
{ {
// The chosen page was deleted: get_permalink() returns false for it // The chosen page was deleted: get_permalink() returns false for it