diff --git a/assets/js/blocks.js b/assets/js/blocks.js index 4a0f096..7093251 100644 --- a/assets/js/blocks.js +++ b/assets/js/blocks.js @@ -3,10 +3,11 @@ 'use strict'; const { registerBlockType } = wp.blocks; - const { createElement: el } = wp.element; + const { createElement: el, useState, useEffect } = wp.element; const { useBlockProps, InspectorControls } = wp.blockEditor; const { PanelBody, SelectControl, ToggleControl } = wp.components; const { useSelect } = wp.data; + const apiFetch = wp.apiFetch; const ServerSideRender = wp.serverSideRender; const { __ } = wp.i18n; @@ -42,6 +43,46 @@ }); } + /** + * Dropdown of active group classes fetched from the plugin's public + * offerings endpoint. Values are offering IDs; 0 means all classes. + */ + function GroupClassSelect(props) { + const [offerings, setOfferings] = useState(null); + + useEffect(() => { + apiFetch({ path: '/us-scheduler/v1/offerings?kind=group_class' }) + .then(setOfferings) + .catch(() => setOfferings([])); + }, []); + + const options = [{ label: __('All classes', 'unsupervised-schedular'), value: '0' }].concat( + (offerings || []).map((o) => ({ + label: o.title || __('(no title)', 'unsupervised-schedular'), + value: String(o.id), + })) + ); + + // A previously chosen class that is no longer offered (deleted or + // deactivated) keeps its stored id visible instead of silently + // pretending "All classes" is selected. + const value = String(props.value || 0); + if (offerings !== null && !options.some((opt) => opt.value === value)) { + options.push({ + label: __('Unavailable class #', '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', @@ -116,6 +157,19 @@ icon: 'groups', keywords: ['group', 'class', 'enrol'], shortcode: 'us_group_classes', + attributes: { + offeringId: { type: 'number', default: 0 }, + }, + inspector: (attributes, setAttributes) => el( + PanelBody, + { title: __('Classes shown', 'unsupervised-schedular') }, + el(GroupClassSelect, { + label: __('Class', 'unsupervised-schedular'), + help: __('Show only one group class, for embedding on a page dedicated to it.', 'unsupervised-schedular'), + value: attributes.offeringId, + onChange: (offeringId) => setAttributes({ offeringId }), + }) + ), }, ]; diff --git a/assets/js/group-classes.js b/assets/js/group-classes.js index 375ddda..b28661d 100644 --- a/assets/js/group-classes.js +++ b/assets/js/group-classes.js @@ -10,6 +10,10 @@ const errorBox = document.getElementById('us-group-error'); const { restUrl, nonce } = usScheduler; + // When the shortcode/block pins a single offering, only that class is + // shown, so the page can be embedded alongside a full class description. + const singleOfferingId = Number(app.dataset.offering || 0); + function apiFetch(path, options = {}) { return fetch(restUrl + path, { ...options, @@ -68,16 +72,39 @@ `; } + // Parse a Y-m-d date into local time; new Date('Y-m-d') would parse as + // UTC midnight and can display as the previous day in western timezones. + function formatDate(ymd) { + const [y, m, d] = ymd.split('-').map(Number); + return new Date(y, m - 1, d).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); + } + + function termLabel(o) { + if (!o.term_start) return ''; + if (!o.term_end || o.term_end === o.term_start) { + return formatDate(o.term_start); + } + const weekMs = 7 * 24 * 60 * 60 * 1000; + const sessions = Math.round((new Date(o.term_end) - new Date(o.term_start)) / weekMs) + 1; + return `${formatDate(o.term_start)} – ${formatDate(o.term_end)} (${sessions} weekly sessions)`; + } + function renderClasses(offerings) { - const groups = offerings.filter((o) => o.kind === 'group_class'); + let groups = offerings.filter((o) => o.kind === 'group_class'); + if (singleOfferingId) { + groups = groups.filter((o) => Number(o.id) === singleOfferingId); + } if (!groups.length) { - list.innerHTML = '
No group classes are open for enrolment right now.
'; + list.innerHTML = singleOfferingId + ? 'This class is not open for enrolment right now.
' + : 'No group classes are open for enrolment right now.
'; return; } list.innerHTML = groups.map((o) => `${escHtml(termLabel(o))}
` : ''} ${o.schedule_note ? `${escHtml(o.schedule_note)}
` : ''} ${o.description ? `${escHtml(o.description)}
` : ''}${escHtml(Number(o.price).toFixed(2))} ${escHtml(o.currency)}
diff --git a/docs/features/editor-blocks.md b/docs/features/editor-blocks.md index a2af7ea..0f39d51 100644 --- a/docs/features/editor-blocks.md +++ b/docs/features/editor-blocks.md @@ -21,8 +21,7 @@ transform. ## Block options -Two blocks have sidebar (inspector) options controlling where their -logged-in/logged-out link sends the visitor: +Three blocks have sidebar (inspector) options: | Block | Attribute | Default | Effect | |---|---|---|---| @@ -30,9 +29,14 @@ logged-in/logged-out link sends the visitor: | `us-scheduler/booking` | `autoRedirect` (boolean) | `false` | Send logged-out visitors straight to the login page instead of showing the link. | | `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/group-classes` | `offeringId` (number) | `0` | Restrict the page to a single group class, for embedding on a page dedicated to that class. `0` = browse all classes. Shortcode equivalent: `[us_group_classes offering="…"]`. | The page selects list all published pages; if a chosen page is later deleted, -the blocks fall back to their defaults. The link targets are also available +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 to the shortcodes as `[us_booking login_page_id="…"]` and `[us_student_login booking_page_id="…"]`; auto-redirect is block-only. diff --git a/docs/features/group-classes.md b/docs/features/group-classes.md index fc60c49..def60c4 100644 --- a/docs/features/group-classes.md +++ b/docs/features/group-classes.md @@ -15,6 +15,12 @@ Students enrol in a group class — an offering of kind `group_class` — as a c | `payment_id` | BIGINT UNSIGNED | Nullable FK → `us_payments.id` | | `enrolled_at` | DATETIME | Insertion time | +## Class Dates +A group class offering carries `term_start`/`term_end` (see `offerings.md`): +one-off classes end the day they start; weekly classes run a set number of +sessions. The class card on the enrolment page shows the date or date range +with the session count. + ## Enrolment Flow 1. Student opens a group class from the offering catalog. 2. Student answers the offering's questions (`GET /offerings/{id}/questions`). @@ -50,7 +56,7 @@ instructor's group classes if the caller has `view_own_lessons` on those offerin - Model: `Unsupervised\Schedular\GroupClass\Enrollment` - Admin controller: `Unsupervised\Schedular\GroupClass\GroupClassController` (gated on `view_all_lessons`) - REST endpoint: `Unsupervised\Schedular\GroupClass\EnrollmentEndpoint` -- Frontend: `Unsupervised\Schedular\GroupClass\GroupClassPage` (`[us_group_classes]` shortcode) +- Frontend: `Unsupervised\Schedular\GroupClass\GroupClassPage` (`[us_group_classes]` shortcode; `offering="…"` restricts it to a single class for embedding on a dedicated page — the block equivalent is the `offeringId` attribute) - Reuses `Registration\RegistrationGate` (intake answers + booking-scoped policy acceptance, type `enrollment`) > **Payment seam:** payment is deferred to #7. An enrolment is created with @@ -62,3 +68,4 @@ instructor's group classes if the caller has `view_own_lessons` on those offerin ## Tests - `tests/Unit/GroupClass/EnrollmentTest.php` - `tests/Unit/GroupClass/EnrollmentRepositoryTest.php` +- `tests/Unit/GroupClass/GroupClassPageTest.php` diff --git a/docs/features/offerings.md b/docs/features/offerings.md index be2770e..6a03f7d 100644 --- a/docs/features/offerings.md +++ b/docs/features/offerings.md @@ -28,11 +28,24 @@ An offering is anything a student can register for: a private-lesson type (30 or - `one_time` — charged once at booking (a single private lesson). - `full_term` — charged in full upfront at registration (a weekly private reservation or a year-long group class). See `payments.md`. +## Term Dates +Group classes carry a term: `term_start` is the date of the first class and +`term_end` the last. The add-offering form takes a start date plus a sessions +control — **one-off** (the term ends the day it starts) or **weekly for N +sessions** (`term_end = term_start + (N−1) weeks`, computed by +`Offering::weeklyTermEnd()`). Dates are validated by `Offering::normalizeDate()` +(strict `Y-m-d`); an invalid start date leaves both term columns NULL. The +student-facing class card shows the date (one-off) or the date range with the +weekly session count. + ## Admin Interface Studio admin and instructors manage offerings under **Offerings** in wp-admin. - Studio admin (`manage_offerings`) manages offerings for any instructor. - Instructor (`manage_offerings`) manages only their own. - Each offering's intake questions are edited from the offering screen (see `registration-questions.md`). +- The offerings list shows each offering's ID (needed for `[us_group_classes offering="…"]`) and its term dates. +- **Edit** on a row reloads the page (`?usc_edit=