Collapse the lesson-type filter behind a Show Only button #120
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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` | `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` | `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="…"]`. |
|
||||
@@ -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
|
||||
`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
|
||||
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
|
||||
`[us_student_login booking_page_id="…"]`; auto-redirect is block-only.
|
||||
|
||||
|
||||
@@ -58,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
|
||||
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
|
||||
Students cancel their own lessons via `POST /bookings/{id}/cancel` (idempotent).
|
||||
Cancelling marks the lesson `cancelled`, frees the availability slot for
|
||||
@@ -120,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.
|
||||
|
||||
## 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
|
||||
|
||||
## Implementation
|
||||
|
||||
+45
-2
@@ -15,7 +15,23 @@ namespace Unsupervised\Schedular;
|
||||
*/
|
||||
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 = [
|
||||
[
|
||||
'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(
|
||||
'<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' ) ),
|
||||
$lessons,
|
||||
$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 PM–4:30 PM', 'unsupervised-schedular' ),
|
||||
esc_html__( 'Confirmed', 'unsupervised-schedular' ),
|
||||
esc_html__( 'Cancel', 'unsupervised-schedular' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample group-class card.
|
||||
*
|
||||
|
||||
+19
-3
@@ -85,11 +85,23 @@ class BlockRegistrar {
|
||||
'us-scheduler/booking' => [
|
||||
'render' => [ $this, 'renderBooking' ],
|
||||
'attributes' => [
|
||||
'loginPageId' => [
|
||||
'loginPageId' => [
|
||||
'type' => 'number',
|
||||
'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' => [
|
||||
@@ -134,7 +146,11 @@ class BlockRegistrar {
|
||||
* @param array<string, mixed> $attributes Block attributes.
|
||||
*/
|
||||
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 ) );
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,11 +9,30 @@ use Unsupervised\Schedular\Val;
|
||||
|
||||
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.
|
||||
*
|
||||
* @param array<int|string, mixed> $atts Block attributes (`loginPageId`) or
|
||||
* shortcode attributes (`login_page_id`).
|
||||
* Supported attributes (block / shortcode form):
|
||||
* - `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 {
|
||||
if ( ! is_user_logged_in() ) {
|
||||
@@ -38,11 +57,42 @@ class BookingPage {
|
||||
wp_enqueue_style( '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();
|
||||
include USC_PLUGIN_DIR . 'templates/frontend/booking-page.php';
|
||||
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
|
||||
* one is configured (and still exists), otherwise the WordPress login
|
||||
|
||||
@@ -4,14 +4,23 @@ declare(strict_types=1);
|
||||
if (! defined('ABSPATH')) {
|
||||
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>
|
||||
<?php endif; ?>
|
||||
<?php if ($showBooking) : ?>
|
||||
<div id="us-slot-list">
|
||||
<p><?php esc_html_e('Loading available slots…', 'unsupervised-schedular'); ?></p>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div id="us-booking-error" style="display:none;" role="alert"></div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Unsupervised\Schedular\Tests\Unit;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use Unsupervised\Schedular\BlockPreview;
|
||||
use Unsupervised\Schedular\Booking\BookingPage;
|
||||
|
||||
class BlockPreviewTest extends TestCase
|
||||
{
|
||||
@@ -18,6 +19,25 @@ class BlockPreviewTest extends TestCase
|
||||
self::assertStringContainsString('class="us-slot"', $html);
|
||||
self::assertStringContainsString('class="us-book-btn" disabled', $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
|
||||
|
||||
@@ -124,7 +124,7 @@ class BlockRegistrarTest extends TestCase
|
||||
// The link-target and auto-redirect options must be declared
|
||||
// server-side or the block-renderer preview rejects them.
|
||||
self::assertSame(
|
||||
['loginPageId', 'autoRedirect'],
|
||||
['loginPageId', 'autoRedirect', 'lessonTypeId', 'showTypeFilter', 'displayMode'],
|
||||
array_keys($registered['us-scheduler/booking']['attributes'])
|
||||
);
|
||||
self::assertSame(
|
||||
|
||||
@@ -17,6 +17,26 @@ class BookingPageTest extends TestCase
|
||||
$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
|
||||
{
|
||||
Functions\when('is_user_logged_in')->justReturn(false);
|
||||
@@ -59,6 +79,85 @@ class BookingPageTest extends TestCase
|
||||
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
|
||||
{
|
||||
// The chosen page was deleted: get_permalink() returns false for it
|
||||
|
||||
Reference in New Issue
Block a user