The panel's row and its two columns are divs with explicit flex rules, but
the text itself still sits in inline elements. A theme is free to take those
out of normal flow, and when it does the date and time land on the lesson
title and the status pill lands on the Cancel button. Pin position, float
and margin on the leaf elements at the same id-level specificity the rest of
the panel already uses, so a theme rule cannot lift them out of the column.
The rows behind "Show all" had the same shape of problem from the other
direction: `[hidden]` is only a UA-stylesheet rule, so the `div {
display: block }` reset that many themes still ship outranks it and the
collapsed rows render anyway. An author `!important` is the only way to win
that particular cascade.
Verified with a headless-browser harness rendering the exact markup
booking.js emits against twelve theme CSS patterns at two widths: before,
five patterns overlapped text or revealed the hidden rows; after, all pass.
Closes#149
Co-Authored-By: Claude Opus 5 <[email protected]>
A parent registers once and manages lessons for one or more children, who
need no login of their own. A child is a real wp_users row with the student
role but no usable login — so student_id keeps meaning "a WordPress user"
on every table, and booking, credits, policies and enrolments work unchanged.
A us_guardians link table maps guardian to child.
The signup form gains a parent/guardian tick that reveals a block per child,
with the account-signup questions asked per child rather than per guardian
— they describe the student, not the account holder. Signup policies are
recorded once per child with the guardian as the acceptor, which is the
record that actually means something. A family that half-creates is rolled
back entirely rather than leaving a guardian who cannot re-register.
The booking and enrolment forms gain a "Who is this for?" picker listing
children first, so the default selection is never the parent — booking for
the wrong child is correctable, quietly billing a parent for their kid's
lesson is not. POST /bookings and POST /enrollments take an optional
student_id honoured only for that child's guardian; anything else is a 403.
That check is the authorisation boundary of the feature.
Payments and credits gain a payer: the charge names the child it was for and
the guardian who owes it, so per-child reporting is unchanged while notices,
receipts and the payment step reach the parent. Credit is held by the payer,
so one child's cancellation can settle a sibling's charge, and the daily
billing scan sends a guardian one notice covering every child.
Closes#132
Co-Authored-By: Claude Opus 5 <[email protected]>
Adding availability for 5:30-6:00 PM with the lesson length left on its
60-minute default saved nothing and said nothing. A window is stored as
consecutive lesson-length slots, so one that fits no lesson splits into
none: splitByDuration() returned [], createFromWindow() inserted
nothing, and addSlot() discarded the result and re-rendered the page
unchanged.
The REST endpoint already rejected that window with a 400. The admin
form checked the same rules separately, and its copy was both laxer and
mute — an unreadable date, an end before the start, and a two-day window
were bare `return`s, and it never checked offering ownership at all, so
a crafted POST could tie a slot to another instructor's offering and
inherit their price and payment routing.
Both callers now go through WindowValidator, which returns the window or
a WP_Error explaining the refusal. The endpoint returns that error as
is; the page renders its message as a notice. handleFormAction returns
a [notice, error] pair so deletes report themselves too, and a
successful add says how many slots it created.
Two failures could also go unnoticed underneath: wpdb::insert's result
was ignored, and insert_id still holds the previous statement's id after
a failed write, so a failure looked like a success — and could become
the recurrence group of a weekly series, orphaning every later
occurrence. weeks was unbounded server-side despite the form's max=52.
availability-admin.js narrows the lesson-length choices to those that
fit the window and blocks submission when none do, which is what makes
the original mistake hard to repeat. It is a convenience: the server
validates regardless.
Closes#130
The Policies admin page listed versions but never showed what any of them
said, so revising a policy meant retyping it blind into an empty draft box.
Each version row now has a View action that renders that version's text on
the page, editable in place. A draft is saved back to itself; editing a
published or archived version branches a new draft and leaves the original
alone, because acceptances are recorded against policy_version_id and text a
student agreed to must stay exactly as they saw it.
That viewer also exposed why a studio reported the acceptance box as
unreadable — one squashed line, overlapping words, a horizontal scrollbar.
Bodies are typed into a bare textarea, so most carry no markup, and the raw
text was emitted with its blank lines intact but nothing to turn them into
paragraphs. PolicyVersion::bodyHtml() now renders every body the way
WordPress renders post content (kses, then wpautop) and feeds all three
consumers: the booking/enrolment JSON, the signup form, and the new viewer.
Bodies written with markup are unaffected.
The other half was that .us-policy-body had no CSS whatsoever and inherited
whatever the theme did with an unstyled block in a form. It is now a bounded
reading box that scrolls vertically and breaks long tokens, so a pasted URL
cannot force the page sideways and a long policy cannot push the accept
checkbox out of view. RegistrationPage was also never enqueueing the plugin
stylesheet, which is why the signup gate looked worst of all.
Closes#126Closes#127
Co-Authored-By: Claude Opus 5 <[email protected]>
A monthly group class multiplied its price by the sessions falling in the month,
the same rule private lessons use — so a class priced at 40.00 CAD meeting
weekly was billed 160.00 CAD on the 1st, and no studio could quote the price on
a class card without lying about it.
A group class is now billed its fee once for the month however many times it
meets, which is what the card quotes and what the student ticks to agree to.
Private lessons keep the per-lesson rule: their price is a per-lesson fee, and
that is why the card quotes it per lesson.
The session count still labels the month on the student's payment notice; it no
longer prices it.
Co-Authored-By: Claude Opus 5 <[email protected]>
A monthly charge covers every lesson that falls in the month, so a private
lesson's fee reads "50.00 CAD per lesson monthly" — the figure on its own would
suggest the whole month costs 50.00. A group class is enrolled in once, as a
single schedule, so its price is quoted as the monthly figure it is.
The pay agreement follows the same split: per-lesson for a monthly private
lesson, the monthly figure for a monthly group class. Weekly, full-term and
at-booking wording is unchanged.
Co-Authored-By: Claude Opus 5 <[email protected]>
Every price a student meets on the front end now carries the cadence it is
billed on — at booking, up front, weekly, monthly — so a bare amount can no
longer read as a one-off when it is a recurring charge.
Both registration forms then restate the price and require a second, separate
tick agreeing to pay it, distinct from the policy acceptances above it. The
agreed figure includes the studio HST so it matches Payment::total(), the amount
actually billed; the rate reaches the browser as a new localized `taxRate`.
A weekly reservation is charged per lesson for every week it claims, and a week
another student takes first is simply not claimed, so its total is quoted as a
ceiling ("up to 12 lessons") rather than a promise. Free offerings have nothing
to agree to and show no price block at all.
The formatting and the agreement live in one shared helper (`window.usPricing`,
registered as `us-scheduler-pricing`) so a price reads the same in the booking
form, the class catalogue and the editor preview.
Closes#124
Co-Authored-By: Claude Opus 5 <[email protected]>
Three sidebar options on the Lesson Booking block, all mirrored as shortcode
attributes and carried to the front end as data attributes on
#us-booking-app (or as omitted containers):
- Lesson type (lessonTypeId / lesson_type) pins the calendar to a single
private-lesson type: only the times bookable as it are listed, and it is
the only type bookable there, auto-selected on the registration form. A
pinned type that is no longer offered says so instead of showing an empty
calendar.
- Show the lesson-type filter (showTypeFilter / show_filter) drops the
"Show Only" control for studios that do not want it.
- Sections (displayMode / show) embeds one half of the page — the booking
calendar or the student's upcoming lessons — so the two can live on
different pages. The script skips the work belonging to a missing half:
no availability or catalog request for an upcoming-only embed, no
bookings request for a booking-only one. An unrecognised value renders
the whole page. The editor preview follows the same setting.
Also fixes the expanded filter's first lesson type sharing a line with the
"Lesson type" heading — the choices now sit in their own row beneath it.
Co-Authored-By: Claude Opus 5 <[email protected]>
The filter took a row of the booking calendar before a student had asked for
it. The view toggle and a new "Show Only" button now share one control row,
and the lesson-type list is revealed between that row and the calendar.
The list stays open across re-renders once revealed, and collapsing it leaves
the filter applied — the button keeps its active styling and carries the
number of ticked types, so a collapsed filter is never invisible.
Closes#119
Co-Authored-By: Claude Opus 5 <[email protected]>
Not every open time can be booked as every private-lesson type: a slot tied
to an offering takes that offering only, and a generic slot only takes types
whose length fits. Students had no way to see that before clicking a time.
The booking calendar now carries a lesson-type filter — a checkbox per active
private-lesson type, fetched once from GET /offerings?kind=private_lesson.
Ticking types narrows the calendar to the times bookable as one of them and
re-anchors the week view on the earliest match. The registration form's
Lesson type picker is narrowed the same way, and a lone remaining type is
pre-selected with its intake questions loaded.
Bookability is decided by offeringFitsSlot(), the client-side mirror of the
rule POST /bookings enforces; the filter is a browsing aid and the server
still validates every booking. No ticks means no filter, and the whole
control is hidden when the studio offers fewer than two private-lesson types.
Closes#117
Co-Authored-By: Claude Opus 5 <[email protected]>
The Student Registration block's "After email confirmation" panel becomes
"After registration": the page it selects is now where a newly registered
student continues to, and a new autoRedirect toggle sends them there
instead of showing the link.
Only the two finished states qualify (RegistrationPage::isRegistrationComplete):
an invited student who is now logged in, and a self-signup back from the
emailed confirmation link. A validation error, an expired confirmation
link, and the intermediate "check your email" step all stay on the page so
their message is read.
The invited-student success previously had no link at all; it gains a
"Continue to your account" one. That path deliberately has no
WordPress-login-screen fallback — pointing someone already signed in at the
login screen helps nobody — so continueUrl() distinguishes "no page chosen"
from "page chosen", and the redirect does nothing until one is picked.
Closes#115
Co-Authored-By: Claude Opus 5 <[email protected]>
The Group Classes block can be pinned to a single class via its Class
option so it can be embedded on a page dedicated to that class. On such a
page the surrounding copy already describes the class, so the card
repeated it. In single-class mode the description is now left out and the
card shows only the schedule, instructor, schedule note, price, enrolment
deadline and the enrol/withdraw controls.
The editor preview follows the same rule: BlockPreview::groupClasses()
takes the mode from the block's offeringId attribute, drops the sample
description when a class is pinned, and notes what the published page
shows. Its sample card also gained the .us-class-when and
.us-enrol-deadline elements the live markup has always rendered.
Closes#114
Co-Authored-By: Claude Opus 5 <[email protected]>
Three bug fixes for the 1.2.1 section:
- Fixed-size fields (question labels, offering titles/notes/e-transfer
email, policy titles/slugs) no longer silently fail to save when the
value exceeds its column length. The REST endpoints reject over-long
values with a 400, the admin controllers refuse to insert them, and the
form inputs carry a maxlength so the browser blocks over-long entry.
Limits are MAX_* constants on the value objects, kept in lockstep with
the schema columns.
- Students are kept out of wp-admin entirely. New StudentAdminGuard
redirects front-end-only users (no back-office capability) away from the
dashboard and hides the admin bar for them, while administrators, studio
admins, and instructors keep full access.
- The Add/Edit Offering instructor picker now includes WordPress
administrators when they act as instructors (the default single-account
setup), so a solo studio owner is selectable instead of the dropdown
being empty.
composer test (618), composer lint, composer cs all pass.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Three registration fixes reported from live use:
- Accepting an invite now keeps the student signed in. The form was
processed inside render() during the_content, so wp_set_auth_cookie()
ran after headers were sent and the cookie never persisted — the new
student was bounced back to the logged-out registration page. The
submission is now handled on template_redirect (before output) with a
post/redirect/get, so the cookie sticks and the student lands logged in.
- The "registration is by invitation only" message is now customisable via
a new block attribute (inviteOnlyMessage / shortcode invite_only_message),
falling back to the default wording when blank.
- Account-registration questions save again. dbDelta does not reliably
relax a column from NOT NULL to NULL, so sites created before account-
scope questions kept us_questions.offering_id NOT NULL and rejected
account inserts ("Column 'offering_id' cannot be null"). A one-time,
self-healing migration (guarded by its own option, not the version gate)
re-applies the nullable definition on next load.
composer test, composer lint, composer cs all pass.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Group classes now carry an optional per-class withdrawal deadline. Up to
that day a student may withdraw themselves from the class; the withdrawal
frees the seat and voids any pending payment but never issues an account
credit. After the deadline self-withdrawal closes and a studio admin must
withdraw the student by hand (the admin path is never subject to the
deadline). A blank deadline keeps self-withdrawal open indefinitely.
Also make the Add/Edit Offering form show only the fields relevant to the
selected kind: group settings for group classes, weekly reservation for
private lessons. Progressive enhancement — without JS every field renders.
- New nullable us_offerings.withdrawal_deadline column; Offering model gains
$withdrawalDeadline + isWithdrawalOpen().
- New student endpoint POST /enrollments/{id}/withdraw, gated by the deadline
(403 withdrawal_closed), ownership-checked, idempotent.
- Front-end group-class page shows a Withdraw button while open.
- No USC_VERSION bump: 1.2.0 is unreleased and accumulates schema changes
under its section, matching the scheduled-billing and credit features.
Tests: composer test (596), composer lint, composer cs all pass.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Cancelling a lesson that was already paid for now credits the student
that money instead of leaving it as a manual refund, and the daily
scheduled-billing scan applies any available credit against their due
charges before emailing the notice.
- New us_credits ledger + us_payments.credit_applied column (Payment::netDue).
- PaymentService::creditForCancelledLesson issues a per-lesson share of the
covering payment's total; wired into all three cancel paths (student
self-cancel, instructor status update, admin student-detail cancel).
- PaymentService::applyCredits draws credit down FIFO across a run's charges,
marking a fully-covered charge paid-by-credit; the notice shows the credit
applied and reduced total, and the admin queue shows net due.
- Student detail page shows a student's credit balance and history.
Ships as part of the unreleased 1.2.0 (same release as scheduled billing).
Tests: composer test (585), composer lint, composer cs all pass.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The plugin header carries 1.2.0 but CHANGELOG.md still topped out at the
untagged 1.1.3 section, and two shipped features (#104 lesson booking
detail, #105 weekly/monthly scheduled billing) were unrecorded. Neither
1.1.2 nor 1.1.3 was ever tagged, so their changes belong to the 1.2.0
release. Merge the untagged sections into a single 1.2.0 section and add
the two missing features so the release workflow publishes real notes.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The front end used the deadline only to gate the Enrol button; students had
no way to see when enrolment closes. Add an "Enrol by <date>" line to each
class card, shown while enrolment is still open, for the effective deadline
(the instructor's date, or the first class day by default).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Group classes gain an instructor-set enrolment deadline (new
us_offerings.enrollment_deadline column) that defaults to the first day of
the class (term_start). Past the deadline students can no longer self-enrol:
the enrolment endpoint rejects it (403 enrollment_closed) and the front-end
class list shows "Enrolment has closed." in place of the Enrol button.
Instructors keep a manual path: the "Add students directly" control on each
class's details page now renders for public classes too (not just
invite-only) and deliberately bypasses the deadline and capacity, so a
student can be added as a late enrolment after the class has closed. Past
the deadline the details page labels these as late enrolments.
Bumps USC_VERSION to 1.1.3 for the schema change.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
WordPress only renders the "Enable auto-updates" toggle for a plugin that
appears in the update_plugins transient's response or no_update list, which
is what sets core's update-supported flag. UpdateChecker only populated the
response side (when a newer release existed), so between releases the plugin
was absent from the transient and the toggle never showed.
provideUpdate() now returns a no_update payload (installed version, empty
package) whenever no newer release is offered — including when the release
lookup fails — so the plugin stays in the transient and the toggle appears.
The response path (one-click and unattended updates) is unchanged.
Bumps to 1.1.1 so the fix ships to installed sites via the self-updater.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Add Auth\UserName::format(), which prefers a user's first + last name, then
their nickname, avoiding display_name (which can be the login/username).
Route the instructor name through it in both the front-end offerings response
(instructor_name) and the back-end group-class summary and details views.
Tests: composer test (513), composer lint, composer cs all pass.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Group classes now carry a specific class time (alongside date and duration)
and an assigned instructor:
- Schema: add `class_time` (TIME) to `us_offerings`; `Offering` gains
`normalizeTime`/`sessionWindows`. (Rides the pending 1.0.0->1.1.0 dbDelta
upgrade, so no version bump.)
- Offering form: class-time field, plus a studio-admin instructor picker
(plain instructors always own their own classes).
- `ClassSlotReconciler`: assigning an instructor clears their open booking
slots overlapping each session and flags already-booked lessons that clash
(a booked lesson is never deleted). Uses new
`AvailabilityRepository::findOverlapping`.
- Front end: `GET /offerings` exposes `instructor_name`; the enrolment page
shows who teaches each class and when it meets.
Back-office group-class views redesigned:
- Instructor **My Group Classes** and studio-admin **Group Classes** are now
per-class summaries with enrolment counts, not flat student lists.
- Each links through (`?class_id=<id>`) to a per-class **details page**
(schedule panel, roster with payment status, and — for invite-only classes
— the add/make-available/invite-by-email controls). Invite-only membership
is managed entirely from this page.
- Invite actions are allowed for the class's owning instructor or any
`view_all_lessons` studio admin, so an owner-operator (studio admin who also
teaches) can reach every class's roster and invites from the Group Classes
page.
Tests: composer test (508), composer lint, composer cs all pass.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Add CHANGELOG.md (one section per version, newest first; the top section
always reflects the current plugin header version — release status is
purely a matter of tagging).
The Release workflow now extracts the tagged version's changelog section
and publishes it as the Gitea release body (POST on create, PATCH when the
release was pre-created via the UI). After a stable release, a new
bump-version job bumps the plugin to the next patch version, opens a fresh
changelog section, and opens a PR. Pre-release tags are skipped.
Co-Authored-By: Claude Opus 4.8 <[email protected]>