From 264d9cba01ceb8b3a57505cd80c6ec59e3a94ce8 Mon Sep 17 00:00:00 2001 From: James Griffin Date: Tue, 28 Jul 2026 13:16:18 -0300 Subject: [PATCH] Add per-embed lesson-type and section options to the booking block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three sidebar options on the Lesson Booking block, all mirrored as shortcode attributes and carried to the front end as data attributes on #us-booking-app (or as omitted containers): - Lesson type (lessonTypeId / lesson_type) pins the calendar to a single private-lesson type: only the times bookable as it are listed, and it is the only type bookable there, auto-selected on the registration form. A pinned type that is no longer offered says so instead of showing an empty calendar. - Show the lesson-type filter (showTypeFilter / show_filter) drops the "Show Only" control for studios that do not want it. - Sections (displayMode / show) embeds one half of the page — the booking calendar or the student's upcoming lessons — so the two can live on different pages. The script skips the work belonging to a missing half: no availability or catalog request for an upcoming-only embed, no bookings request for a booking-only one. An unrecognised value renders the whole page. The editor preview follows the same setting. Also fixes the expanded filter's first lesson type sharing a line with the "Lesson type" heading — the choices now sit in their own row beneath it. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + assets/css/frontend.css | 13 ++- assets/js/blocks.js | 111 +++++++++++++++++++++---- assets/js/booking.js | 49 +++++++++-- docs/features/editor-blocks.md | 13 ++- docs/features/lesson-booking.md | 24 +++++- src/BlockPreview.php | 47 ++++++++++- src/BlockRegistrar.php | 22 ++++- src/Booking/BookingPage.php | 54 +++++++++++- templates/frontend/booking-page.php | 11 ++- tests/Unit/BlockPreviewTest.php | 20 +++++ tests/Unit/BlockRegistrarTest.php | 2 +- tests/Unit/Booking/BookingPageTest.php | 99 ++++++++++++++++++++++ 13 files changed, 426 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6512a8..4c73a36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ each change under the current top section as you work. ## [1.2.2] ### Added +- 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 **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. diff --git a/assets/css/frontend.css b/assets/css/frontend.css index 973b351..385f200 100644 --- a/assets/css/frontend.css +++ b/assets/css/frontend.css @@ -141,10 +141,6 @@ } .us-type-filter { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px 16px; margin-bottom: 12px; padding: 8px 12px; border: 1px solid #eee; @@ -152,9 +148,18 @@ } .us-type-filter-heading { + display: block; + margin-bottom: 6px; font-weight: 600; } +.us-type-filter-choices { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 16px; +} + .us-type-filter-choice { display: inline-flex; align-items: center; diff --git a/assets/js/blocks.js b/assets/js/blocks.js index 7c24482..7c55aaf 100644 --- a/assets/js/blocks.js +++ b/assets/js/blocks.js @@ -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 = [ { name: 'us-scheduler/booking', @@ -94,24 +135,60 @@ attributes: { loginPageId: { type: 'number', default: 0 }, autoRedirect: { type: 'boolean', default: false }, + lessonTypeId: { type: 'number', default: 0 }, + showTypeFilter: { type: 'boolean', default: true }, + displayMode: { type: 'string', default: 'both' }, }, - inspector: (attributes, setAttributes) => el( - PanelBody, - { title: __('Logged-out visitors', 'unsupervised-schedular') }, - 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 }), - }) - ), + inspector: (attributes, setAttributes) => [ + el( + PanelBody, + { title: __('What to show', 'unsupervised-schedular'), key: 'display' }, + el(SelectControl, { + label: __('Sections', 'unsupervised-schedular'), + help: __('Split the page in two: a booking calendar here, the student’s upcoming lessons somewhere else.', 'unsupervised-schedular'), + value: attributes.displayMode || 'both', + options: [ + { label: __('Booking and upcoming lessons', 'unsupervised-schedular'), value: 'both' }, + { label: __('Booking only', 'unsupervised-schedular'), value: 'booking' }, + { label: __('Upcoming lessons only', 'unsupervised-schedular'), value: 'upcoming' }, + ], + onChange: (displayMode) => setAttributes({ displayMode }), + }) + ), + 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', diff --git a/assets/js/booking.js b/assets/js/booking.js index 6c23612..4a8fa2b 100644 --- a/assets/js/booking.js +++ b/assets/js/booking.js @@ -11,6 +11,11 @@ const errorBox = document.getElementById('us-booking-error'); const { restUrl, nonce } = usScheduler; + // Per-instance options from the block/shortcode: pin the page to a single + // lesson type, and whether the "Show Only" filter is offered at all. + const pinnedTypeId = Number(app.dataset.lessonType) || 0; + const filterEnabled = app.dataset.typeFilter !== '0'; + function apiFetch(path, options = {}) { return fetch(restUrl + path, { ...options, @@ -153,7 +158,7 @@ // 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 ''; + if (!filterEnabled || catalog.length < 2) return ''; const count = filterActive() ? ` (${selectedTypeIds.size})` : ''; @@ -176,7 +181,7 @@ // The lesson-type list itself — collapsed until the student opens it, and // rendered between the control row and the calendar. function filterHtml() { - if (catalog.length < 2 || !filterOpen) return ''; + if (!filterEnabled || catalog.length < 2 || !filterOpen) return ''; const choices = catalog.map((o) => `