Merge pull request 'Group class term dates, single-class embed mode, and offering editing' (#60) from feature/group-class-terms into main
CI / Tests (PHP 8.2) (push) Successful in 44s
CI / No Debug Code (push) Successful in 2s
CI / Tests (PHP 8.3) (push) Successful in 32s
CI / Build Plugin Zip (push) Successful in 1m42s
CI / Tests (PHP 8.1) (push) Successful in 43s
CI / PHPStan (push) Successful in 49s
CI / Coding Standards (push) Successful in 2m43s
CI / Tests (PHP 8.2) (push) Successful in 44s
CI / No Debug Code (push) Successful in 2s
CI / Tests (PHP 8.3) (push) Successful in 32s
CI / Build Plugin Zip (push) Successful in 1m42s
CI / Tests (PHP 8.1) (push) Successful in 43s
CI / PHPStan (push) Successful in 49s
CI / Coding Standards (push) Successful in 2m43s
Reviewed-on: #60
This commit was merged in pull request #60.
This commit is contained in:
+55
-1
@@ -3,10 +3,11 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const { registerBlockType } = wp.blocks;
|
const { registerBlockType } = wp.blocks;
|
||||||
const { createElement: el } = wp.element;
|
const { createElement: el, useState, useEffect } = wp.element;
|
||||||
const { useBlockProps, InspectorControls } = wp.blockEditor;
|
const { useBlockProps, InspectorControls } = wp.blockEditor;
|
||||||
const { PanelBody, SelectControl, ToggleControl } = wp.components;
|
const { PanelBody, SelectControl, ToggleControl } = wp.components;
|
||||||
const { useSelect } = wp.data;
|
const { useSelect } = wp.data;
|
||||||
|
const apiFetch = wp.apiFetch;
|
||||||
const ServerSideRender = wp.serverSideRender;
|
const ServerSideRender = wp.serverSideRender;
|
||||||
const { __ } = wp.i18n;
|
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 = [
|
const blocks = [
|
||||||
{
|
{
|
||||||
name: 'us-scheduler/booking',
|
name: 'us-scheduler/booking',
|
||||||
@@ -116,6 +157,19 @@
|
|||||||
icon: 'groups',
|
icon: 'groups',
|
||||||
keywords: ['group', 'class', 'enrol'],
|
keywords: ['group', 'class', 'enrol'],
|
||||||
shortcode: 'us_group_classes',
|
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 }),
|
||||||
|
})
|
||||||
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,10 @@
|
|||||||
const errorBox = document.getElementById('us-group-error');
|
const errorBox = document.getElementById('us-group-error');
|
||||||
const { restUrl, nonce } = usScheduler;
|
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 = {}) {
|
function apiFetch(path, options = {}) {
|
||||||
return fetch(restUrl + path, {
|
return fetch(restUrl + path, {
|
||||||
...options,
|
...options,
|
||||||
@@ -68,16 +72,39 @@
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
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) {
|
if (!groups.length) {
|
||||||
list.innerHTML = '<p>No group classes are open for enrolment right now.</p>';
|
list.innerHTML = singleOfferingId
|
||||||
|
? '<p>This class is not open for enrolment right now.</p>'
|
||||||
|
: '<p>No group classes are open for enrolment right now.</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
list.innerHTML = groups.map((o) => `
|
list.innerHTML = groups.map((o) => `
|
||||||
<div class="us-class">
|
<div class="us-class">
|
||||||
<h3>${escHtml(o.title)}</h3>
|
<h3>${escHtml(o.title)}</h3>
|
||||||
|
${termLabel(o) ? `<p>${escHtml(termLabel(o))}</p>` : ''}
|
||||||
${o.schedule_note ? `<p>${escHtml(o.schedule_note)}</p>` : ''}
|
${o.schedule_note ? `<p>${escHtml(o.schedule_note)}</p>` : ''}
|
||||||
${o.description ? `<p>${escHtml(o.description)}</p>` : ''}
|
${o.description ? `<p>${escHtml(o.description)}</p>` : ''}
|
||||||
<p>${escHtml(Number(o.price).toFixed(2))} ${escHtml(o.currency)}</p>
|
<p>${escHtml(Number(o.price).toFixed(2))} ${escHtml(o.currency)}</p>
|
||||||
|
|||||||
@@ -21,8 +21,7 @@ transform.
|
|||||||
|
|
||||||
## Block options
|
## Block options
|
||||||
|
|
||||||
Two blocks have sidebar (inspector) options controlling where their
|
Three blocks have sidebar (inspector) options:
|
||||||
logged-in/logged-out link sends the visitor:
|
|
||||||
|
|
||||||
| Block | Attribute | Default | Effect |
|
| 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/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` | `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-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 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
|
to the shortcodes as `[us_booking login_page_id="…"]` and
|
||||||
`[us_student_login booking_page_id="…"]`; auto-redirect is block-only.
|
`[us_student_login booking_page_id="…"]`; auto-redirect is block-only.
|
||||||
|
|
||||||
|
|||||||
@@ -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` |
|
| `payment_id` | BIGINT UNSIGNED | Nullable FK → `us_payments.id` |
|
||||||
| `enrolled_at` | DATETIME | Insertion time |
|
| `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
|
## Enrolment Flow
|
||||||
1. Student opens a group class from the offering catalog.
|
1. Student opens a group class from the offering catalog.
|
||||||
2. Student answers the offering's questions (`GET /offerings/{id}/questions`).
|
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`
|
- Model: `Unsupervised\Schedular\GroupClass\Enrollment`
|
||||||
- Admin controller: `Unsupervised\Schedular\GroupClass\GroupClassController` (gated on `view_all_lessons`)
|
- Admin controller: `Unsupervised\Schedular\GroupClass\GroupClassController` (gated on `view_all_lessons`)
|
||||||
- REST endpoint: `Unsupervised\Schedular\GroupClass\EnrollmentEndpoint`
|
- 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`)
|
- Reuses `Registration\RegistrationGate` (intake answers + booking-scoped policy acceptance, type `enrollment`)
|
||||||
|
|
||||||
> **Payment seam:** payment is deferred to #7. An enrolment is created with
|
> **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
|
||||||
- `tests/Unit/GroupClass/EnrollmentTest.php`
|
- `tests/Unit/GroupClass/EnrollmentTest.php`
|
||||||
- `tests/Unit/GroupClass/EnrollmentRepositoryTest.php`
|
- `tests/Unit/GroupClass/EnrollmentRepositoryTest.php`
|
||||||
|
- `tests/Unit/GroupClass/GroupClassPageTest.php`
|
||||||
|
|||||||
@@ -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).
|
- `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`.
|
- `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
|
## Admin Interface
|
||||||
Studio admin and instructors manage offerings under **Offerings** in wp-admin.
|
Studio admin and instructors manage offerings under **Offerings** in wp-admin.
|
||||||
- Studio admin (`manage_offerings`) manages offerings for any instructor.
|
- Studio admin (`manage_offerings`) manages offerings for any instructor.
|
||||||
- Instructor (`manage_offerings`) manages only their own.
|
- Instructor (`manage_offerings`) manages only their own.
|
||||||
- Each offering's intake questions are edited from the offering screen (see `registration-questions.md`).
|
- 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=<id>`) with the form prefilled; saving posts `usc_action=update`. Owner and currency are always preserved on update, so a form submission can never reassign an offering. Non-admin instructors can only load and update their own offerings.
|
||||||
|
- The form includes a **description** textarea and an **Active — open for registration** checkbox (unchecking hides the offering from students without deleting it — the admin-UI counterpart of the REST `is_active` flag).
|
||||||
|
|
||||||
## REST API
|
## REST API
|
||||||
| Method | Endpoint | Permission |
|
| Method | Endpoint | Permission |
|
||||||
@@ -51,5 +64,6 @@ Studio admin and instructors manage offerings under **Offerings** in wp-admin.
|
|||||||
- REST endpoint: `Unsupervised\Schedular\Offering\OfferingEndpoint`
|
- REST endpoint: `Unsupervised\Schedular\Offering\OfferingEndpoint`
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
- `tests/Unit/Offering/OfferingControllerTest.php`
|
||||||
- `tests/Unit/Offering/OfferingRepositoryTest.php`
|
- `tests/Unit/Offering/OfferingRepositoryTest.php`
|
||||||
- `tests/Unit/Offering/OfferingTest.php`
|
- `tests/Unit/Offering/OfferingTest.php`
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ class BlockRegistrar {
|
|||||||
wp_register_script(
|
wp_register_script(
|
||||||
self::SCRIPT_HANDLE,
|
self::SCRIPT_HANDLE,
|
||||||
USC_PLUGIN_URL . 'assets/js/blocks.js',
|
USC_PLUGIN_URL . 'assets/js/blocks.js',
|
||||||
[ 'wp-blocks', 'wp-element', 'wp-block-editor', 'wp-components', 'wp-data', 'wp-core-data', 'wp-server-side-render', 'wp-i18n' ],
|
[ 'wp-blocks', 'wp-element', 'wp-block-editor', 'wp-components', 'wp-data', 'wp-core-data', 'wp-server-side-render', 'wp-i18n', 'wp-api-fetch' ],
|
||||||
USC_VERSION,
|
USC_VERSION,
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
@@ -108,7 +108,12 @@ class BlockRegistrar {
|
|||||||
],
|
],
|
||||||
'us-scheduler/group-classes' => [
|
'us-scheduler/group-classes' => [
|
||||||
'render' => [ $this, 'renderGroupClasses' ],
|
'render' => [ $this, 'renderGroupClasses' ],
|
||||||
'attributes' => [],
|
'attributes' => [
|
||||||
|
'offeringId' => [
|
||||||
|
'type' => 'number',
|
||||||
|
'default' => 0,
|
||||||
|
],
|
||||||
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,15 +4,21 @@ declare(strict_types=1);
|
|||||||
namespace Unsupervised\Schedular\GroupClass;
|
namespace Unsupervised\Schedular\GroupClass;
|
||||||
|
|
||||||
use Unsupervised\Schedular\Auth\RoleManager;
|
use Unsupervised\Schedular\Auth\RoleManager;
|
||||||
|
use Unsupervised\Schedular\Val;
|
||||||
|
|
||||||
class GroupClassPage {
|
class GroupClassPage {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the group-class enrolment shortcode output.
|
* Renders the group-class enrolment shortcode output.
|
||||||
*
|
*
|
||||||
* @param array<int|string, mixed> $atts Shortcode attributes (unused — reserved for future options).
|
* Supported attributes: `offering` (shortcode) / `offeringId` (block) — an
|
||||||
|
* offering id that restricts the page to a single class, so the shortcode
|
||||||
|
* can be embedded on a page dedicated to that class. 0 or absent shows the
|
||||||
|
* full browsable catalog.
|
||||||
|
*
|
||||||
|
* @param array<int|string, mixed> $atts Shortcode or block attributes.
|
||||||
*/
|
*/
|
||||||
public function render( array $atts ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
|
public function render( array $atts ): string {
|
||||||
if ( ! is_user_logged_in() ) {
|
if ( ! is_user_logged_in() ) {
|
||||||
$permalink = get_permalink();
|
$permalink = get_permalink();
|
||||||
|
|
||||||
@@ -31,6 +37,8 @@ class GroupClassPage {
|
|||||||
wp_enqueue_style( 'us-scheduler' );
|
wp_enqueue_style( 'us-scheduler' );
|
||||||
wp_enqueue_script( 'us-scheduler-group' );
|
wp_enqueue_script( 'us-scheduler-group' );
|
||||||
|
|
||||||
|
$offeringId = absint( Val::int( $atts['offering'] ?? $atts['offeringId'] ?? 0 ) );
|
||||||
|
|
||||||
ob_start();
|
ob_start();
|
||||||
include USC_PLUGIN_DIR . 'templates/frontend/group-classes-page.php';
|
include USC_PLUGIN_DIR . 'templates/frontend/group-classes-page.php';
|
||||||
return (string) ob_get_clean();
|
return (string) ob_get_clean();
|
||||||
|
|||||||
@@ -46,6 +46,27 @@ class Offering {
|
|||||||
public readonly ?int $id = null,
|
public readonly ?int $id = null,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalise a submitted term date to canonical `Y-m-d`, or null when it is
|
||||||
|
* not a real calendar date. Round-trips through DateTimeImmutable so
|
||||||
|
* strings PHP would silently coerce (e.g. `2026-02-30`) are rejected.
|
||||||
|
*/
|
||||||
|
public static function normalizeDate( string $value ): ?string {
|
||||||
|
$date = \DateTimeImmutable::createFromFormat( '!Y-m-d', $value );
|
||||||
|
|
||||||
|
return false !== $date && $date->format( 'Y-m-d' ) === $value ? $date->format( 'Y-m-d' ) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last class date of a weekly term: the start date plus `$occurrences - 1`
|
||||||
|
* weeks. A one-off class (one occurrence) ends the day it starts.
|
||||||
|
*/
|
||||||
|
public static function weeklyTermEnd( string $termStart, int $occurrences ): string {
|
||||||
|
$weeks = max( 1, $occurrences ) - 1;
|
||||||
|
|
||||||
|
return ( new \DateTimeImmutable( $termStart ) )->modify( '+' . ( 7 * $weeks ) . ' days' )->format( 'Y-m-d' );
|
||||||
|
}
|
||||||
|
|
||||||
public static function fromRow( \stdClass $row ): self {
|
public static function fromRow( \stdClass $row ): self {
|
||||||
return new self(
|
return new self(
|
||||||
instructorId: Val::int( $row->instructor_id ),
|
instructorId: Val::int( $row->instructor_id ),
|
||||||
|
|||||||
@@ -22,6 +22,20 @@ class OfferingController {
|
|||||||
$this->handleFormAction( $instructorId, $manageAll );
|
$this->handleFormAction( $instructorId, $manageAll );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// View-state query param only (which offering the form is editing) —
|
||||||
|
// nothing is mutated from it, so no nonce applies.
|
||||||
|
// phpcs:disable WordPress.Security.NonceVerification.Recommended
|
||||||
|
$editId = absint( Val::int( $_GET['usc_edit'] ?? 0 ) );
|
||||||
|
// phpcs:enable WordPress.Security.NonceVerification.Recommended
|
||||||
|
|
||||||
|
$editing = null;
|
||||||
|
if ( $editId > 0 ) {
|
||||||
|
$candidate = $this->repository->findById( $editId );
|
||||||
|
if ( $candidate && ( $manageAll || $candidate->instructorId === $instructorId ) ) {
|
||||||
|
$editing = $candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$offerings = $manageAll
|
$offerings = $manageAll
|
||||||
? $this->repository->findAll()
|
? $this->repository->findAll()
|
||||||
: $this->repository->findAll( $instructorId );
|
: $this->repository->findAll( $instructorId );
|
||||||
@@ -35,7 +49,23 @@ class OfferingController {
|
|||||||
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) );
|
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) );
|
||||||
|
|
||||||
if ( 'add' === $action ) {
|
if ( 'add' === $action ) {
|
||||||
$this->addOffering( $instructorId );
|
$offering = $this->offeringFromPost( $instructorId );
|
||||||
|
if ( null !== $offering ) {
|
||||||
|
$this->repository->insert( $offering );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( 'update' === $action ) {
|
||||||
|
$offeringId = absint( Val::int( $_POST['offering_id'] ?? 0 ) );
|
||||||
|
if ( $offeringId > 0 ) {
|
||||||
|
$existing = $this->repository->findById( $offeringId );
|
||||||
|
if ( $existing && ( $manageAll || $existing->instructorId === $instructorId ) ) {
|
||||||
|
$offering = $this->offeringFromPost( $instructorId, $existing );
|
||||||
|
if ( null !== $offering ) {
|
||||||
|
$this->repository->update( $offeringId, $offering );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ( 'delete' === $action ) {
|
if ( 'delete' === $action ) {
|
||||||
@@ -50,13 +80,20 @@ class OfferingController {
|
|||||||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||||
}
|
}
|
||||||
|
|
||||||
private function addOffering( int $instructorId ): void {
|
/**
|
||||||
|
* Build an offering from the submitted add/edit form, or null when the
|
||||||
|
* submission is invalid. When `$existing` is given the result is an edit:
|
||||||
|
* it keeps the existing id, owner, and currency so an update can never
|
||||||
|
* reassign an offering to whoever happens to submit the form.
|
||||||
|
*/
|
||||||
|
private function offeringFromPost( int $instructorId, ?Offering $existing = null ): ?Offering {
|
||||||
|
// Nonce is verified by the caller (renderPage) before this method runs.
|
||||||
// phpcs:disable WordPress.Security.NonceVerification.Missing
|
// phpcs:disable WordPress.Security.NonceVerification.Missing
|
||||||
$title = sanitize_text_field( Val::string( wp_unslash( $_POST['title'] ?? '' ) ) );
|
$title = sanitize_text_field( Val::string( wp_unslash( $_POST['title'] ?? '' ) ) );
|
||||||
$kind = sanitize_key( Val::string( wp_unslash( $_POST['kind'] ?? '' ) ) );
|
$kind = sanitize_key( Val::string( wp_unslash( $_POST['kind'] ?? '' ) ) );
|
||||||
|
|
||||||
if ( '' === $title || ! in_array( $kind, Offering::VALID_KINDS, true ) ) {
|
if ( '' === $title || ! in_array( $kind, Offering::VALID_KINDS, true ) ) {
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$billingMode = sanitize_key( Val::string( wp_unslash( $_POST['billing_mode'] ?? Offering::BILLING_ONE_TIME ) ) );
|
$billingMode = sanitize_key( Val::string( wp_unslash( $_POST['billing_mode'] ?? Offering::BILLING_ONE_TIME ) ) );
|
||||||
@@ -67,19 +104,33 @@ class OfferingController {
|
|||||||
$duration = absint( Val::int( $_POST['duration_minutes'] ?? 0 ) );
|
$duration = absint( Val::int( $_POST['duration_minutes'] ?? 0 ) );
|
||||||
$capacity = absint( Val::int( $_POST['capacity'] ?? 0 ) );
|
$capacity = absint( Val::int( $_POST['capacity'] ?? 0 ) );
|
||||||
|
|
||||||
$this->repository->insert(
|
// Term dates: a class either meets once (term ends the day it starts)
|
||||||
new Offering(
|
// or repeats weekly for a set number of sessions.
|
||||||
instructorId: $instructorId,
|
$termStart = Offering::normalizeDate( sanitize_text_field( Val::string( wp_unslash( $_POST['term_start'] ?? '' ) ) ) );
|
||||||
|
$termEnd = null;
|
||||||
|
if ( null !== $termStart ) {
|
||||||
|
$recurrence = sanitize_key( Val::string( wp_unslash( $_POST['term_recurrence'] ?? 'single' ) ) );
|
||||||
|
$sessions = absint( Val::int( $_POST['term_sessions'] ?? 1 ) );
|
||||||
|
$termEnd = 'weekly' === $recurrence ? Offering::weeklyTermEnd( $termStart, $sessions ) : $termStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Offering(
|
||||||
|
instructorId: null !== $existing ? $existing->instructorId : $instructorId,
|
||||||
kind: $kind,
|
kind: $kind,
|
||||||
title: $title,
|
title: $title,
|
||||||
price: max( 0.0, (float) sanitize_text_field( Val::string( wp_unslash( $_POST['price'] ?? '0' ) ) ) ),
|
price: max( 0.0, (float) sanitize_text_field( Val::string( wp_unslash( $_POST['price'] ?? '0' ) ) ) ),
|
||||||
|
currency: null !== $existing ? $existing->currency : 'CAD',
|
||||||
billingMode: $billingMode,
|
billingMode: $billingMode,
|
||||||
|
description: $this->nullableText( sanitize_textarea_field( Val::string( wp_unslash( $_POST['description'] ?? '' ) ) ) ),
|
||||||
durationMinutes: $duration > 0 ? $duration : null,
|
durationMinutes: $duration > 0 ? $duration : null,
|
||||||
allowWeekly: isset( $_POST['allow_weekly'] ),
|
allowWeekly: isset( $_POST['allow_weekly'] ),
|
||||||
capacity: $capacity > 0 ? $capacity : null,
|
capacity: $capacity > 0 ? $capacity : null,
|
||||||
|
termStart: $termStart,
|
||||||
|
termEnd: $termEnd,
|
||||||
scheduleNote: $this->nullableText( sanitize_text_field( Val::string( wp_unslash( $_POST['schedule_note'] ?? '' ) ) ) ),
|
scheduleNote: $this->nullableText( sanitize_text_field( Val::string( wp_unslash( $_POST['schedule_note'] ?? '' ) ) ) ),
|
||||||
etransferEmail: $this->nullableText( sanitize_email( Val::string( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) ) ),
|
etransferEmail: $this->nullableText( sanitize_email( Val::string( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) ) ),
|
||||||
)
|
isActive: isset( $_POST['is_active'] ),
|
||||||
|
id: $existing?->id,
|
||||||
);
|
);
|
||||||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,64 +7,111 @@ if (! defined('ABSPATH')) {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @var list<\Unsupervised\Schedular\Offering\Offering> $offerings */
|
/**
|
||||||
|
* @var list<\Unsupervised\Schedular\Offering\Offering> $offerings
|
||||||
|
* @var \Unsupervised\Schedular\Offering\Offering|null $editing Offering loaded into the form, or null when adding.
|
||||||
|
*/
|
||||||
|
|
||||||
|
$baseUrl = admin_url('admin.php?page=us-offerings');
|
||||||
|
|
||||||
|
// Prefill the sessions control from the stored term dates: a term longer than
|
||||||
|
// one day was created as weekly sessions one week apart.
|
||||||
|
$termRecurrence = 'single';
|
||||||
|
$termSessions = 10;
|
||||||
|
if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $editing->termEnd !== $editing->termStart) {
|
||||||
|
$termRecurrence = 'weekly';
|
||||||
|
$termSessions = (int) round(((int) strtotime($editing->termEnd) - (int) strtotime($editing->termStart)) / 604800) + 1;
|
||||||
|
}
|
||||||
?>
|
?>
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<h1><?php esc_html_e('Offerings', 'unsupervised-schedular'); ?></h1>
|
<h1><?php esc_html_e('Offerings', 'unsupervised-schedular'); ?></h1>
|
||||||
|
|
||||||
<h2><?php esc_html_e('Add Offering', 'unsupervised-schedular'); ?></h2>
|
<h2><?php $editing ? esc_html_e('Edit Offering', 'unsupervised-schedular') : esc_html_e('Add Offering', 'unsupervised-schedular'); ?></h2>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<?php wp_nonce_field('usc_offering_action'); ?>
|
<?php wp_nonce_field('usc_offering_action'); ?>
|
||||||
|
<?php if ($editing) : ?>
|
||||||
|
<input type="hidden" name="usc_action" value="update">
|
||||||
|
<input type="hidden" name="offering_id" value="<?php echo esc_attr((string) $editing->id); ?>">
|
||||||
|
<?php else : ?>
|
||||||
<input type="hidden" name="usc_action" value="add">
|
<input type="hidden" name="usc_action" value="add">
|
||||||
|
<?php endif; ?>
|
||||||
<table class="form-table">
|
<table class="form-table">
|
||||||
<tr>
|
<tr>
|
||||||
<th><label for="title"><?php esc_html_e('Title', 'unsupervised-schedular'); ?></label></th>
|
<th><label for="title"><?php esc_html_e('Title', 'unsupervised-schedular'); ?></label></th>
|
||||||
<td><input type="text" name="title" id="title" class="regular-text" required></td>
|
<td><input type="text" name="title" id="title" class="regular-text" required value="<?php echo esc_attr($editing->title ?? ''); ?>"></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th><label for="kind"><?php esc_html_e('Kind', 'unsupervised-schedular'); ?></label></th>
|
<th><label for="kind"><?php esc_html_e('Kind', 'unsupervised-schedular'); ?></label></th>
|
||||||
<td>
|
<td>
|
||||||
<select name="kind" id="kind">
|
<select name="kind" id="kind">
|
||||||
<option value="<?php echo esc_attr(Offering::KIND_PRIVATE_LESSON); ?>"><?php esc_html_e('Private lesson', 'unsupervised-schedular'); ?></option>
|
<option value="<?php echo esc_attr(Offering::KIND_PRIVATE_LESSON); ?>"><?php esc_html_e('Private lesson', 'unsupervised-schedular'); ?></option>
|
||||||
<option value="<?php echo esc_attr(Offering::KIND_GROUP_CLASS); ?>"><?php esc_html_e('Group class', 'unsupervised-schedular'); ?></option>
|
<option value="<?php echo esc_attr(Offering::KIND_GROUP_CLASS); ?>" <?php echo $editing && Offering::KIND_GROUP_CLASS === $editing->kind ? 'selected' : ''; ?>><?php esc_html_e('Group class', 'unsupervised-schedular'); ?></option>
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th><label for="description"><?php esc_html_e('Description', 'unsupervised-schedular'); ?></label></th>
|
||||||
|
<td><textarea name="description" id="description" class="large-text" rows="4"><?php echo esc_textarea($editing->description ?? ''); ?></textarea></td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th><label for="duration_minutes"><?php esc_html_e('Duration (minutes)', 'unsupervised-schedular'); ?></label></th>
|
<th><label for="duration_minutes"><?php esc_html_e('Duration (minutes)', 'unsupervised-schedular'); ?></label></th>
|
||||||
<td><input type="number" name="duration_minutes" id="duration_minutes" min="0" step="1"></td>
|
<td><input type="number" name="duration_minutes" id="duration_minutes" min="0" step="1" value="<?php echo esc_attr((string) ($editing->durationMinutes ?? '')); ?>"></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th><label for="price"><?php esc_html_e('Price (dollars)', 'unsupervised-schedular'); ?></label></th>
|
<th><label for="price"><?php esc_html_e('Price (dollars)', 'unsupervised-schedular'); ?></label></th>
|
||||||
<td><input type="number" name="price" id="price" min="0" step="0.01" value="0.00"></td>
|
<td><input type="number" name="price" id="price" min="0" step="0.01" value="<?php echo esc_attr(number_format($editing->price ?? 0.0, 2, '.', '')); ?>"></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th><label for="billing_mode"><?php esc_html_e('Billing', 'unsupervised-schedular'); ?></label></th>
|
<th><label for="billing_mode"><?php esc_html_e('Billing', 'unsupervised-schedular'); ?></label></th>
|
||||||
<td>
|
<td>
|
||||||
<select name="billing_mode" id="billing_mode">
|
<select name="billing_mode" id="billing_mode">
|
||||||
<option value="<?php echo esc_attr(Offering::BILLING_ONE_TIME); ?>"><?php esc_html_e('One-time at booking', 'unsupervised-schedular'); ?></option>
|
<option value="<?php echo esc_attr(Offering::BILLING_ONE_TIME); ?>"><?php esc_html_e('One-time at booking', 'unsupervised-schedular'); ?></option>
|
||||||
<option value="<?php echo esc_attr(Offering::BILLING_FULL_TERM); ?>"><?php esc_html_e('Full term upfront', 'unsupervised-schedular'); ?></option>
|
<option value="<?php echo esc_attr(Offering::BILLING_FULL_TERM); ?>" <?php echo $editing && Offering::BILLING_FULL_TERM === $editing->billingMode ? 'selected' : ''; ?>><?php esc_html_e('Full term upfront', 'unsupervised-schedular'); ?></option>
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th><?php esc_html_e('Weekly reservation', 'unsupervised-schedular'); ?></th>
|
<th><?php esc_html_e('Weekly reservation', 'unsupervised-schedular'); ?></th>
|
||||||
<td><label><input type="checkbox" name="allow_weekly" value="1"> <?php esc_html_e('Allow weekly recurring reservation (private)', 'unsupervised-schedular'); ?></label></td>
|
<td><label><input type="checkbox" name="allow_weekly" value="1" <?php echo $editing && $editing->allowWeekly ? 'checked' : ''; ?>> <?php esc_html_e('Allow weekly recurring reservation (private)', 'unsupervised-schedular'); ?></label></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th><label for="capacity"><?php esc_html_e('Capacity', 'unsupervised-schedular'); ?></label></th>
|
<th><label for="capacity"><?php esc_html_e('Capacity', 'unsupervised-schedular'); ?></label></th>
|
||||||
<td><input type="number" name="capacity" id="capacity" min="0" step="1"> <span class="description"><?php esc_html_e('Group classes only', 'unsupervised-schedular'); ?></span></td>
|
<td><input type="number" name="capacity" id="capacity" min="0" step="1" value="<?php echo esc_attr((string) ($editing->capacity ?? '')); ?>"> <span class="description"><?php esc_html_e('Group classes only', 'unsupervised-schedular'); ?></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th><label for="term_start"><?php esc_html_e('Start date', 'unsupervised-schedular'); ?></label></th>
|
||||||
|
<td>
|
||||||
|
<input type="date" name="term_start" id="term_start" value="<?php echo esc_attr($editing->termStart ?? ''); ?>">
|
||||||
|
<span class="description"><?php esc_html_e('Group classes only — date of the first class', 'unsupervised-schedular'); ?></span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th><?php esc_html_e('Sessions', 'unsupervised-schedular'); ?></th>
|
||||||
|
<td>
|
||||||
|
<label><input type="radio" name="term_recurrence" value="single" <?php echo 'single' === $termRecurrence ? 'checked' : ''; ?>> <?php esc_html_e('One-off', 'unsupervised-schedular'); ?></label>
|
||||||
|
|
||||||
|
<label><input type="radio" name="term_recurrence" value="weekly" <?php echo 'weekly' === $termRecurrence ? 'checked' : ''; ?>> <?php esc_html_e('Weekly for', 'unsupervised-schedular'); ?></label>
|
||||||
|
<input type="number" name="term_sessions" min="1" max="52" value="<?php echo esc_attr((string) $termSessions); ?>" style="width:5em;"> <?php esc_html_e('sessions', 'unsupervised-schedular'); ?>
|
||||||
|
<p class="description"><?php esc_html_e('The end date is calculated from the start date and the number of weekly sessions.', 'unsupervised-schedular'); ?></p>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th><label for="schedule_note"><?php esc_html_e('Schedule note', 'unsupervised-schedular'); ?></label></th>
|
<th><label for="schedule_note"><?php esc_html_e('Schedule note', 'unsupervised-schedular'); ?></label></th>
|
||||||
<td><input type="text" name="schedule_note" id="schedule_note" class="regular-text" placeholder="<?php esc_attr_e('e.g. Tuesdays 4:00pm', 'unsupervised-schedular'); ?>"></td>
|
<td><input type="text" name="schedule_note" id="schedule_note" class="regular-text" placeholder="<?php esc_attr_e('e.g. Tuesdays 4:00pm', 'unsupervised-schedular'); ?>" value="<?php echo esc_attr($editing->scheduleNote ?? ''); ?>"></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th><label for="etransfer_email"><?php esc_html_e('E-transfer email', 'unsupervised-schedular'); ?></label></th>
|
<th><label for="etransfer_email"><?php esc_html_e('E-transfer email', 'unsupervised-schedular'); ?></label></th>
|
||||||
<td><input type="email" name="etransfer_email" id="etransfer_email" class="regular-text" placeholder="<?php esc_attr_e('Overrides the studio default', 'unsupervised-schedular'); ?>"></td>
|
<td><input type="email" name="etransfer_email" id="etransfer_email" class="regular-text" placeholder="<?php esc_attr_e('Overrides the studio default', 'unsupervised-schedular'); ?>" value="<?php echo esc_attr($editing->etransferEmail ?? ''); ?>"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th><?php esc_html_e('Active', 'unsupervised-schedular'); ?></th>
|
||||||
|
<td><label><input type="checkbox" name="is_active" value="1" <?php echo null === $editing || $editing->isActive ? 'checked' : ''; ?>> <?php esc_html_e('Open for registration', 'unsupervised-schedular'); ?></label></td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
<?php submit_button(esc_html__('Add Offering', 'unsupervised-schedular')); ?>
|
<?php submit_button($editing ? esc_html__('Update Offering', 'unsupervised-schedular') : esc_html__('Add Offering', 'unsupervised-schedular')); ?>
|
||||||
|
<?php if ($editing) : ?>
|
||||||
|
<p><a href="<?php echo esc_url($baseUrl); ?>"><?php esc_html_e('Cancel editing', 'unsupervised-schedular'); ?></a></p>
|
||||||
|
<?php endif; ?>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<h2><?php esc_html_e('Current Offerings', 'unsupervised-schedular'); ?></h2>
|
<h2><?php esc_html_e('Current Offerings', 'unsupervised-schedular'); ?></h2>
|
||||||
@@ -75,11 +122,13 @@ if (! defined('ABSPATH')) {
|
|||||||
<table class="wp-list-table widefat fixed striped">
|
<table class="wp-list-table widefat fixed striped">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th style="width:4em;"><?php esc_html_e('ID', 'unsupervised-schedular'); ?></th>
|
||||||
<th><?php esc_html_e('Title', 'unsupervised-schedular'); ?></th>
|
<th><?php esc_html_e('Title', 'unsupervised-schedular'); ?></th>
|
||||||
<th><?php esc_html_e('Kind', 'unsupervised-schedular'); ?></th>
|
<th><?php esc_html_e('Kind', 'unsupervised-schedular'); ?></th>
|
||||||
<th><?php esc_html_e('Duration', 'unsupervised-schedular'); ?></th>
|
<th><?php esc_html_e('Duration', 'unsupervised-schedular'); ?></th>
|
||||||
<th><?php esc_html_e('Price', 'unsupervised-schedular'); ?></th>
|
<th><?php esc_html_e('Price', 'unsupervised-schedular'); ?></th>
|
||||||
<th><?php esc_html_e('Billing', 'unsupervised-schedular'); ?></th>
|
<th><?php esc_html_e('Billing', 'unsupervised-schedular'); ?></th>
|
||||||
|
<th><?php esc_html_e('Term', 'unsupervised-schedular'); ?></th>
|
||||||
<th><?php esc_html_e('Active', 'unsupervised-schedular'); ?></th>
|
<th><?php esc_html_e('Active', 'unsupervised-schedular'); ?></th>
|
||||||
<th><?php esc_html_e('Actions', 'unsupervised-schedular'); ?></th>
|
<th><?php esc_html_e('Actions', 'unsupervised-schedular'); ?></th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -87,13 +136,24 @@ if (! defined('ABSPATH')) {
|
|||||||
<tbody>
|
<tbody>
|
||||||
<?php foreach ($offerings as $offering) : ?>
|
<?php foreach ($offerings as $offering) : ?>
|
||||||
<tr>
|
<tr>
|
||||||
|
<td><?php echo esc_html((string) $offering->id); ?></td>
|
||||||
<td><?php echo esc_html($offering->title); ?></td>
|
<td><?php echo esc_html($offering->title); ?></td>
|
||||||
<td><?php echo esc_html($offering->kind); ?></td>
|
<td><?php echo esc_html($offering->kind); ?></td>
|
||||||
<td><?php echo $offering->durationMinutes ? esc_html((string) $offering->durationMinutes . ' min') : '—'; ?></td>
|
<td><?php echo $offering->durationMinutes ? esc_html((string) $offering->durationMinutes . ' min') : '—'; ?></td>
|
||||||
<td><?php echo esc_html(number_format($offering->price, 2) . ' ' . $offering->currency); ?></td>
|
<td><?php echo esc_html(number_format($offering->price, 2) . ' ' . $offering->currency); ?></td>
|
||||||
<td><?php echo esc_html($offering->billingMode); ?></td>
|
<td><?php echo esc_html($offering->billingMode); ?></td>
|
||||||
|
<td>
|
||||||
|
<?php if (null === $offering->termStart) : ?>
|
||||||
|
—
|
||||||
|
<?php elseif (null === $offering->termEnd || $offering->termEnd === $offering->termStart) : ?>
|
||||||
|
<?php echo esc_html((string) mysql2date('M j, Y', $offering->termStart)); ?>
|
||||||
|
<?php else : ?>
|
||||||
|
<?php echo esc_html((string) mysql2date('M j, Y', $offering->termStart) . ' – ' . (string) mysql2date('M j, Y', $offering->termEnd)); ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
<td><?php echo $offering->isActive ? esc_html__('Yes', 'unsupervised-schedular') : esc_html__('No', 'unsupervised-schedular'); ?></td>
|
<td><?php echo $offering->isActive ? esc_html__('Yes', 'unsupervised-schedular') : esc_html__('No', 'unsupervised-schedular'); ?></td>
|
||||||
<td>
|
<td>
|
||||||
|
<a class="button button-small" href="<?php echo esc_url(add_query_arg('usc_edit', (int) $offering->id, $baseUrl)); ?>"><?php esc_html_e('Edit', 'unsupervised-schedular'); ?></a>
|
||||||
<form method="post" style="display:inline;">
|
<form method="post" style="display:inline;">
|
||||||
<?php wp_nonce_field('usc_offering_action'); ?>
|
<?php wp_nonce_field('usc_offering_action'); ?>
|
||||||
<input type="hidden" name="usc_action" value="delete">
|
<input type="hidden" name="usc_action" value="delete">
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ declare(strict_types=1);
|
|||||||
if (! defined('ABSPATH')) {
|
if (! defined('ABSPATH')) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @var int $offeringId Offering id when the page is restricted to a single class; 0 for the full catalog. */
|
||||||
?>
|
?>
|
||||||
<div id="us-group-app">
|
<div id="us-group-app"<?php echo $offeringId > 0 ? ' data-offering="' . esc_attr((string) $offeringId) . '"' : ''; ?>>
|
||||||
<div id="us-group-list">
|
<div id="us-group-list">
|
||||||
<p><?php esc_html_e('Loading group classes…', 'unsupervised-schedular'); ?></p>
|
<p><?php esc_html_e('Loading group classes…', 'unsupervised-schedular'); ?></p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -126,7 +126,10 @@ class BlockRegistrarTest extends TestCase
|
|||||||
array_keys($registered['us-scheduler/student-login']['attributes'])
|
array_keys($registered['us-scheduler/student-login']['attributes'])
|
||||||
);
|
);
|
||||||
self::assertSame([], $registered['us-scheduler/student-register']['attributes']);
|
self::assertSame([], $registered['us-scheduler/student-register']['attributes']);
|
||||||
self::assertSame([], $registered['us-scheduler/group-classes']['attributes']);
|
self::assertSame(
|
||||||
|
['offeringId'],
|
||||||
|
array_keys($registered['us-scheduler/group-classes']['attributes'])
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testRegisterBlocksDoesNotReRegisterAnAlreadyRegisteredStyle(): void
|
public function testRegisterBlocksDoesNotReRegisterAnAlreadyRegisteredStyle(): void
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Unsupervised\Schedular\Tests\Unit\GroupClass;
|
||||||
|
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
use Unsupervised\Schedular\GroupClass\GroupClassPage;
|
||||||
|
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||||
|
|
||||||
|
class GroupClassPageTest extends TestCase
|
||||||
|
{
|
||||||
|
private GroupClassPage $page;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->page = new GroupClassPage();
|
||||||
|
|
||||||
|
Functions\when('is_user_logged_in')->justReturn(true);
|
||||||
|
Functions\when('current_user_can')->justReturn(true);
|
||||||
|
Functions\when('wp_enqueue_style')->justReturn(null);
|
||||||
|
Functions\when('wp_enqueue_script')->justReturn(null);
|
||||||
|
Functions\when('absint')->alias(static fn ($value) => abs((int) $value));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDefaultRenderHasNoOfferingRestriction(): void
|
||||||
|
{
|
||||||
|
$html = $this->page->render([]);
|
||||||
|
|
||||||
|
self::assertStringContainsString('id="us-group-app"', $html);
|
||||||
|
self::assertStringNotContainsString('data-offering', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testShortcodeOfferingAttributePinsASingleClass(): void
|
||||||
|
{
|
||||||
|
$html = $this->page->render(['offering' => '12']);
|
||||||
|
|
||||||
|
self::assertStringContainsString('data-offering="12"', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBlockOfferingIdAttributePinsASingleClass(): void
|
||||||
|
{
|
||||||
|
$html = $this->page->render(['offeringId' => 7]);
|
||||||
|
|
||||||
|
self::assertStringContainsString('data-offering="7"', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGarbageOfferingAttributeIsIgnored(): void
|
||||||
|
{
|
||||||
|
$html = $this->page->render(['offering' => 'banana']);
|
||||||
|
|
||||||
|
self::assertStringNotContainsString('data-offering', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLoggedOutVisitorGetsLoginPrompt(): void
|
||||||
|
{
|
||||||
|
Functions\when('is_user_logged_in')->justReturn(false);
|
||||||
|
Functions\when('get_permalink')->justReturn('http://example.com/classes/');
|
||||||
|
Functions\when('wp_login_url')->justReturn('http://example.com/wp-login.php');
|
||||||
|
|
||||||
|
$html = $this->page->render([]);
|
||||||
|
|
||||||
|
self::assertStringContainsString('log in to enrol in a class', $html);
|
||||||
|
self::assertStringNotContainsString('us-group-app', $html);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Unsupervised\Schedular\Tests\Unit\Offering;
|
||||||
|
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
use Mockery;
|
||||||
|
use Unsupervised\Schedular\Offering\Offering;
|
||||||
|
use Unsupervised\Schedular\Offering\OfferingController;
|
||||||
|
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||||
|
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||||
|
|
||||||
|
class OfferingControllerTest extends TestCase
|
||||||
|
{
|
||||||
|
private OfferingRepository&Mockery\MockInterface $repository;
|
||||||
|
private OfferingController $controller;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->repository = Mockery::mock(OfferingRepository::class);
|
||||||
|
$this->controller = new OfferingController($this->repository);
|
||||||
|
|
||||||
|
$_POST = [];
|
||||||
|
$_GET = [];
|
||||||
|
|
||||||
|
Functions\when('current_user_can')->justReturn(true);
|
||||||
|
Functions\when('get_current_user_id')->justReturn(3);
|
||||||
|
Functions\when('check_admin_referer')->justReturn(true);
|
||||||
|
Functions\when('admin_url')->justReturn('admin.php?page=us-offerings');
|
||||||
|
Functions\when('add_query_arg')->alias(
|
||||||
|
static fn ($key, $value, $url) => $url . '&' . $key . '=' . $value
|
||||||
|
);
|
||||||
|
Functions\when('wp_unslash')->returnArg();
|
||||||
|
Functions\when('sanitize_text_field')->returnArg();
|
||||||
|
Functions\when('sanitize_textarea_field')->returnArg();
|
||||||
|
Functions\when('sanitize_email')->returnArg();
|
||||||
|
Functions\when('sanitize_key')->alias(
|
||||||
|
static fn ($key) => strtolower((string) preg_replace('/[^a-zA-Z0-9_\-]/', '', (string) $key))
|
||||||
|
);
|
||||||
|
Functions\when('absint')->alias(static fn ($value) => abs((int) $value));
|
||||||
|
Functions\when('wp_nonce_field')->justReturn('');
|
||||||
|
Functions\when('submit_button')->alias(static function (string $text = ''): void {
|
||||||
|
echo $text; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- test stub
|
||||||
|
});
|
||||||
|
Functions\when('mysql2date')->alias(
|
||||||
|
static fn (string $format, string $date) => date($format, (int) strtotime($date))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAddGroupClassWithWeeklyTermComputesEndDate(): void
|
||||||
|
{
|
||||||
|
$_POST = [
|
||||||
|
'usc_action' => 'add',
|
||||||
|
'title' => 'Ballet Beginners',
|
||||||
|
'kind' => Offering::KIND_GROUP_CLASS,
|
||||||
|
'billing_mode' => Offering::BILLING_FULL_TERM,
|
||||||
|
'capacity' => '8',
|
||||||
|
'term_start' => '2026-09-08',
|
||||||
|
'term_recurrence' => 'weekly',
|
||||||
|
'term_sessions' => '10',
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->repository->shouldReceive('insert')->once()->with(Mockery::on(
|
||||||
|
static fn (Offering $o) => '2026-09-08' === $o->termStart && '2026-11-10' === $o->termEnd
|
||||||
|
))->andReturn(1);
|
||||||
|
$this->repository->shouldReceive('findAll')->andReturn([]);
|
||||||
|
|
||||||
|
$this->render();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAddOneOffGroupClassEndsOnItsStartDate(): void
|
||||||
|
{
|
||||||
|
$_POST = [
|
||||||
|
'usc_action' => 'add',
|
||||||
|
'title' => 'Recital Workshop',
|
||||||
|
'kind' => Offering::KIND_GROUP_CLASS,
|
||||||
|
'term_start' => '2026-09-08',
|
||||||
|
'term_recurrence' => 'single',
|
||||||
|
'term_sessions' => '10',
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->repository->shouldReceive('insert')->once()->with(Mockery::on(
|
||||||
|
static fn (Offering $o) => '2026-09-08' === $o->termStart && '2026-09-08' === $o->termEnd
|
||||||
|
))->andReturn(1);
|
||||||
|
$this->repository->shouldReceive('findAll')->andReturn([]);
|
||||||
|
|
||||||
|
$this->render();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testInvalidTermStartLeavesTermDatesNull(): void
|
||||||
|
{
|
||||||
|
$_POST = [
|
||||||
|
'usc_action' => 'add',
|
||||||
|
'title' => 'Choir',
|
||||||
|
'kind' => Offering::KIND_GROUP_CLASS,
|
||||||
|
'term_start' => 'not-a-date',
|
||||||
|
'term_recurrence' => 'weekly',
|
||||||
|
'term_sessions' => '10',
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->repository->shouldReceive('insert')->once()->with(Mockery::on(
|
||||||
|
static fn (Offering $o) => null === $o->termStart && null === $o->termEnd
|
||||||
|
))->andReturn(1);
|
||||||
|
$this->repository->shouldReceive('findAll')->andReturn([]);
|
||||||
|
|
||||||
|
$this->render();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testOfferingListShowsIdAndTermRange(): void
|
||||||
|
{
|
||||||
|
$offering = new Offering(
|
||||||
|
instructorId: 3,
|
||||||
|
kind: Offering::KIND_GROUP_CLASS,
|
||||||
|
title: 'Ballet Beginners',
|
||||||
|
termStart: '2026-09-08',
|
||||||
|
termEnd: '2026-11-10',
|
||||||
|
id: 42,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->repository->shouldReceive('findAll')->andReturn([$offering]);
|
||||||
|
|
||||||
|
$html = $this->render();
|
||||||
|
|
||||||
|
self::assertStringContainsString('<td>42</td>', $html);
|
||||||
|
self::assertStringContainsString('Sep 8, 2026 – Nov 10, 2026', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUpdateAppliesChangesButPreservesOwnerAndCurrency(): void
|
||||||
|
{
|
||||||
|
$existing = new Offering(
|
||||||
|
instructorId: 9,
|
||||||
|
kind: Offering::KIND_GROUP_CLASS,
|
||||||
|
title: 'Ballet Beginners',
|
||||||
|
currency: 'USD',
|
||||||
|
id: 42,
|
||||||
|
);
|
||||||
|
|
||||||
|
$_POST = [
|
||||||
|
'usc_action' => 'update',
|
||||||
|
'offering_id' => '42',
|
||||||
|
'title' => 'Ballet Intermediate',
|
||||||
|
'kind' => Offering::KIND_GROUP_CLASS,
|
||||||
|
'description' => 'A step up.',
|
||||||
|
'capacity' => '6',
|
||||||
|
'is_active' => '1',
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->repository->shouldReceive('findById')->once()->with(42)->andReturn($existing);
|
||||||
|
$this->repository->shouldReceive('update')->once()->with(42, Mockery::on(
|
||||||
|
static fn (Offering $o) => 9 === $o->instructorId
|
||||||
|
&& 'USD' === $o->currency
|
||||||
|
&& 'Ballet Intermediate' === $o->title
|
||||||
|
&& 'A step up.' === $o->description
|
||||||
|
&& 6 === $o->capacity
|
||||||
|
&& $o->isActive
|
||||||
|
))->andReturn(true);
|
||||||
|
$this->repository->shouldReceive('findAll')->andReturn([]);
|
||||||
|
|
||||||
|
$this->render();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUpdateWithoutActiveCheckboxDeactivatesTheOffering(): void
|
||||||
|
{
|
||||||
|
$existing = new Offering(instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: 'Choir', id: 42);
|
||||||
|
|
||||||
|
$_POST = [
|
||||||
|
'usc_action' => 'update',
|
||||||
|
'offering_id' => '42',
|
||||||
|
'title' => 'Choir',
|
||||||
|
'kind' => Offering::KIND_GROUP_CLASS,
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->repository->shouldReceive('findById')->once()->with(42)->andReturn($existing);
|
||||||
|
$this->repository->shouldReceive('update')->once()->with(42, Mockery::on(
|
||||||
|
static fn (Offering $o) => ! $o->isActive
|
||||||
|
))->andReturn(true);
|
||||||
|
$this->repository->shouldReceive('findAll')->andReturn([]);
|
||||||
|
|
||||||
|
$this->render();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNonAdminCannotUpdateAnotherInstructorsOffering(): void
|
||||||
|
{
|
||||||
|
Functions\when('current_user_can')->alias(
|
||||||
|
static fn (string $cap) => 'manage_instructors' !== $cap
|
||||||
|
);
|
||||||
|
|
||||||
|
$foreign = new Offering(instructorId: 4, kind: Offering::KIND_GROUP_CLASS, title: 'Choir', id: 42);
|
||||||
|
|
||||||
|
$_POST = [
|
||||||
|
'usc_action' => 'update',
|
||||||
|
'offering_id' => '42',
|
||||||
|
'title' => 'Hijacked',
|
||||||
|
'kind' => Offering::KIND_GROUP_CLASS,
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->repository->shouldReceive('findById')->once()->with(42)->andReturn($foreign);
|
||||||
|
$this->repository->shouldNotReceive('update');
|
||||||
|
$this->repository->shouldReceive('findAll')->andReturn([]);
|
||||||
|
|
||||||
|
$this->render();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEditQueryParamPrefillsTheForm(): void
|
||||||
|
{
|
||||||
|
$editing = new Offering(
|
||||||
|
instructorId: 3,
|
||||||
|
kind: Offering::KIND_GROUP_CLASS,
|
||||||
|
title: 'Ballet Beginners',
|
||||||
|
description: 'For new dancers.',
|
||||||
|
capacity: 8,
|
||||||
|
termStart: '2026-09-08',
|
||||||
|
termEnd: '2026-11-10',
|
||||||
|
id: 42,
|
||||||
|
);
|
||||||
|
|
||||||
|
$_GET = ['usc_edit' => '42'];
|
||||||
|
|
||||||
|
$this->repository->shouldReceive('findById')->once()->with(42)->andReturn($editing);
|
||||||
|
$this->repository->shouldReceive('findAll')->andReturn([$editing]);
|
||||||
|
|
||||||
|
$html = $this->render();
|
||||||
|
|
||||||
|
self::assertStringContainsString('Edit Offering', $html);
|
||||||
|
self::assertStringContainsString('value="update"', $html);
|
||||||
|
self::assertStringContainsString('name="offering_id" value="42"', $html);
|
||||||
|
self::assertStringContainsString('value="Ballet Beginners"', $html);
|
||||||
|
self::assertStringContainsString('For new dancers.', $html);
|
||||||
|
self::assertStringContainsString('value="2026-09-08"', $html);
|
||||||
|
// 2026-09-08 → 2026-11-10 is ten weekly sessions.
|
||||||
|
self::assertStringContainsString('name="term_sessions" min="1" max="52" value="10"', $html);
|
||||||
|
self::assertStringContainsString('value="weekly" checked', $html);
|
||||||
|
self::assertStringContainsString('Update Offering', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNonAdminCannotLoadAnotherInstructorsOfferingIntoTheForm(): void
|
||||||
|
{
|
||||||
|
Functions\when('current_user_can')->alias(
|
||||||
|
static fn (string $cap) => 'manage_instructors' !== $cap
|
||||||
|
);
|
||||||
|
|
||||||
|
$foreign = new Offering(instructorId: 4, kind: Offering::KIND_GROUP_CLASS, title: 'Choir', id: 42);
|
||||||
|
|
||||||
|
$_GET = ['usc_edit' => '42'];
|
||||||
|
|
||||||
|
$this->repository->shouldReceive('findById')->once()->with(42)->andReturn($foreign);
|
||||||
|
$this->repository->shouldReceive('findAll')->andReturn([]);
|
||||||
|
|
||||||
|
$html = $this->render();
|
||||||
|
|
||||||
|
self::assertStringContainsString('Add Offering', $html);
|
||||||
|
self::assertStringNotContainsString('Edit Offering', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function render(): string
|
||||||
|
{
|
||||||
|
ob_start();
|
||||||
|
$this->controller->renderPage();
|
||||||
|
|
||||||
|
return (string) ob_get_clean();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,30 @@ class OfferingTest extends TestCase
|
|||||||
self::assertSame(42, $offering->id);
|
self::assertSame(42, $offering->id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testNormalizeDateAcceptsRealDates(): void
|
||||||
|
{
|
||||||
|
self::assertSame('2026-09-08', Offering::normalizeDate('2026-09-08'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNormalizeDateRejectsGarbage(): void
|
||||||
|
{
|
||||||
|
self::assertNull(Offering::normalizeDate(''));
|
||||||
|
self::assertNull(Offering::normalizeDate('not-a-date'));
|
||||||
|
self::assertNull(Offering::normalizeDate('2026-02-30'));
|
||||||
|
self::assertNull(Offering::normalizeDate('2026-09-08 10:00'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testWeeklyTermEndAddsOneWeekPerExtraSession(): void
|
||||||
|
{
|
||||||
|
self::assertSame('2026-11-10', Offering::weeklyTermEnd('2026-09-08', 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testWeeklyTermEndOfSingleSessionIsTheStartDate(): void
|
||||||
|
{
|
||||||
|
self::assertSame('2026-09-08', Offering::weeklyTermEnd('2026-09-08', 1));
|
||||||
|
self::assertSame('2026-09-08', Offering::weeklyTermEnd('2026-09-08', 0));
|
||||||
|
}
|
||||||
|
|
||||||
public function testDefaults(): void
|
public function testDefaults(): void
|
||||||
{
|
{
|
||||||
$offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir');
|
$offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir');
|
||||||
|
|||||||
Reference in New Issue
Block a user