Add per-embed lesson-type and section options to the booking block
CI / Tests (PHP 8.1) (pull_request) Successful in 47s
CI / Tests (PHP 8.2) (pull_request) Successful in 54s
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 2m51s
CI / Coding Standards (pull_request) Successful in 2m56s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m42s
CI / Build Plugin Zip (pull_request) Skipped
CI / Tests (PHP 8.1) (pull_request) Successful in 47s
CI / Tests (PHP 8.2) (pull_request) Successful in 54s
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 2m51s
CI / Coding Standards (pull_request) Successful in 2m56s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m42s
CI / Build Plugin Zip (pull_request) Skipped
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 <[email protected]>
This commit is contained in:
@@ -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;
|
||||
|
||||
+94
-17
@@ -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',
|
||||
|
||||
+41
-8
@@ -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) => `
|
||||
<label class="us-type-filter-choice">
|
||||
@@ -188,8 +193,10 @@
|
||||
return `
|
||||
<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>
|
||||
${choices}
|
||||
${filterActive() ? '<button type="button" id="us-type-filter-clear" class="us-type-filter-clear">Show all types</button>' : ''}
|
||||
<div class="us-type-filter-choices">
|
||||
${choices}
|
||||
${filterActive() ? '<button type="button" id="us-type-filter-clear" class="us-type-filter-clear">Show all types</button>' : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -240,6 +247,13 @@
|
||||
function render() {
|
||||
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>';
|
||||
@@ -247,7 +261,11 @@
|
||||
}
|
||||
|
||||
if (!slots.length) {
|
||||
slotList.innerHTML = controlsHtml() + filterHtml() + '<p>No open times match the selected lesson types.</p>';
|
||||
const message = pinnedTypeId
|
||||
? '<p>No open times for this lesson type right now.</p>'
|
||||
: '<p>No open times match the selected lesson types.</p>';
|
||||
|
||||
slotList.innerHTML = controlsHtml() + filterHtml() + message;
|
||||
wireControlEvents();
|
||||
return;
|
||||
}
|
||||
@@ -596,19 +614,34 @@
|
||||
// The private-lesson catalog drives both the filter and the registration
|
||||
// form's lesson-type picker, and it does not change while the student
|
||||
// browses — so it is fetched once and kept.
|
||||
let catalogLoaded = false;
|
||||
|
||||
function loadCatalog() {
|
||||
if (catalog.length) return Promise.resolve(catalog);
|
||||
if (catalogLoaded) return Promise.resolve(catalog);
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
function loadSlots() {
|
||||
clearError();
|
||||
loadMyLessons();
|
||||
|
||||
// An upcoming-lessons-only embed has no calendar to fill.
|
||||
if (!slotList) return;
|
||||
|
||||
slotList.style.display = 'block';
|
||||
confirm.style.display = 'none';
|
||||
loadMyLessons();
|
||||
Promise.all([apiFetch('availability'), loadCatalog()])
|
||||
.then(([slots]) => {
|
||||
allSlots = slots;
|
||||
|
||||
Reference in New Issue
Block a user