Group class term dates, single-class embed mode, and offering editing
CI / Tests (PHP 8.2) (pull_request) Successful in 45s
CI / Tests (PHP 8.1) (pull_request) Successful in 48s
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 1m14s
CI / PHPStan (pull_request) Successful in 1m16s
CI / Tests (PHP 8.3) (pull_request) Successful in 37s
CI / Build Plugin Zip (pull_request) Has been skipped

Group class offerings now carry real dates: the add/edit form takes a
start date plus a sessions control (one-off, or weekly for N sessions;
the end date is computed as start + (N-1) weeks via
Offering::weeklyTermEnd). Dates are validated strictly (Y-m-d) and shown
in the offerings list and on the student-facing class card, including
the weekly session count.

[us_group_classes offering="<id>"] (block attribute offeringId, chosen
from a dropdown of active classes fetched from the public offerings
endpoint) restricts the page to a single class so the enrolment flow can
be embedded on a page dedicated to that class; a pinned class that is no
longer offered reports itself closed instead of falling back to the
catalog.

Offerings are now editable from the admin screen: an Edit button
prefills the shared add/edit form and saving posts usc_action=update.
Updates always preserve the original owner and currency, and non-admin
instructors can only load and update their own offerings. The form also
gains the previously missing description field and an Active toggle (the
admin-UI counterpart of the REST is_active flag) so an edit cannot wipe
data the form never collected.

Closes #59

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
2026-07-05 23:18:59 -03:00
co-authored by Claude Fable 5
parent 9d8924132c
commit cde8704267
15 changed files with 653 additions and 42 deletions
+21
View File
@@ -46,6 +46,27 @@ class Offering {
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 {
return new self(
instructorId: Val::int( $row->instructor_id ),
+67 -16
View File
@@ -22,6 +22,20 @@ class OfferingController {
$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
? $this->repository->findAll()
: $this->repository->findAll( $instructorId );
@@ -35,7 +49,23 @@ class OfferingController {
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_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 ) {
@@ -50,13 +80,20 @@ class OfferingController {
// 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
$title = sanitize_text_field( Val::string( wp_unslash( $_POST['title'] ?? '' ) ) );
$kind = sanitize_key( Val::string( wp_unslash( $_POST['kind'] ?? '' ) ) );
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 ) ) );
@@ -67,19 +104,33 @@ class OfferingController {
$duration = absint( Val::int( $_POST['duration_minutes'] ?? 0 ) );
$capacity = absint( Val::int( $_POST['capacity'] ?? 0 ) );
$this->repository->insert(
new Offering(
instructorId: $instructorId,
kind: $kind,
title: $title,
price: max( 0.0, (float) sanitize_text_field( Val::string( wp_unslash( $_POST['price'] ?? '0' ) ) ) ),
billingMode: $billingMode,
durationMinutes: $duration > 0 ? $duration : null,
allowWeekly: isset( $_POST['allow_weekly'] ),
capacity: $capacity > 0 ? $capacity : null,
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'] ?? '' ) ) ) ),
)
// Term dates: a class either meets once (term ends the day it starts)
// or repeats weekly for a set number of sessions.
$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,
title: $title,
price: max( 0.0, (float) sanitize_text_field( Val::string( wp_unslash( $_POST['price'] ?? '0' ) ) ) ),
currency: null !== $existing ? $existing->currency : 'CAD',
billingMode: $billingMode,
description: $this->nullableText( sanitize_textarea_field( Val::string( wp_unslash( $_POST['description'] ?? '' ) ) ) ),
durationMinutes: $duration > 0 ? $duration : null,
allowWeekly: isset( $_POST['allow_weekly'] ),
capacity: $capacity > 0 ? $capacity : null,
termStart: $termStart,
termEnd: $termEnd,
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'] ?? '' ) ) ) ),
isActive: isset( $_POST['is_active'] ),
id: $existing?->id,
);
// phpcs:enable WordPress.Security.NonceVerification.Missing
}