diff --git a/CHANGELOG.md b/CHANGELOG.md index 52a2295..4c73a36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,8 @@ 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 **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 cdd288d..385f200 100644 --- a/assets/css/frontend.css +++ b/assets/css/frontend.css @@ -117,11 +117,30 @@ color: #8a6d1a; } -.us-type-filter { +.us-calendar-controls { display: flex; flex-wrap: wrap; + justify-content: space-between; 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; padding: 8px 12px; border: 1px solid #eee; @@ -129,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; @@ -150,7 +178,6 @@ .us-view-toggle { display: flex; gap: 8px; - margin-bottom: 12px; } .us-view-toggle button { 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 35e4ee2..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, @@ -85,8 +90,10 @@ let catalog = []; // 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(); + let filterOpen = false; // 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 @@ -135,14 +142,31 @@ 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 ` -
- - +
+
+ + +
+ ${filterToggleHtml()}
`; } + // 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 ` + `; + } + // "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) { @@ -154,10 +178,10 @@ 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. + // 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) return ''; + if (!filterEnabled || catalog.length < 2 || !filterOpen) return ''; const choices = catalog.map((o) => `