Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbc85d88f1 | ||
|
|
171b655bb8
|
||
|
|
d9dd576630 | ||
|
|
61b00c2ed3
|
||
|
|
7eb2afc6a3
|
||
|
|
8a985f04d6
|
||
|
|
da985c7f71 | ||
|
|
7c91e1eef7
|
||
|
|
f3917d0784 | ||
|
|
b508ab92f8
|
||
|
|
2a661a10ff | ||
|
|
95df78d384 | ||
|
|
fabbd35fa7 | ||
|
|
3a954bac57
|
||
|
|
907f665876 | ||
|
|
bfdc3b3380
|
||
|
|
a276d53c1b
|
||
|
|
9344ab7193
|
||
|
|
d6a515cc93 | ||
|
|
ae07930d6d |
@@ -11,6 +11,26 @@ When a `v*` tag is pushed, `.gitea/workflows/release.yml` publishes the matching
|
||||
the plugin to the next patch version and adds a fresh section here for it. Record
|
||||
each change under the current top section as you work.
|
||||
|
||||
## [1.2.4]
|
||||
|
||||
### Fixed
|
||||
- **Adding availability no longer fails in silence.** Entering a window shorter than the chosen lesson length — 5:30–6:00 PM with the lesson length left on its default of 60 minutes, say — saved nothing and said nothing: the page just reloaded, whether the window was one-off or set to repeat for 41 weeks. The **Lesson length** menu now offers only the lengths that actually fit the window you have entered, and the form refuses to submit when none of them do. Every other way the form could quietly do nothing now explains itself too — an unreadable date, an end time before the start, a window running past midnight into the next day — and a successful save says how many bookable slots it created. Deleting says whether the slot went, and tells you when one is refused because it is already booked. Availability added through the API is checked against exactly the same rules, which it previously enforced slightly differently.
|
||||
- A student's **upcoming lessons no longer pile on top of each other**. On the booking page, the lesson name, its date and time, the status badge and the **Cancel** button could render over one another instead of sitting in a tidy row — worst with a long lesson-type name, and on narrow screens, where the row had no phone layout at all. The panel now keeps its shape whatever the theme around it does, long names wrap instead of shoving the Cancel button out of the row, and on a phone the lesson details stack above the buttons.
|
||||
- The registration page **no longer dead-ends a visitor who is already signed in**. It used to greet them with "You already have an account and are logged in." and nothing else, leaving them to find their own way to the studio. They now get a link onward to the page chosen under the block's **After registration** panel, and the link names it — "Continue to Book a Lesson" rather than the vaguer wording an invited student used to see. With no page chosen, the message appears on its own as before, because sending someone who is already signed in to the sign-in screen helps nobody.
|
||||
|
||||
## [1.2.3]
|
||||
|
||||
### Changed
|
||||
- A **monthly group class is now billed its price once per month**, however many times the class meets in that month. Previously the monthly charge multiplied the price by the number of sessions in the month — a class priced at `40.00 CAD` meeting weekly was billed `160.00 CAD` on the 1st — which no studio could quote honestly on a class card. A monthly **private lesson** is unchanged: its price is a per-lesson fee and the month is still billed one fee per lesson, which is why it is quoted per lesson. Studios running a monthly group class should check the class price now reads as the monthly fee they intend to charge.
|
||||
|
||||
### Added
|
||||
- Every price a student sees now says **when** it is due. Lesson types in the booking form read `50.00 CAD at booking`, and group-class cards read `120.00 CAD up front`, `40.00 CAD weekly` or `40.00 CAD monthly` — the offering's billing mode, in the student's words. A monthly **private lesson** is quoted per lesson (`50.00 CAD per lesson monthly`), since its monthly charge covers every lesson booked that month; a monthly group class is quoted as the monthly figure it is. A free offering still just reads **Free**.
|
||||
- The **Policies** admin page can now **show you what is actually in a version**. Every row in the versions table has a **View** button that opens that version's text below the table, rendered exactly as students see it at booking and signup, whether the version is the published one, an old archived one, or a draft nobody has seen yet. The text is editable straight from the viewer, and what happens when you save depends on the version: a draft is simply updated in place, while editing a **published or archived version saves your text as a new draft version** and leaves the original exactly as students accepted it. The new draft then opens in the viewer ready to publish. Nothing a student has agreed to is ever rewritten.
|
||||
- Booking a lesson and enrolling in a class now take a **second confirmation that the student agrees to pay**. Above the Confirm button the form restates the price with its cadence, spells out how it is collected ("Charged on the 1st of each month, for that month's lessons"), adds the studio's HST so the figure matches the total actually billed, and requires a tick on "I agree to pay 56.50 CAD at booking." before it will submit — separate from, and in addition to, the studio policies the student accepts above it. Reserving a time weekly quotes the per-lesson fee and the most it can add up to ("up to 12 lessons, 678.00 CAD in total"), since a week another student takes first is simply not booked. Free offerings have nothing to agree to and show no price block.
|
||||
|
||||
### Fixed
|
||||
- Policies are **readable where students have to accept them**. A policy typed as plain paragraphs — the normal way to write one, with no HTML — was being dropped into the booking, enrolment, and signup forms unformatted, collapsing the whole document into a single squashed line with a horizontal scrollbar and words piling on top of each other. Policy text is now formatted the same way WordPress formats post content, so blank lines become real paragraphs, and the acceptance box is styled as a proper bounded reading panel: long policies scroll vertically instead of running off the side of the page, long pasted links wrap rather than forcing the page sideways, and the "I have read and agree" tick stays in view. Policies written with HTML are unaffected. The studio registration page was also missing the plugin's stylesheet entirely, which is why the problem was at its worst there.
|
||||
|
||||
## [1.2.2]
|
||||
|
||||
### Added
|
||||
|
||||
+153
-16
@@ -34,47 +34,72 @@
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.us-my-lessons {
|
||||
/*
|
||||
* The upcoming-lessons panel. Every rule here is scoped under #us-booking-app —
|
||||
* the same id-level specificity .us-slot above uses — because these rows sit in
|
||||
* whatever layout the theme provides and carry more content than a calendar
|
||||
* cell. Bare class selectors lost to theme rules on div/span/strong, which
|
||||
* collapsed the flex layout and piled the details on top of the actions.
|
||||
*/
|
||||
#us-booking-app .us-my-lessons {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.us-my-lesson {
|
||||
#us-booking-app .us-my-lesson {
|
||||
box-sizing: border-box;
|
||||
max-width: 100%;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
gap: 8px 12px;
|
||||
}
|
||||
|
||||
.us-my-lesson-info {
|
||||
/*
|
||||
* `min-width: 0` lets the title column shrink below its content width — without
|
||||
* it a long offering title cannot compress and shoves the status pill and
|
||||
* Cancel button out of the row. The flex-basis keeps the details and the
|
||||
* actions on one line while there is room, and wraps them once there is not.
|
||||
*/
|
||||
#us-booking-app .us-my-lesson-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: 1 1 14em;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.us-my-lesson-title {
|
||||
#us-booking-app .us-my-lesson-title,
|
||||
#us-booking-app .us-my-lesson-when {
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
#us-booking-app .us-my-lesson-title {
|
||||
font-size: 1.05em;
|
||||
}
|
||||
|
||||
.us-my-lesson-duration {
|
||||
#us-booking-app .us-my-lesson-duration {
|
||||
font-weight: normal;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.us-my-lesson-when {
|
||||
#us-booking-app .us-my-lesson-when {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.us-my-lesson-actions {
|
||||
#us-booking-app .us-my-lesson-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.us-show-all-lessons {
|
||||
#us-booking-app .us-show-all-lessons {
|
||||
background: transparent;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
@@ -82,11 +107,11 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.us-show-all-lessons:hover {
|
||||
#us-booking-app .us-show-all-lessons:hover {
|
||||
border-color: #888;
|
||||
}
|
||||
|
||||
.us-cancel-lesson {
|
||||
#us-booking-app .us-cancel-lesson {
|
||||
background: transparent;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
@@ -95,24 +120,26 @@
|
||||
color: #c00;
|
||||
}
|
||||
|
||||
.us-cancel-lesson:hover {
|
||||
#us-booking-app .us-cancel-lesson:hover {
|
||||
border-color: #c00;
|
||||
}
|
||||
|
||||
.us-lesson-status {
|
||||
#us-booking-app .us-lesson-status {
|
||||
display: inline-block;
|
||||
font-size: 0.85em;
|
||||
font-weight: 600;
|
||||
padding: 2px 10px;
|
||||
border-radius: 10px;
|
||||
background: #eee;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.us-lesson-status-confirmed {
|
||||
#us-booking-app .us-lesson-status-confirmed {
|
||||
background: #e2f5e5;
|
||||
color: #1a7d2e;
|
||||
}
|
||||
|
||||
.us-lesson-status-pending {
|
||||
#us-booking-app .us-lesson-status-pending {
|
||||
background: #fdf3d7;
|
||||
color: #8a6d1a;
|
||||
}
|
||||
@@ -241,6 +268,102 @@
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* The price and pay agreement on a booking / enrolment form. */
|
||||
.us-price {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
padding: 12px 16px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.us-price h4 {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.us-price p {
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.us-price-amount strong {
|
||||
font-size: 1.15em;
|
||||
}
|
||||
|
||||
.us-price-cadence {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.us-price-tax,
|
||||
.us-price-note {
|
||||
font-size: 0.9em;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.us-price-agree {
|
||||
display: block;
|
||||
margin-top: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* The cadence-carrying price on a group-class card. */
|
||||
.us-class-price {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Policy acceptance — booking, enrolment, and signup all render this markup. */
|
||||
.us-policy {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.us-policy h4 {
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
/*
|
||||
* The body is admin-authored HTML sitting inside whatever layout the theme
|
||||
* provides, so it gets an explicit reading box rather than inheriting one.
|
||||
* `overflow-wrap` breaks pasted URLs instead of letting one long token force
|
||||
* the horizontal scrollbar, and the bounded height keeps a long policy from
|
||||
* pushing the accept checkbox off the screen.
|
||||
*/
|
||||
.us-policy-body {
|
||||
box-sizing: border-box;
|
||||
max-width: 100%;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
background: #fafafa;
|
||||
white-space: normal;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
line-height: 1.5;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.us-policy-body p,
|
||||
.us-policy-body ul,
|
||||
.us-policy-body ol {
|
||||
margin: 0 0 0.75em;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.us-policy-body ul,
|
||||
.us-policy-body ol {
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
|
||||
.us-policy-body > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.us-policy-accept,
|
||||
.us-policies input[type="checkbox"] {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.us-week-grid {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -249,6 +372,20 @@
|
||||
.us-week-day {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* A lesson row carries a title, a date/time, a status pill and a button —
|
||||
* more than fits one narrow line, so stack the details above the actions
|
||||
* rather than letting them wrap into each other.
|
||||
*/
|
||||
#us-booking-app .us-my-lesson {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
#us-booking-app .us-my-lesson-info {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Shown only in block-editor previews (see BlockPreview). */
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Availability form: keep the lesson-length choices honest.
|
||||
*
|
||||
* A window is stored as consecutive lesson-length slots, so one shorter than the
|
||||
* chosen lesson length holds no slots at all and saves nothing. Picking 5:30–6:00
|
||||
* PM while the length select sat on its default of 60 minutes used to do exactly
|
||||
* that, silently. The server now rejects it with a message; this narrows the
|
||||
* choices first so the mistake is hard to make.
|
||||
*
|
||||
* This is a convenience only — AvailabilityController and the REST endpoint both
|
||||
* validate the same window server-side regardless of what happens here.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const form = document.getElementById('usc-add-availability');
|
||||
if (!form) return;
|
||||
|
||||
const startEl = document.getElementById('start_dt');
|
||||
const endEl = document.getElementById('end_dt');
|
||||
const durationEl = document.getElementById('duration_minutes');
|
||||
const warningEl = document.getElementById('usc-duration-warning');
|
||||
const submitEl = form.querySelector('input[type="submit"], button[type="submit"]');
|
||||
|
||||
if (!startEl || !endEl || !durationEl) return;
|
||||
|
||||
/**
|
||||
* Minutes between the two datetime-local inputs, or 0 when the pair is not a
|
||||
* usable window yet — empty, unparseable, backwards, or spanning two days
|
||||
* (which the server rejects on its own terms, with its own message).
|
||||
*/
|
||||
function windowMinutes() {
|
||||
const start = new Date(startEl.value);
|
||||
const end = new Date(endEl.value);
|
||||
|
||||
if (!startEl.value || !endEl.value || isNaN(start) || isNaN(end)) return 0;
|
||||
if (end <= start) return 0;
|
||||
if (startEl.value.slice(0, 10) !== endEl.value.slice(0, 10)) return 0;
|
||||
|
||||
return Math.round((end - start) / 60000);
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
const minutes = windowMinutes();
|
||||
const options = Array.from(durationEl.options);
|
||||
|
||||
// No usable window yet: leave every choice alone rather than fighting
|
||||
// someone part-way through typing a date.
|
||||
if (minutes === 0) {
|
||||
options.forEach((option) => {
|
||||
option.hidden = false;
|
||||
option.disabled = false;
|
||||
});
|
||||
setBlocked(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let fits = [];
|
||||
|
||||
options.forEach((option) => {
|
||||
const tooLong = Number(option.value) > minutes;
|
||||
|
||||
option.hidden = tooLong;
|
||||
option.disabled = tooLong;
|
||||
|
||||
if (!tooLong) fits.push(option);
|
||||
});
|
||||
|
||||
if (fits.length === 0) {
|
||||
// Nothing bookable fits, so the form cannot produce a single slot.
|
||||
setBlocked(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setBlocked(false);
|
||||
|
||||
// The selection may have just been hidden — fall back to the longest
|
||||
// length that still fits, which is what the instructor most likely wants.
|
||||
if (durationEl.selectedOptions[0] && durationEl.selectedOptions[0].disabled) {
|
||||
durationEl.value = fits.reduce(
|
||||
(longest, option) => (Number(option.value) > Number(longest.value) ? option : longest),
|
||||
fits[0]
|
||||
).value;
|
||||
}
|
||||
}
|
||||
|
||||
function setBlocked(blocked) {
|
||||
if (warningEl) warningEl.hidden = !blocked;
|
||||
if (submitEl) submitEl.disabled = blocked;
|
||||
}
|
||||
|
||||
startEl.addEventListener('change', refresh);
|
||||
startEl.addEventListener('input', refresh);
|
||||
endEl.addEventListener('change', refresh);
|
||||
endEl.addEventListener('input', refresh);
|
||||
|
||||
refresh();
|
||||
}());
|
||||
+56
-9
@@ -360,13 +360,27 @@
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// "Piano Lesson (60 min — $50.00 CAD)" / "Trial Lesson (Free)"
|
||||
// "Piano Lesson (60 min — 50.00 CAD at booking)" / "Trial Lesson (Free)"
|
||||
function offeringLabel(o) {
|
||||
const duration = o.duration_minutes ? `${o.duration_minutes} min — ` : '';
|
||||
const price = Number(o.price) > 0
|
||||
? `$${Number(o.price).toFixed(2)} ${o.currency}`
|
||||
: 'Free';
|
||||
return `${o.title} (${duration}${price})`;
|
||||
return `${o.title} (${duration}${window.usPricing.priceLabel(o)})`;
|
||||
}
|
||||
|
||||
// How many lessons a weekly reservation can claim, mirroring
|
||||
// BookingEndpoint::MAX_WEEKLY_OCCURRENCES so the quoted total is never
|
||||
// higher than the server will actually charge for.
|
||||
const MAX_WEEKLY_OCCURRENCES = 12;
|
||||
|
||||
// The open times a weekly reservation of this slot would claim: every
|
||||
// still-unbooked slot of its recurring group, capped the way the server
|
||||
// caps it. Some may be taken by another student first, so this is the
|
||||
// upper bound on what will be booked, not a guarantee.
|
||||
function weeklyOccurrences(slot) {
|
||||
if (!slot.recurrence_group) return 1;
|
||||
|
||||
const inGroup = allSlots.filter((s) => s.recurrence_group === slot.recurrence_group).length;
|
||||
|
||||
return Math.min(Math.max(inGroup, 1), MAX_WEEKLY_OCCURRENCES);
|
||||
}
|
||||
|
||||
function openRegistration(slot) {
|
||||
@@ -443,6 +457,7 @@
|
||||
<div id="us-questions"></div>
|
||||
${policies.map(policyField).join('')}
|
||||
${weekly}
|
||||
<div id="us-price-summary"></div>
|
||||
<p>
|
||||
<button type="submit" class="us-book-btn">Confirm Booking</button>
|
||||
<button type="button" id="us-cancel" class="us-cancel-btn">Back</button>
|
||||
@@ -458,6 +473,27 @@
|
||||
let questions = [];
|
||||
|
||||
const questionsBox = document.getElementById('us-questions');
|
||||
const priceBox = document.getElementById('us-price-summary');
|
||||
const weeklyEl = document.getElementById('us-weekly');
|
||||
|
||||
// What the booking will cost and the agreement to pay it, restated
|
||||
// whenever the choices that decide the amount change: the lesson type
|
||||
// carries the price, and a weekly reservation multiplies a per-lesson
|
||||
// one-time price by every week it claims. A slot tied to a type the
|
||||
// catalog no longer carries has no price to quote, so it shows nothing
|
||||
// rather than a figure it cannot stand behind.
|
||||
function renderPrice() {
|
||||
const offering = selectedId ? catalog.find((o) => Number(o.id) === selectedId) : null;
|
||||
priceBox.innerHTML = offering
|
||||
? window.usPricing.summaryHtml({
|
||||
price: offering.price,
|
||||
currency: offering.currency,
|
||||
billing_mode: offering.billing_mode,
|
||||
kind: offering.kind,
|
||||
occurrences: weeklyEl && weeklyEl.checked ? weeklyOccurrences(slot) : 1,
|
||||
})
|
||||
: '';
|
||||
}
|
||||
|
||||
function loadQuestions() {
|
||||
questions = [];
|
||||
@@ -475,10 +511,14 @@
|
||||
document.getElementById('us-offering').addEventListener('change', (e) => {
|
||||
selectedId = Number(e.target.value) || 0;
|
||||
loadQuestions();
|
||||
renderPrice();
|
||||
});
|
||||
}
|
||||
|
||||
if (weeklyEl) weeklyEl.addEventListener('change', renderPrice);
|
||||
|
||||
loadQuestions();
|
||||
renderPrice();
|
||||
|
||||
document.getElementById('us-cancel').addEventListener('click', loadSlots);
|
||||
document.getElementById('us-register-form').addEventListener('submit', (e) => {
|
||||
@@ -487,6 +527,10 @@
|
||||
showError('Please choose a lesson type.');
|
||||
return;
|
||||
}
|
||||
if (!window.usPricing.agreed(e.target)) {
|
||||
showError(window.usPricing.AGREE_REQUIRED);
|
||||
return;
|
||||
}
|
||||
submitBooking(e.target, slot, selectedId, questions);
|
||||
});
|
||||
}
|
||||
@@ -538,16 +582,19 @@
|
||||
function lessonRowHtml(l) {
|
||||
const title = l.offering_title ? escHtml(String(l.offering_title)) : 'Lesson';
|
||||
const duration = l.duration_minutes ? ` <span class="us-my-lesson-duration">(${escHtml(String(l.duration_minutes))} min)</span>` : '';
|
||||
// The two columns are divs, not spans: as spans the layout only held up
|
||||
// while the stylesheet's display:flex won, and a theme rule on span
|
||||
// collapsed the row onto itself.
|
||||
return `
|
||||
<div class="us-my-lesson">
|
||||
<span class="us-my-lesson-info">
|
||||
<div class="us-my-lesson-info">
|
||||
<strong class="us-my-lesson-title">${title}${duration}</strong>
|
||||
<span class="us-my-lesson-when">${escHtml(dayLabel(dayKey(l.start_dt)))} · ${escHtml(timeOf(l.start_dt))}–${escHtml(timeOf(l.end_dt))}</span>
|
||||
</span>
|
||||
<span class="us-my-lesson-actions">
|
||||
</div>
|
||||
<div class="us-my-lesson-actions">
|
||||
<span class="us-lesson-status us-lesson-status-${escHtml(String(l.status))}">${escHtml(lessonStatusLabel(String(l.status)))}</span>
|
||||
<button type="button" class="us-cancel-lesson" data-lesson-id="${l.id}">Cancel</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
|
||||
@@ -152,7 +152,7 @@
|
||||
${o.instructor_name ? `<p class="us-class-instructor">With ${escHtml(o.instructor_name)}</p>` : ''}
|
||||
${o.schedule_note ? `<p>${escHtml(o.schedule_note)}</p>` : ''}
|
||||
${!singleOfferingId && o.description ? `<p>${escHtml(o.description)}</p>` : ''}
|
||||
<p>${escHtml(Number(o.price).toFixed(2))} ${escHtml(o.currency)}</p>
|
||||
<p class="us-class-price">${escHtml(window.usPricing.priceLabel(o))}</p>
|
||||
${!enrolledMap.has(Number(o.id)) && isEnrollmentOpen(o) && enrolmentDeadline(o)
|
||||
? `<p class="us-enrol-deadline">Enrol by ${escHtml(formatDate(enrolmentDeadline(o)))}</p>`
|
||||
: ''}
|
||||
@@ -204,6 +204,7 @@
|
||||
<form id="us-enrol-form">
|
||||
${questions.map(questionField).join('')}
|
||||
${policies.map(policyField).join('')}
|
||||
${window.usPricing.summaryHtml(offering)}
|
||||
<p>
|
||||
<button type="submit" class="us-enrol-btn">Confirm Enrolment</button>
|
||||
<button type="button" id="us-group-cancel" class="us-cancel-btn">Back</button>
|
||||
@@ -214,6 +215,10 @@
|
||||
document.getElementById('us-group-cancel').addEventListener('click', loadClasses);
|
||||
document.getElementById('us-enrol-form').addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
if (!window.usPricing.agreed(e.target)) {
|
||||
showError(window.usPricing.AGREE_REQUIRED);
|
||||
return;
|
||||
}
|
||||
submitEnrolment(e.target, offering, questions);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/* global usScheduler */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// Cadence wording for each offering billing mode, in the phrasing a student
|
||||
// sees beside a price. Mirrors Offering::VALID_BILLING_MODES.
|
||||
const CADENCE = {
|
||||
one_time: 'at booking',
|
||||
full_term: 'up front',
|
||||
weekly: 'weekly',
|
||||
monthly: 'monthly',
|
||||
};
|
||||
|
||||
// How each cadence is actually collected, spelled out beneath the price so
|
||||
// the one-word cadence is never the only thing a student has to go on.
|
||||
const CADENCE_NOTE = {
|
||||
one_time: 'Charged once, when you book.',
|
||||
full_term: 'Charged once, up front, for the whole term.',
|
||||
weekly: 'Charged for each lesson, 24 hours before it starts.',
|
||||
monthly: 'Charged on the 1st of each month, for that month’s lessons.',
|
||||
};
|
||||
|
||||
// The billing modes whose price is a per-lesson fee billed again and again,
|
||||
// rather than a single charge. Mirrors Offering::SCHEDULED_BILLING_MODES.
|
||||
const RECURRING = ['weekly', 'monthly'];
|
||||
|
||||
function escHtml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function mode(billingMode) {
|
||||
return CADENCE[billingMode] ? billingMode : 'one_time';
|
||||
}
|
||||
|
||||
// A monthly charge rolls up every lesson that falls in the month, so a
|
||||
// private lesson's monthly price is quoted *per lesson* — the fee is
|
||||
// multiplied by the lessons booked that month. A group class is enrolled in
|
||||
// once, as one schedule, so its monthly figure is quoted as it stands.
|
||||
function isPerLessonMonthly(billingMode, kind) {
|
||||
return 'monthly' === billingMode && 'group_class' !== kind;
|
||||
}
|
||||
|
||||
// "50.00 CAD" — amount then currency code, the format used throughout the
|
||||
// ledger, receipts and payment notices.
|
||||
function money(amount, currency) {
|
||||
return `${(Number(amount) || 0).toFixed(2)} ${String(currency || '')}`.trim();
|
||||
}
|
||||
|
||||
// The studio's HST rate as a percentage, frozen onto every payment at
|
||||
// booking time (comped students are the one exception — they are not taxed).
|
||||
function taxRate() {
|
||||
return Number(usScheduler.taxRate) || 0;
|
||||
}
|
||||
|
||||
// Tax on a pre-tax amount, rounded the same way PaymentService does.
|
||||
function tax(amount) {
|
||||
return Math.round((Number(amount) || 0) * taxRate()) / 100;
|
||||
}
|
||||
|
||||
function total(amount) {
|
||||
return (Number(amount) || 0) + tax(amount);
|
||||
}
|
||||
|
||||
// "50.00 CAD at booking" / "50.00 CAD per lesson monthly" / "Free" — the
|
||||
// catalogue label, always carrying the cadence so a price is never shown
|
||||
// without saying when it is due.
|
||||
function priceLabel(offering) {
|
||||
const price = Number(offering.price) || 0;
|
||||
if (price <= 0) {
|
||||
return 'Free';
|
||||
}
|
||||
|
||||
const billingMode = mode(offering.billing_mode);
|
||||
const perLesson = isPerLessonMonthly(billingMode, offering.kind) ? 'per lesson ' : '';
|
||||
|
||||
return `${money(price, offering.currency)} ${perLesson}${CADENCE[billingMode]}`;
|
||||
}
|
||||
|
||||
// The price block shown on a booking/enrolment form, followed by the
|
||||
// agreement the student must tick to confirm they will pay it. A free
|
||||
// offering has nothing to agree to, so it renders nothing at all.
|
||||
//
|
||||
// opts: { price, currency, billing_mode, kind, occurrences }
|
||||
// `occurrences` is how many lessons a one-time price is charged for in this
|
||||
// one registration (a weekly reservation claims several at once); it is
|
||||
// ignored for the other modes, whose price is charged per period regardless.
|
||||
function summaryHtml(opts) {
|
||||
const price = Number(opts.price) || 0;
|
||||
if (price <= 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const billingMode = mode(opts.billing_mode);
|
||||
const currency = opts.currency;
|
||||
const each = total(price);
|
||||
const count = 'one_time' === billingMode ? Math.max(1, Number(opts.occurrences) || 1) : 1;
|
||||
|
||||
const taxLine = taxRate() > 0
|
||||
? `<p class="us-price-tax">${escHtml(`Plus ${taxRate()}% HST — ${money(each, currency)}${count > 1 ? ' per lesson' : ''}.`)}</p>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="us-price">
|
||||
<h4>Price</h4>
|
||||
<p class="us-price-amount">
|
||||
<strong>${escHtml(money(price, currency))}</strong>
|
||||
<span class="us-price-cadence">${escHtml(cadenceLabel(billingMode, opts.kind))}</span>
|
||||
</p>
|
||||
${taxLine}
|
||||
<p class="us-price-note">${escHtml(count > 1
|
||||
? 'Charged once, when you book — for every week reserved.'
|
||||
: CADENCE_NOTE[billingMode])}</p>
|
||||
<label class="us-price-agree">
|
||||
<input type="checkbox" class="us-price-accept" required>
|
||||
${escHtml(agreeText(each, currency, billingMode, count, opts.kind))}
|
||||
</label>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// The cadence as it reads beside an amount: a private lesson billed monthly
|
||||
// adds "per lesson", since the month's charge is that fee times the lessons
|
||||
// it covers.
|
||||
function cadenceLabel(billingMode, kind) {
|
||||
return isPerLessonMonthly(billingMode, kind)
|
||||
? `per lesson ${CADENCE[billingMode]}`
|
||||
: CADENCE[billingMode];
|
||||
}
|
||||
|
||||
// What the student is ticking: the amount actually billed (tax included),
|
||||
// and when. A weekly reservation is charged per lesson for every week it
|
||||
// claims, and the claim can come up short when another student takes one of
|
||||
// the times first — so its total is stated as a ceiling, never a promise.
|
||||
function agreeText(each, currency, billingMode, count, kind) {
|
||||
if (RECURRING.indexOf(billingMode) !== -1) {
|
||||
// A monthly group class is enrolled in once and quoted as it stands;
|
||||
// everything else recurring is a per-lesson fee.
|
||||
return 'monthly' === billingMode && !isPerLessonMonthly(billingMode, kind)
|
||||
? `I agree to pay ${money(each, currency)} monthly.`
|
||||
: `I agree to pay ${money(each, currency)} per lesson, billed ${CADENCE[billingMode]}.`;
|
||||
}
|
||||
|
||||
if (count > 1) {
|
||||
return `I agree to pay ${money(each, currency)} per lesson at booking — `
|
||||
+ `up to ${count} lessons, ${money(each * count, currency)} in total.`;
|
||||
}
|
||||
|
||||
return `I agree to pay ${money(each, currency)} ${CADENCE[billingMode]}.`;
|
||||
}
|
||||
|
||||
// Whether the payment agreement has been ticked. A form without one (a free
|
||||
// offering) has nothing outstanding, so it counts as agreed.
|
||||
function agreed(root) {
|
||||
const box = root.querySelector('.us-price-accept');
|
||||
|
||||
return !box || box.checked;
|
||||
}
|
||||
|
||||
// Shared by the booking and group-class flows so a price reads the same
|
||||
// wherever a student meets it.
|
||||
window.usPricing = {
|
||||
priceLabel,
|
||||
summaryHtml,
|
||||
agreed,
|
||||
AGREE_REQUIRED: 'Please confirm you agree to pay the amount shown.',
|
||||
};
|
||||
}());
|
||||
@@ -132,7 +132,7 @@ recorded in `us_policy_acceptances` with `registration_type = account` and
|
||||
The block's **After registration** panel picks the page a student continues to once
|
||||
registration finishes, and whether they get there by hand or automatically.
|
||||
|
||||
- **Sign-in page** (`loginPageId` / `login_page_id`) — the target of the "Sign in to your account" link shown after email confirmation (`?us_confirmed=1|ready`, falling back to the WordPress login screen) and of the "Continue to your account" link an invited student sees on the spot (`?us_registered=invite`; no link at all with no page chosen, since an already-signed-in student has no use for the login screen).
|
||||
- **Sign-in page** (`loginPageId` / `login_page_id`) — the target of the "Sign in to your account" link shown after email confirmation (`?us_confirmed=1|ready`, falling back to the WordPress login screen) and of the **"Continue to _<page title>_"** link every **logged-in** visitor gets (`RegistrationPage::continueLink()`): an invited student who just finished signing up (`?us_registered=invite`), and anyone who simply arrives at the registration page already signed in. The link names the chosen page (via `get_the_title()`) so the visitor knows where it goes; an untitled page falls back to "Continue to your account" rather than reading "Continue to ". Neither gets the WordPress-login-screen fallback — with no page chosen there is no link at all, since sending someone already signed in to the login screen is the same dead end with extra steps.
|
||||
- **Redirect automatically** (`autoRedirect`, block only) — sends the student to that page instead of showing the link, via `BlockRegistrar::maybeAutoRedirect()` on `template_redirect`. It fires only on those two finished states (`RegistrationPage::isRegistrationComplete()`), so the "check your email" step, a validation error, and an `expired` confirmation link are always shown rather than redirected past. With no page chosen nothing happens — there is deliberately no login-screen fallback for the redirect. See `editor-blocks.md`.
|
||||
|
||||
## Token Redirect
|
||||
|
||||
@@ -25,8 +25,9 @@ A slot's `duration_minutes` is matched against the offering a student picks: a
|
||||
`AvailabilitySlot::splitByDuration()` chunks a submitted window into consecutive
|
||||
`duration_minutes` slots; `AvailabilityRepository::createFromWindow()` persists
|
||||
one row per chunk. A trailing remainder shorter than the lesson length is
|
||||
dropped. Windows must start and end on the same day and fit at least one lesson
|
||||
(REST responds `400 invalid_window` otherwise; the admin form is a no-op).
|
||||
dropped. Windows must start and end on the same day and fit at least one lesson;
|
||||
both the REST endpoint and the admin form reject one that does not, with a
|
||||
message saying so (see **REST API** below).
|
||||
`AvailabilityRepository::splitOversizedWindows()` is a data migration (run by
|
||||
`Installer` on activation or version change) that rewrites pre-split rows.
|
||||
|
||||
@@ -44,6 +45,27 @@ Instructors access **My Availability** in wp-admin (`?page=us-availability`).
|
||||
- Bulk delete: the list view has a checkbox per unbooked slot (with a select-all header checkbox) and a **Delete selected** button (`usc_action=bulk_delete`, `slot_ids[]`); each id is ownership-checked, and booked slots are refused at the repository level
|
||||
- Current slots can be shown as a **weekly calendar** (the default, navigated with `usc_week=Y-m-d`) or a **list** (`usc_view=list`); the grid honours the site's `start_of_week` option via `Availability\WeekCalendar`
|
||||
|
||||
### Feedback
|
||||
Every submitted action reports its outcome as a wp-admin notice — a success
|
||||
notice naming the number of slots created or deleted, or an error explaining the
|
||||
refusal. `AvailabilityController::handleFormAction()` returns a
|
||||
`[$notice, $error]` pair that `templates/admin/availability.php` renders.
|
||||
|
||||
This matters because the form used to fail **silently**: a window shorter than
|
||||
the chosen lesson length splits into no slots, so nothing was written, nothing
|
||||
was said, and the page simply reloaded. Submitting 5:30–6:00 PM with the length
|
||||
select on its 60-minute default was the reported case. Invalid datetimes, an end
|
||||
before the start, a window spanning two days, and an offering belonging to
|
||||
another instructor were all silent in the same way.
|
||||
|
||||
### Lesson-length choices
|
||||
`assets/js/availability-admin.js` (enqueued by `AdminMenu::enqueueAssets()` on
|
||||
this screen only) hides any lesson length longer than the entered window, falls
|
||||
back to the longest one that still fits when the current pick is hidden, and
|
||||
disables the submit button when nothing fits. It is a convenience, not a
|
||||
guarantee — the server validates the same window regardless. The choices come
|
||||
from `AvailabilitySlot::DURATION_CHOICES`.
|
||||
|
||||
## Public Calendar
|
||||
The front-end booking shortcode renders open slots from `GET /availability`
|
||||
either as an agenda-style list grouped by day or as a **weekly calendar** with
|
||||
@@ -62,12 +84,29 @@ with the **Show Only** lesson-type filter — see `lesson-booking.md`.
|
||||
`GET` supports query params: `instructor_id`, `offering_id`, `duration_minutes`, `from` (datetime), `to` (datetime).
|
||||
Slots whose start has already passed are never returned.
|
||||
|
||||
`POST` validates `start_dt`/`end_dt` (admin form and REST alike) via
|
||||
`AvailabilitySlot::normalizeDateTime()`: the canonical `Y-m-d H:i[:s]` and HTML
|
||||
`datetime-local` (`Y-m-d\TH:i[:s]`) forms are normalised to `Y-m-d H:i:s`;
|
||||
anything else — or an end not after the start — is rejected (REST responds
|
||||
`400 invalid_datetime`; the admin form is a no-op). A valid window is stored as
|
||||
`POST` runs every submitted window — admin form and REST alike — through
|
||||
`Availability\WindowValidator`, which returns either the window ready to persist
|
||||
or a `WP_Error`. The REST endpoint returns that error directly (its `status`
|
||||
data makes it a 400); the admin screen shows `get_error_message()` in a notice.
|
||||
Sharing one validator is deliberate: the two paths previously checked the same
|
||||
rules separately, and the admin copy was both laxer (no offering-ownership
|
||||
check) and mute (a bare `return` on every rejection).
|
||||
|
||||
| Rejection | Code |
|
||||
|---|---|
|
||||
| Start or end not a real datetime | `invalid_datetime` |
|
||||
| End at or before the start | `invalid_datetime` |
|
||||
| Window spans two days | `invalid_window` |
|
||||
| Window shorter than the lesson length (so it holds no slots) | `invalid_window` |
|
||||
| Offering missing, or owned by another instructor | `invalid_offering` |
|
||||
|
||||
`start_dt`/`end_dt` are normalised by `AvailabilitySlot::normalizeDateTime()`:
|
||||
the canonical `Y-m-d H:i[:s]` and HTML `datetime-local` (`Y-m-d\TH:i[:s]`) forms
|
||||
become `Y-m-d H:i:s`; anything else is rejected. A valid window is stored as
|
||||
lesson-length slots and `201` returns `{ "ids": [...] }` for every row created.
|
||||
`weeks` is clamped to `AvailabilitySlot::MAX_WEEKLY_OCCURRENCES` in the
|
||||
repository, so the form's `max` cannot be bypassed by posting directly. A write
|
||||
that fails entirely returns `500 not_saved` rather than a `201` listing no ids.
|
||||
|
||||
Times are displayed in 12-hour AM/PM form in the booking calendar and wp-admin
|
||||
lists.
|
||||
@@ -78,6 +117,8 @@ lists.
|
||||
- Week bucketing: `Unsupervised\Schedular\Availability\WeekCalendar`
|
||||
- Admin controller: `Unsupervised\Schedular\Availability\AvailabilityController`
|
||||
- REST endpoint: `Unsupervised\Schedular\Availability\AvailabilityEndpoint`
|
||||
- Shared window validation: `Unsupervised\Schedular\Availability\WindowValidator`
|
||||
- Admin form script: `assets/js/availability-admin.js`, enqueued by `AdminMenu::enqueueAssets()`
|
||||
|
||||
## Tests
|
||||
- `tests/Unit/Availability/AvailabilityControllerTest.php`
|
||||
@@ -85,3 +126,4 @@ lists.
|
||||
- `tests/Unit/Availability/AvailabilitySlotTest.php`
|
||||
- `tests/Unit/Availability/AvailabilityEndpointTest.php`
|
||||
- `tests/Unit/Availability/WeekCalendarTest.php`
|
||||
- `tests/Unit/Availability/WindowValidatorTest.php`
|
||||
|
||||
@@ -38,12 +38,13 @@ shows "You are enrolled in this class." instead of the Enrol button (the
|
||||
server would reject the duplicate with `409 already_enrolled` regardless — a
|
||||
cancelled enrolment does not block re-enrolling).
|
||||
|
||||
1. Student opens a group class from the offering catalog.
|
||||
1. Student opens a group class from the offering catalog. Each class card shows its price with the **cadence** it is billed on — `120.00 CAD up front`, `40.00 CAD monthly`, and so on.
|
||||
2. Student answers the offering's questions (`GET /offerings/{id}/questions`).
|
||||
3. Student accepts the current published policy versions (`GET /policies`) — required to continue.
|
||||
4. Full-term payment is taken per the student's billing method (card by default; `pending` for e-transfer; skipped for comp). See `payments.md`.
|
||||
5. `POST /enrollments` creates the enrolment (`status = active`), records answers and policy acceptances, and links the payment — but only if the offering's `capacity` has not been reached.
|
||||
6. On successful payment (or comp) a receipt is emailed.
|
||||
4. The enrolment form restates the price (with HST) and requires a second, separate agreement to pay that amount before it will submit. See **Price Display and the Pay Agreement** in `payments.md`.
|
||||
5. Full-term payment is taken per the student's billing method (card by default; `pending` for e-transfer; skipped for comp). See `payments.md`.
|
||||
6. `POST /enrollments` creates the enrolment (`status = active`), records answers and policy acceptances, and links the payment — but only if the offering's `capacity` has not been reached.
|
||||
7. On successful payment (or comp) a receipt is emailed.
|
||||
|
||||
Capacity is enforced at enrolment time by counting `active` rows for the offering;
|
||||
a class at capacity rejects further enrolments.
|
||||
|
||||
@@ -25,11 +25,12 @@ Students register for a private lesson by choosing an offering, picking a time (
|
||||
3. For a `weekly` reservation, the same weekday/time is held for the rest of the offering's term.
|
||||
4. Student answers the offering's questions (`GET /offerings/{id}/questions`).
|
||||
5. Student accepts the current published policy versions (`GET /policies`) — required to continue.
|
||||
6. Payment is taken per the student's billing method (card by default; `pending` for e-transfer; skipped for comp). See `payments.md`.
|
||||
7. `POST /bookings` creates the lesson row(s) (`status = pending`), records answers and policy acceptances, marks `us_availability.is_booked = 1`, and links the payment. A booking with nothing owed (a free offering) creates no payment and is `confirmed` immediately.
|
||||
8. On successful payment (or comp) the lesson is `confirmed` and a receipt is emailed.
|
||||
9. Instructor sees the booking under **My Lessons** and may update status via `PATCH /bookings/{id}/status`.
|
||||
10. The booking page also shows the student their upcoming lessons (`GET /bookings`) — each with the booked offering's name and length, when it happens, a per-lesson status badge (pending payment / confirmed), and a **Cancel** button. Only the soonest five are shown; a **Show all** control reveals the rest. `GET /bookings` includes `offering_title` and `duration_minutes` for each lesson so the list needs no extra request.
|
||||
6. Student is shown what the booking costs — the offering's price with its **cadence** (at booking / up front / weekly / monthly), plus HST — and must tick a second, separate agreement to pay that amount before the form will submit. A weekly reservation quotes the per-lesson fee and the ceiling on the total it can claim. A free offering shows no price block. See **Price Display and the Pay Agreement** in `payments.md`.
|
||||
7. Payment is taken per the student's billing method (card by default; `pending` for e-transfer; skipped for comp). See `payments.md`.
|
||||
8. `POST /bookings` creates the lesson row(s) (`status = pending`), records answers and policy acceptances, marks `us_availability.is_booked = 1`, and links the payment. A booking with nothing owed (a free offering) creates no payment and is `confirmed` immediately.
|
||||
9. On successful payment (or comp) the lesson is `confirmed` and a receipt is emailed.
|
||||
10. Instructor sees the booking under **My Lessons** and may update status via `PATCH /bookings/{id}/status`.
|
||||
11. The booking page also shows the student their upcoming lessons (`GET /bookings`) — each with the booked offering's name and length, when it happens, a per-lesson status badge (pending payment / confirmed), and a **Cancel** button. Only the soonest five are shown; a **Show all** control reveals the rest. `GET /bookings` includes `offering_title` and `duration_minutes` for each lesson so the list needs no extra request.
|
||||
|
||||
## Lesson-Type Filter
|
||||
Not every open slot can be booked as every private-lesson type — a slot tied to
|
||||
@@ -153,6 +154,7 @@ instructor may only open their own lessons; the studio **Scheduler** may open an
|
||||
- Admin lesson detail presenter: `Unsupervised\Schedular\Booking\LessonDetail` (per-lesson intake answers + policy acceptances), template `templates/admin/lesson-detail.php`
|
||||
- REST endpoint: `Unsupervised\Schedular\Booking\BookingEndpoint`
|
||||
- Frontend: `Unsupervised\Schedular\Booking\BookingPage`, `Unsupervised\Schedular\Auth\LoginPage`
|
||||
- Upcoming-lessons panel: rendered client-side into `#us-my-lessons` by `assets/js/booking.js` (`lessonRowHtml`/`renderMyLessons`), mirrored for the editor by `BlockPreview::upcomingLessons()` — keep the two markup shapes in step.
|
||||
|
||||
> **Payment seam:** a priced booking is created with `status = pending` and its
|
||||
> payment linked via `payment_id`; the lesson is confirmed when the payment is
|
||||
@@ -160,6 +162,14 @@ instructor may only open their own lessons; the studio **Scheduler** may open an
|
||||
> Unpriced bookings skip the seam entirely and are confirmed at creation.
|
||||
> `GET /policies?scope=booking` returns just the booking-gate policies the form
|
||||
> must collect.
|
||||
>
|
||||
> **Frontend CSS scoping:** every rule for the booking page's own markup is
|
||||
> written under `#us-booking-app` (`assets/css/frontend.css`). These panels sit
|
||||
> inside whatever layout the active theme provides, and bare class selectors lose
|
||||
> to theme rules on `div`/`span`/`strong` — which flattens the flex layout and
|
||||
> renders the lesson details on top of the actions. The row's two columns are
|
||||
> `div`s for the same reason: the layout must not depend on overriding the
|
||||
> inline default. New booking-page rules should follow both conventions.
|
||||
|
||||
## Tests
|
||||
- `tests/Unit/Booking/BookingRepositoryTest.php`
|
||||
|
||||
@@ -33,7 +33,12 @@ 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`.
|
||||
- `weekly` — **not** charged at registration; a pending payment for one lesson's fee is generated **24 hours before each lesson** by the daily billing scan.
|
||||
- `monthly` — **not** charged at registration; on the **1st of each month** a single pending payment is generated for every lesson that falls in that month (4 lessons ⇒ 4 × fee).
|
||||
- `monthly` — **not** charged at registration; on the **1st of each month** a single pending payment is generated for that month. A **private lesson**'s price is a per-lesson fee, so the month is billed (#lessons in the month) × fee; a **group class**'s price is the monthly fee itself, billed once for the month however many times the class meets in it.
|
||||
|
||||
Students see the mode as a **cadence** beside every price on the front end — *at
|
||||
booking*, *up front*, *weekly*, *monthly* — and confirm it explicitly before a
|
||||
booking or enrolment goes through. See **Price Display and the Pay Agreement** in
|
||||
`payments.md`.
|
||||
|
||||
`weekly` and `monthly` are *scheduled* billing (`Offering::isScheduledBilling()`): the
|
||||
booking/enrolment succeeds with no payment step, and payments are created later by the
|
||||
|
||||
@@ -98,6 +98,59 @@ After booking, the destination on a payment can be corrected per booking:
|
||||
| `created_at` | DATETIME | Insertion time |
|
||||
| `paid_at` | DATETIME | When marked `paid`; NULL otherwise |
|
||||
|
||||
## Price Display and the Pay Agreement
|
||||
Every price a student is shown on the front end carries its **cadence** — the
|
||||
offering's `billing_mode` in the words the student needs:
|
||||
|
||||
| `billing_mode` | Shown as | Explained beneath as |
|
||||
|----------------|-----------------------------------|------------------------------------------------------------|
|
||||
| `one_time` | `at booking` | Charged once, when you book. |
|
||||
| `full_term` | `up front` | Charged once, up front, for the whole term. |
|
||||
| `weekly` | `weekly` | Charged for each lesson, 24 hours before it starts. |
|
||||
| `monthly` | `per lesson monthly` / `monthly` | Charged on the 1st of each month, for that month's lessons.|
|
||||
|
||||
So a lesson type reads `50.00 CAD at booking` in the booking form's type picker,
|
||||
and a group class card reads `120.00 CAD up front`. A free offering shows `Free`.
|
||||
|
||||
**`monthly` reads differently per offering kind, because it *bills* differently.**
|
||||
A private lesson's price is a per-lesson fee and its monthly charge is that
|
||||
month's lessons × the fee, so the fee is quoted **per lesson**
|
||||
(`50.00 CAD per lesson monthly`). A monthly group class is priced **per month** —
|
||||
`ScheduledBillingRunner::billGroupMonthly()` charges the fee once for the month
|
||||
however many times the class meets in it — so its figure is quoted as it stands
|
||||
(`120.00 CAD monthly`). The display split is `isPerLessonMonthly()` in
|
||||
`assets/js/pricing.js`; the billing split is the one place the monthly rule
|
||||
differs between the two kinds.
|
||||
|
||||
Before a booking or enrolment can be submitted, the form shows the price again as
|
||||
a summary block with a **required agreement checkbox** — the second confirmation,
|
||||
distinct from the policy acceptances above it:
|
||||
|
||||
> ☐ I agree to pay 56.50 CAD at booking.
|
||||
|
||||
The agreed figure is the amount actually billed, so the studio **HST rate** is
|
||||
added to it (`usScheduler.taxRate`, localized from `us_hst_rate`) and broken out
|
||||
above the checkbox — matching the total `Payment::total()` charges. A comped
|
||||
student is not taxed and is not charged at all, so for them the quoted figure is
|
||||
an upper bound. A free offering has nothing to agree to and shows no block.
|
||||
|
||||
Cadence-specific wording:
|
||||
|
||||
- **Weekly reservation of a `one_time` lesson type** — the fee is charged once per
|
||||
week claimed, so the agreement states the per-lesson amount and the total as a
|
||||
ceiling ("up to 12 lessons, 678.00 CAD in total"). The occurrence count mirrors
|
||||
`BookingEndpoint::MAX_WEEKLY_OCCURRENCES`; a slot another student takes first is
|
||||
simply not claimed, so the real charge can come in under it.
|
||||
- **`weekly` / `monthly`** — nothing is taken at registration, so the agreement is
|
||||
to the recurring charge: "I agree to pay 56.50 CAD per lesson, billed monthly."
|
||||
A monthly **group class** agrees to its monthly figure instead ("I agree to pay
|
||||
138.00 CAD monthly."), matching how its price is quoted on the card.
|
||||
|
||||
All of this lives in `assets/js/pricing.js` (`window.usPricing`), shared by the
|
||||
booking and group-class flows so a price reads the same wherever it is met. The
|
||||
script is registered as `us-scheduler-pricing` and is a dependency of both
|
||||
`us-scheduler` and `us-scheduler-group`.
|
||||
|
||||
## Payment Flow
|
||||
1. During registration the front-end calls `POST /payments/intent` — but only when the registration response carried a `payment` summary (unpriced registrations return `payment: null` and skip the payment step). The intent call creates a Stripe PaymentIntent for a `card` student and returns the client secret. (`etransfer` returns a `pending` payment; `comp` returns none.)
|
||||
2. The browser confirms the card payment with Stripe.
|
||||
@@ -139,8 +192,10 @@ See `payment-reporting.md` for the monthly report and CSV export endpoints.
|
||||
- Receipts: `Unsupervised\Schedular\Payment\ReceiptMailer`
|
||||
- Settings page: `Unsupervised\Schedular\Payment\StudioSettings`
|
||||
- REST endpoint: `Unsupervised\Schedular\Payment\PaymentEndpoint`
|
||||
- Front-end price display + pay agreement: `assets/js/pricing.js` (`window.usPricing`), registered and localized with `taxRate` by `Unsupervised\Schedular\ShortcodeRegistrar`
|
||||
|
||||
## Tests
|
||||
- `tests/Unit/ShortcodeRegistrarTest.php` (pricing helper registration + localized `taxRate`)
|
||||
- `tests/Unit/Payment/PaymentRepositoryTest.php`
|
||||
- `tests/Unit/Payment/PaymentTest.php`
|
||||
- `tests/Unit/Payment/StripeGatewayTest.php`
|
||||
|
||||
@@ -40,14 +40,22 @@ The studio admin drafts, versions, and publishes policies (e.g. cancellation, pa
|
||||
|
||||
## Versioning & Acceptance Rules
|
||||
- Editing a published policy creates a new `draft` version; the old version stays `published` until the draft is published.
|
||||
- Editing a `draft` version rewrites it in place — nobody has accepted it yet, so there is nothing to preserve and no new version is created. `PATCH /policies/{id}/versions/{vid}` allows only this case; the admin page also accepts an edit to a `published` or `archived` version and branches a new draft from it.
|
||||
- Publishing a draft sets it `published`, stamps `published_at`, archives the prior version, and points `us_policies.current_version_id` at it.
|
||||
- The registration gate requires acceptance of the `current_version_id` of every policy. Because acceptance is tied to `policy_version_id`, a newly published version is unaccepted and must be re-accepted at the student's next booking.
|
||||
|
||||
## Admin Interface
|
||||
**Policies** in wp-admin (`manage_policies`, studio admin only):
|
||||
- Create a policy; draft and edit version bodies
|
||||
- Create a policy; draft version bodies
|
||||
- View the content of any version (`?page=us-policies&policy_id={id}&version_id={vid}`), whatever its status
|
||||
- Edit from the viewer: a draft is saved in place; editing a published or archived version instead saves the text as a **new draft version** (the viewer follows to it), so text students have already accepted is never rewritten
|
||||
- Publish a draft version; view acceptance history per version
|
||||
|
||||
## Rendering a Policy Body
|
||||
Bodies are typed into a plain textarea, so most are written as blank-line-separated prose with no markup. `PolicyVersion::bodyHtml()` is the single render path — `wp_kses_post()` then `wpautop()`, the same treatment WordPress gives post content — so unmarked-up text arrives as real paragraphs and bodies that do carry markup are left alone. It feeds the booking/enrolment JSON (`GET /policies`), the signup form, and the admin version viewer, which therefore previews exactly what students see.
|
||||
|
||||
The acceptance markup (`.us-policy` / `.us-policy-body`) is styled in `assets/css/frontend.css` as a bounded, vertically scrolling reading box with `overflow-wrap: break-word`, so a long policy or a pasted URL cannot force a horizontal scrollbar or push the accept checkbox out of view. `RegistrationPage` enqueues that stylesheet for the signup gate; `BookingPage` and `GroupClassPage` already did.
|
||||
|
||||
## REST API
|
||||
| Method | Endpoint | Permission |
|
||||
|----------|-----------------------------------------------------------------|-------------------|
|
||||
@@ -74,3 +82,5 @@ cover every policy's current version or the registration is rejected.
|
||||
- `tests/Unit/Policy/PolicyVersionRepositoryTest.php`
|
||||
- `tests/Unit/Policy/AcceptanceRepositoryTest.php`
|
||||
- `tests/Unit/Policy/PolicyServiceTest.php`
|
||||
- `tests/Unit/Policy/PolicyControllerTest.php`
|
||||
- `tests/Unit/Policy/PolicyEndpointTest.php`
|
||||
|
||||
@@ -6,7 +6,9 @@ payments on a recurring schedule:
|
||||
|
||||
- **`weekly`** — one payment per lesson, due **24 hours before** that lesson.
|
||||
- **`monthly`** — one payment per calendar month, due on the **1st**, covering every
|
||||
lesson that falls in the month (4 lessons ⇒ 4 × fee).
|
||||
lesson that falls in the month. A **private lesson**'s fee is per lesson, so the
|
||||
month costs (#lessons) × fee. A **group class**'s fee is per month: the class is
|
||||
billed that fee once for the month, however many times it meets in it.
|
||||
|
||||
Both apply to **private lessons** and **group classes**. At registration the
|
||||
booking/enrolment succeeds with `payment: null` (no payment step); the lesson is
|
||||
@@ -29,7 +31,7 @@ method resolution, e-transfer freezing, comp auto-pay reused) with a `due_date`
|
||||
| **Private weekly** | lesson `start_dt` ≤ now + 24h | 1 × fee | `us_lessons.payment_id` set on the lesson |
|
||||
| **Private monthly** | the lesson's month's 1st ≤ today | (#lessons in month) × fee | `payment_id` set on every lesson in the month |
|
||||
| **Group weekly** | session (from `Offering::sessionWindows()`) − 1 day ≤ now | 1 × fee | `us_payments.period_key` = session date |
|
||||
| **Group monthly** | the month's 1st ≤ today | (#sessions in month) × fee | `period_key` = `YYYY-MM` |
|
||||
| **Group monthly** | the month's 1st ≤ today | 1 × fee (a monthly class is priced per month, not per session) | `period_key` = `YYYY-MM` |
|
||||
|
||||
- Private lessons dedup on `us_lessons.payment_id IS NULL` — a lesson with no payment is
|
||||
unbilled. A monthly group links its earliest lesson via `createForRegistration` and the
|
||||
|
||||
+29
-2
@@ -5,6 +5,7 @@ namespace Unsupervised\Schedular;
|
||||
|
||||
use Unsupervised\Schedular\Availability\AvailabilityController;
|
||||
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||||
use Unsupervised\Schedular\Availability\WindowValidator;
|
||||
use Unsupervised\Schedular\Auth\AccessSettings;
|
||||
use Unsupervised\Schedular\Auth\InstructorController;
|
||||
use Unsupervised\Schedular\Auth\InviteRepository;
|
||||
@@ -42,6 +43,12 @@ use Unsupervised\Schedular\Registration\QuestionRepository;
|
||||
|
||||
class AdminMenu {
|
||||
|
||||
/**
|
||||
* Hook suffix of the availability screen, captured when the page is added so
|
||||
* its script loads on that screen only.
|
||||
*/
|
||||
private string $availabilityHook = '';
|
||||
|
||||
private AvailabilityController $availabilityController;
|
||||
private LessonController $lessonController;
|
||||
private OfferingController $offeringController;
|
||||
@@ -58,7 +65,7 @@ class AdminMenu {
|
||||
private PaymentReportController $paymentReportController;
|
||||
|
||||
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, AnswerRepository $answers, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, AcceptanceRepository $acceptances, InviteRepository $invites, EnrollmentRepository $enrollments, GroupAccessRepository $groupAccess, StudioSettings $settings, PaymentRepository $payments, PaymentService $paymentService, BillingMethodResolver $resolver, RegistrationMailer $registrationMailer, CreditRepository $credits ) {
|
||||
$this->availabilityController = new AvailabilityController( $availability, $offerings );
|
||||
$this->availabilityController = new AvailabilityController( $availability, $offerings, new WindowValidator( $offerings ) );
|
||||
$this->lessonController = new LessonController( $bookings, $payments, $availability, $offerings, new LessonDetail( $answers, $questions, $acceptances, $policies, $policyVersions ) );
|
||||
$this->offeringController = new OfferingController( $offerings, new ClassSlotReconciler( $availability ) );
|
||||
$this->questionController = new QuestionController( $questions, $offerings );
|
||||
@@ -76,9 +83,29 @@ class AdminMenu {
|
||||
|
||||
public function register(): void {
|
||||
add_action( 'admin_menu', [ $this, 'addPages' ] );
|
||||
add_action( 'admin_enqueue_scripts', [ $this, 'enqueueAssets' ] );
|
||||
add_action( 'admin_post_' . PaymentReportController::EXPORT_ACTION, [ $this->paymentReportController, 'export' ] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a screen's script on that screen only.
|
||||
*
|
||||
* @param string $hookSuffix Screen the enqueue is running for.
|
||||
*/
|
||||
public function enqueueAssets( string $hookSuffix ): void {
|
||||
if ( '' === $this->availabilityHook || $hookSuffix !== $this->availabilityHook ) {
|
||||
return;
|
||||
}
|
||||
|
||||
wp_enqueue_script(
|
||||
'us-scheduler-availability-admin',
|
||||
USC_PLUGIN_URL . 'assets/js/availability-admin.js',
|
||||
[],
|
||||
USC_VERSION,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
public function addPages(): void {
|
||||
$this->addStudioSeparators();
|
||||
|
||||
@@ -94,7 +121,7 @@ class AdminMenu {
|
||||
);
|
||||
|
||||
// Instructor: manage their own availability.
|
||||
add_menu_page(
|
||||
$this->availabilityHook = (string) add_menu_page(
|
||||
__( 'My Availability', 'unsupervised-schedular' ),
|
||||
__( 'My Availability', 'unsupervised-schedular' ),
|
||||
RoleManager::CAP_MANAGE_AVAILABILITY,
|
||||
|
||||
@@ -65,24 +65,21 @@ class RegistrationPage {
|
||||
$registered = sanitize_key( Val::string( wp_unslash( $_GET['us_registered'] ?? '' ) ) );
|
||||
|
||||
if ( is_user_logged_in() ) {
|
||||
if ( self::RESULT_INVITE === $registered ) {
|
||||
// An invited student is done the moment they land here logged in,
|
||||
// so this is where their "continue" link belongs. The sign-in-page
|
||||
// fallback is deliberately not used: pointing someone who is
|
||||
// already signed in at the login screen helps nobody.
|
||||
$continue = $this->continueUrl( $this->successPageId( $atts ) );
|
||||
$link = null === $continue
|
||||
? ''
|
||||
: '<p><a href="' . esc_url( $continue ) . '">'
|
||||
. esc_html__( 'Continue to your account', 'unsupervised-schedular' )
|
||||
. '</a></p>';
|
||||
// Both logged-in outcomes are dead ends without somewhere to go next,
|
||||
// so both offer the same "continue" link to the configured page.
|
||||
wp_enqueue_style( 'us-scheduler' );
|
||||
$link = $this->continueLink( $atts );
|
||||
|
||||
if ( self::RESULT_INVITE === $registered ) {
|
||||
// An invited student is done the moment they land here logged in.
|
||||
return '<div class="us-register-form"><p class="us-success">'
|
||||
. esc_html__( 'Your account has been created and you are now logged in.', 'unsupervised-schedular' )
|
||||
. '</p>' . $link . '</div>';
|
||||
}
|
||||
|
||||
return '<p>' . esc_html__( 'You already have an account and are logged in.', 'unsupervised-schedular' ) . '</p>';
|
||||
return '<div class="us-register-form"><p>'
|
||||
. esc_html__( 'You already have an account and are logged in.', 'unsupervised-schedular' )
|
||||
. '</p>' . $link . '</div>';
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- token identifies the invite; the form submit is nonce-checked in maybeHandleSubmit.
|
||||
@@ -116,6 +113,10 @@ class RegistrationPage {
|
||||
$canRegister = $open || $inviteValid;
|
||||
$inviteOnlyMessage = $this->inviteOnlyMessage( $atts );
|
||||
|
||||
// The signup form carries the same policy-acceptance markup as the booking
|
||||
// gate, so it needs the plugin stylesheet that formats it.
|
||||
wp_enqueue_style( 'us-scheduler' );
|
||||
|
||||
// The two-step script only matters when there is a second step to reveal.
|
||||
if ( $canRegister && '' === $successType && [] !== $accountQuestions ) {
|
||||
wp_enqueue_script( 'us-scheduler-register' );
|
||||
@@ -349,6 +350,40 @@ class RegistrationPage {
|
||||
return $this->continueUrl( $loginPageId ) ?? wp_login_url();
|
||||
}
|
||||
|
||||
/**
|
||||
* The "continue" paragraph shown to a logged-in visitor, or an empty string
|
||||
* when no destination page is configured. The link names the chosen page, so
|
||||
* the visitor knows where it goes before clicking; an untitled page falls
|
||||
* back to generic wording rather than reading "Continue to ".
|
||||
*
|
||||
* The sign-in-page fallback {@see loginUrl()} applies is deliberately not
|
||||
* used here: pointing someone who is already signed in at the login screen is
|
||||
* the same dead end with extra steps, so no link is better than that one.
|
||||
*
|
||||
* @param array<int|string, mixed> $atts
|
||||
*/
|
||||
private function continueLink( array $atts ): string {
|
||||
$pageId = $this->successPageId( $atts );
|
||||
$continue = $this->continueUrl( $pageId );
|
||||
|
||||
if ( null === $continue ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$title = trim( Val::string( get_the_title( $pageId ) ) );
|
||||
$label = '' === $title
|
||||
? esc_html__( 'Continue to your account', 'unsupervised-schedular' )
|
||||
: esc_html(
|
||||
sprintf(
|
||||
/* translators: %s: title of the page the student continues to. */
|
||||
__( 'Continue to %s', 'unsupervised-schedular' ),
|
||||
$title
|
||||
)
|
||||
);
|
||||
|
||||
return '<p><a href="' . esc_url( $continue ) . '">' . $label . '</a></p>';
|
||||
}
|
||||
|
||||
/**
|
||||
* The chosen post-registration page's URL, or null when none is configured
|
||||
* (or it has since been deleted). Unlike {@see loginUrl()} this has no
|
||||
|
||||
@@ -13,6 +13,7 @@ class AvailabilityController {
|
||||
public function __construct(
|
||||
private AvailabilityRepository $repository,
|
||||
private OfferingRepository $offerings,
|
||||
private WindowValidator $validator,
|
||||
) {}
|
||||
|
||||
public function renderPage(): void {
|
||||
@@ -21,9 +22,11 @@ class AvailabilityController {
|
||||
}
|
||||
|
||||
$instructorId = get_current_user_id();
|
||||
$notice = '';
|
||||
$error = '';
|
||||
|
||||
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_availability_action' ) ) {
|
||||
$this->handleFormAction( $instructorId );
|
||||
[ $notice, $error ] = $this->handleFormAction( $instructorId );
|
||||
}
|
||||
|
||||
$slots = $this->repository->findByInstructor( $instructorId );
|
||||
@@ -44,17 +47,27 @@ class AvailabilityController {
|
||||
include USC_PLUGIN_DIR . 'templates/admin/availability.php';
|
||||
}
|
||||
|
||||
private function handleFormAction( int $instructorId ): void {
|
||||
/**
|
||||
* Run the submitted action and report what happened. Every branch returns a
|
||||
* message: a form that silently reloads leaves the instructor unable to tell
|
||||
* "saved 41 slots" from "saved nothing".
|
||||
*
|
||||
* @return array{string, string} Success notice and error message; each is
|
||||
* empty when it does not apply.
|
||||
*/
|
||||
private function handleFormAction( int $instructorId ): array {
|
||||
// Nonce is verified by the caller (renderPage) before this method runs.
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Missing
|
||||
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) );
|
||||
|
||||
if ( 'add' === $action ) {
|
||||
$this->addSlot( $instructorId );
|
||||
return $this->addSlot( $instructorId );
|
||||
}
|
||||
|
||||
if ( 'delete' === $action ) {
|
||||
$this->deleteOwnSlot( absint( Val::int( $_POST['slot_id'] ?? 0 ) ), $instructorId );
|
||||
return $this->deleteOwnSlot( absint( Val::int( $_POST['slot_id'] ?? 0 ) ), $instructorId )
|
||||
? [ __( 'Availability slot deleted.', 'unsupervised-schedular' ), '' ]
|
||||
: [ '', __( 'That slot could not be deleted. It may already be booked, or belong to someone else.', 'unsupervised-schedular' ) ];
|
||||
}
|
||||
|
||||
if ( 'bulk_delete' === $action ) {
|
||||
@@ -62,54 +75,116 @@ class AvailabilityController {
|
||||
// absint-sanitized individually below.
|
||||
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput
|
||||
$rawIds = $_POST['slot_ids'] ?? [];
|
||||
$deleted = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach ( is_array( $rawIds ) ? $rawIds : [] as $rawId ) {
|
||||
$this->deleteOwnSlot( absint( Val::int( $rawId ) ), $instructorId );
|
||||
if ( $this->deleteOwnSlot( absint( Val::int( $rawId ) ), $instructorId ) ) {
|
||||
++$deleted;
|
||||
continue;
|
||||
}
|
||||
|
||||
++$failed;
|
||||
}
|
||||
|
||||
return $this->bulkDeleteResult( $deleted, $failed );
|
||||
}
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||
|
||||
return [ '', '' ];
|
||||
}
|
||||
|
||||
/**
|
||||
* Wording for a bulk delete, which can partly succeed.
|
||||
*
|
||||
* @return array{string, string}
|
||||
*/
|
||||
private function bulkDeleteResult( int $deleted, int $failed ): array {
|
||||
$notice = $deleted > 0
|
||||
? sprintf(
|
||||
/* translators: %d: number of availability slots deleted. */
|
||||
_n( '%d slot deleted.', '%d slots deleted.', $deleted, 'unsupervised-schedular' ),
|
||||
$deleted
|
||||
)
|
||||
: '';
|
||||
|
||||
$error = $failed > 0
|
||||
? sprintf(
|
||||
/* translators: %d: number of slots that could not be deleted. */
|
||||
_n(
|
||||
'%d slot could not be deleted — it may already be booked.',
|
||||
'%d slots could not be deleted — they may already be booked.',
|
||||
$failed,
|
||||
'unsupervised-schedular'
|
||||
),
|
||||
$failed
|
||||
)
|
||||
: '';
|
||||
|
||||
if ( 0 === $deleted && 0 === $failed ) {
|
||||
$error = __( 'No slots were selected.', 'unsupervised-schedular' );
|
||||
}
|
||||
|
||||
return [ $notice, $error ];
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a slot only when it exists and belongs to the given instructor.
|
||||
* The repository additionally refuses to delete booked slots.
|
||||
* The repository additionally refuses to delete booked slots. Returns whether
|
||||
* the row actually went away.
|
||||
*/
|
||||
private function deleteOwnSlot( int $slotId, int $instructorId ): void {
|
||||
private function deleteOwnSlot( int $slotId, int $instructorId ): bool {
|
||||
if ( $slotId <= 0 ) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
$slot = $this->repository->findById( $slotId );
|
||||
if ( $slot && $slot->instructorId === $instructorId ) {
|
||||
$this->repository->delete( $slotId );
|
||||
}
|
||||
|
||||
if ( null === $slot || $slot->instructorId !== $instructorId ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
private function addSlot( int $instructorId ): void {
|
||||
return $this->repository->delete( $slotId );
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and persist a submitted window.
|
||||
*
|
||||
* @return array{string, string}
|
||||
*/
|
||||
private function addSlot( int $instructorId ): array {
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Missing
|
||||
$startDt = AvailabilitySlot::normalizeDateTime( sanitize_text_field( Val::string( wp_unslash( $_POST['start_dt'] ?? '' ) ) ) );
|
||||
$endDt = AvailabilitySlot::normalizeDateTime( sanitize_text_field( Val::string( wp_unslash( $_POST['end_dt'] ?? '' ) ) ) );
|
||||
|
||||
// A window must start and end on the same day (weekly repeat covers longer
|
||||
// ranges) and fit at least one lesson; it is stored as lesson-length slots.
|
||||
if ( null === $startDt || null === $endDt || $endDt <= $startDt || substr( $startDt, 0, 10 ) !== substr( $endDt, 0, 10 ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$offeringId = absint( Val::int( $_POST['offering_id'] ?? 0 ) );
|
||||
$duration = absint( Val::int( $_POST['duration_minutes'] ?? 0 ) );
|
||||
|
||||
$window = new AvailabilitySlot(
|
||||
instructorId: $instructorId,
|
||||
startDt: $startDt,
|
||||
endDt: $endDt,
|
||||
durationMinutes: $duration > 0 ? $duration : 60,
|
||||
offeringId: $offeringId > 0 ? $offeringId : null,
|
||||
$window = $this->validator->validate(
|
||||
$instructorId,
|
||||
sanitize_text_field( Val::string( wp_unslash( $_POST['start_dt'] ?? '' ) ) ),
|
||||
sanitize_text_field( Val::string( wp_unslash( $_POST['end_dt'] ?? '' ) ) ),
|
||||
absint( Val::int( $_POST['duration_minutes'] ?? 0 ) ),
|
||||
absint( Val::int( $_POST['offering_id'] ?? 0 ) ),
|
||||
);
|
||||
|
||||
if ( $window instanceof \WP_Error ) {
|
||||
return [ '', $window->get_error_message() ];
|
||||
}
|
||||
|
||||
$recurrence = sanitize_key( Val::string( wp_unslash( $_POST['recurrence'] ?? 'single' ) ) );
|
||||
$weeks = absint( Val::int( $_POST['weeks'] ?? 1 ) );
|
||||
|
||||
$this->repository->createFromWindow( $window, 'weekly' === $recurrence, $weeks );
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||
|
||||
$ids = $this->repository->createFromWindow( $window, 'weekly' === $recurrence, $weeks );
|
||||
|
||||
// The window was valid, so it split into at least one slot — an empty
|
||||
// result means every insert failed.
|
||||
if ( [] === $ids ) {
|
||||
return [ '', __( 'The availability could not be saved. Please try again.', 'unsupervised-schedular' ) ];
|
||||
}
|
||||
|
||||
return [
|
||||
sprintf(
|
||||
/* translators: %d: number of bookable slots created. */
|
||||
_n( 'Added %d bookable slot.', 'Added %d bookable slots.', count( $ids ), 'unsupervised-schedular' ),
|
||||
count( $ids )
|
||||
),
|
||||
'',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,13 @@ declare(strict_types=1);
|
||||
namespace Unsupervised\Schedular\Availability;
|
||||
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
class AvailabilityEndpoint {
|
||||
|
||||
public function __construct(
|
||||
private AvailabilityRepository $repository,
|
||||
private OfferingRepository $offerings,
|
||||
private WindowValidator $validator,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -113,40 +112,18 @@ class AvailabilityEndpoint {
|
||||
}
|
||||
|
||||
public function create( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
|
||||
$instructorId = get_current_user_id();
|
||||
$offeringId = absint( Val::int( $request->get_param( 'offering_id' ) ) );
|
||||
$duration = absint( Val::int( $request->get_param( 'duration_minutes' ) ) );
|
||||
|
||||
// A slot may only be tied to an offering the instructor owns, so it can
|
||||
// never inherit another instructor's price or payment routing at booking.
|
||||
if ( $offeringId > 0 ) {
|
||||
$offering = $this->offerings->findById( $offeringId );
|
||||
if ( null === $offering || $offering->instructorId !== $instructorId ) {
|
||||
return new \WP_Error( 'invalid_offering', __( 'That offering is not available.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
|
||||
}
|
||||
}
|
||||
|
||||
$startDt = AvailabilitySlot::normalizeDateTime( Val::string( $request->get_param( 'start_dt' ) ) );
|
||||
$endDt = AvailabilitySlot::normalizeDateTime( Val::string( $request->get_param( 'end_dt' ) ) );
|
||||
|
||||
if ( null === $startDt || null === $endDt || $endDt <= $startDt ) {
|
||||
return new \WP_Error( 'invalid_datetime', __( 'Provide a valid start and end, with the end after the start.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
|
||||
}
|
||||
|
||||
if ( substr( $startDt, 0, 10 ) !== substr( $endDt, 0, 10 ) ) {
|
||||
return new \WP_Error( 'invalid_window', __( 'Availability must start and end on the same day. Use the weekly repeat to cover multiple weeks.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
|
||||
}
|
||||
|
||||
$window = new AvailabilitySlot(
|
||||
instructorId: $instructorId,
|
||||
startDt: $startDt,
|
||||
endDt: $endDt,
|
||||
durationMinutes: $duration > 0 ? $duration : 60,
|
||||
offeringId: $offeringId > 0 ? $offeringId : null,
|
||||
// Validation lives in WindowValidator so this endpoint and the admin form
|
||||
// enforce exactly the same rules.
|
||||
$window = $this->validator->validate(
|
||||
get_current_user_id(),
|
||||
Val::string( $request->get_param( 'start_dt' ) ),
|
||||
Val::string( $request->get_param( 'end_dt' ) ),
|
||||
absint( Val::int( $request->get_param( 'duration_minutes' ) ) ),
|
||||
absint( Val::int( $request->get_param( 'offering_id' ) ) ),
|
||||
);
|
||||
|
||||
if ( [] === $window->splitByDuration() ) {
|
||||
return new \WP_Error( 'invalid_window', __( 'The availability window is shorter than the lesson length.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
|
||||
if ( $window instanceof \WP_Error ) {
|
||||
return $window;
|
||||
}
|
||||
|
||||
$ids = $this->repository->createFromWindow(
|
||||
@@ -155,6 +132,12 @@ class AvailabilityEndpoint {
|
||||
absint( Val::int( $request->get_param( 'weeks' ) ) )
|
||||
);
|
||||
|
||||
// A valid window splits into at least one slot, so nothing written means
|
||||
// every insert failed.
|
||||
if ( [] === $ids ) {
|
||||
return new \WP_Error( 'not_saved', __( 'The availability could not be saved.', 'unsupervised-schedular' ), [ 'status' => 500 ] );
|
||||
}
|
||||
|
||||
return new \WP_REST_Response( [ 'ids' => $ids ], 201 );
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,14 @@ class AvailabilityRepository {
|
||||
$this->table = $db->prefix . 'us_availability';
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert one slot row. Returns its id, or 0 when the write failed —
|
||||
* `insert_id` still holds the *previous* statement's id after a failed
|
||||
* insert, so returning it unconditionally made a failed write look like a
|
||||
* successful one.
|
||||
*/
|
||||
public function insert( AvailabilitySlot $slot ): int {
|
||||
$this->db->insert(
|
||||
$written = $this->db->insert(
|
||||
$this->table,
|
||||
[
|
||||
'instructor_id' => $slot->instructorId,
|
||||
@@ -27,7 +33,7 @@ class AvailabilityRepository {
|
||||
[ '%d', '%d', '%s', '%s', '%d', '%d', '%d', '%s' ]
|
||||
);
|
||||
|
||||
return $this->db->insert_id;
|
||||
return false === $written ? 0 : $this->db->insert_id;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,9 +48,17 @@ class AvailabilityRepository {
|
||||
$ids = [];
|
||||
|
||||
foreach ( $window->splitByDuration() as $slot ) {
|
||||
$ids = $weekly
|
||||
? array_merge( $ids, $this->createWeeklySeries( $slot, $weeks ) )
|
||||
: [ ...$ids, $this->insert( $slot ) ];
|
||||
if ( $weekly ) {
|
||||
$ids = array_merge( $ids, $this->createWeeklySeries( $slot, $weeks ) );
|
||||
continue;
|
||||
}
|
||||
|
||||
$id = $this->insert( $slot );
|
||||
|
||||
// A failed insert returns 0; it must not reach the caller as an id.
|
||||
if ( $id > 0 ) {
|
||||
$ids[] = $id;
|
||||
}
|
||||
}
|
||||
|
||||
return $ids;
|
||||
@@ -55,10 +69,14 @@ class AvailabilityRepository {
|
||||
* separate row one week apart, all sharing a `recurrence_group` (the id of the
|
||||
* first row).
|
||||
*
|
||||
* The count is clamped to `AvailabilitySlot::MAX_WEEKLY_OCCURRENCES`. The
|
||||
* form's `max` attribute says the same, but only this is binding — a
|
||||
* hand-crafted POST used to be able to ask for an unbounded number of rows.
|
||||
*
|
||||
* @return list<int> Inserted slot IDs.
|
||||
*/
|
||||
public function createWeeklySeries( AvailabilitySlot $first, int $occurrences ): array {
|
||||
$occurrences = max( 1, $occurrences );
|
||||
$occurrences = max( 1, min( AvailabilitySlot::MAX_WEEKLY_OCCURRENCES, $occurrences ) );
|
||||
$start = new \DateTimeImmutable( $first->startDt );
|
||||
$end = new \DateTimeImmutable( $first->endDt );
|
||||
|
||||
@@ -79,6 +97,13 @@ class AvailabilityRepository {
|
||||
)
|
||||
);
|
||||
|
||||
// A failed insert returns 0. Skipping it keeps a bogus id out of the
|
||||
// returned list and, more importantly, stops 0 becoming the series'
|
||||
// recurrence group — which would orphan every later occurrence.
|
||||
if ( $id <= 0 ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( 0 === $groupId ) {
|
||||
$groupId = $id;
|
||||
$this->setRecurrenceGroup( $id, $groupId );
|
||||
|
||||
@@ -7,6 +7,23 @@ use Unsupervised\Schedular\Val;
|
||||
|
||||
class AvailabilitySlot {
|
||||
|
||||
/** Lesson length used when none was submitted. */
|
||||
public const DEFAULT_DURATION_MINUTES = 60;
|
||||
|
||||
/**
|
||||
* Lesson lengths a window can be split into, offered by the availability
|
||||
* form. The form hides the ones a given window is too short for.
|
||||
*
|
||||
* @var list<int>
|
||||
*/
|
||||
public const DURATION_CHOICES = [ 30, 60 ];
|
||||
|
||||
/**
|
||||
* Ceiling on a weekly series, matching the form's `max`. Enforced in the
|
||||
* repository too, so a hand-crafted POST cannot ask for ten thousand rows.
|
||||
*/
|
||||
public const MAX_WEEKLY_OCCURRENCES = 52;
|
||||
|
||||
public function __construct(
|
||||
public readonly int $instructorId,
|
||||
public readonly string $startDt,
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Availability;
|
||||
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
|
||||
/**
|
||||
* Validates a submitted availability window.
|
||||
*
|
||||
* The admin form and the REST endpoint both accept the same window, and used to
|
||||
* check it independently — the endpoint returning a specific 400 for each
|
||||
* failure while the form simply returned, saving nothing and saying nothing. A
|
||||
* 30-minute window submitted with the default 60-minute lesson length was the
|
||||
* visible symptom: no rows, no error, no clue. Both callers now come through
|
||||
* here, so neither can drift from the other again.
|
||||
*
|
||||
* Every rejection is a `WP_Error` carrying a message written for the person who
|
||||
* submitted the form: the endpoint returns it as-is (the `status` data makes it
|
||||
* a 400), and the admin screen shows `get_error_message()` in a notice.
|
||||
*/
|
||||
class WindowValidator {
|
||||
|
||||
public function __construct( private OfferingRepository $offerings ) {}
|
||||
|
||||
/**
|
||||
* Check a submitted window and return it ready to persist.
|
||||
*
|
||||
* @param int $instructorId Instructor the window belongs to.
|
||||
* @param string $rawStart Submitted start, in any form {@see AvailabilitySlot::normalizeDateTime()} accepts.
|
||||
* @param string $rawEnd Submitted end, likewise.
|
||||
* @param int $durationMinutes Lesson length the window is split into; 0 falls back to the 60-minute default.
|
||||
* @param int $offeringId Offering the slots are tied to, or 0 for any private lesson.
|
||||
*
|
||||
* @return AvailabilitySlot|\WP_Error The window, or why it was rejected.
|
||||
*/
|
||||
public function validate( int $instructorId, string $rawStart, string $rawEnd, int $durationMinutes, int $offeringId ): AvailabilitySlot|\WP_Error {
|
||||
$startDt = AvailabilitySlot::normalizeDateTime( $rawStart );
|
||||
$endDt = AvailabilitySlot::normalizeDateTime( $rawEnd );
|
||||
|
||||
if ( null === $startDt || null === $endDt ) {
|
||||
return new \WP_Error(
|
||||
'invalid_datetime',
|
||||
__( 'Enter a valid start and end date and time.', 'unsupervised-schedular' ),
|
||||
[ 'status' => 400 ]
|
||||
);
|
||||
}
|
||||
|
||||
if ( $endDt <= $startDt ) {
|
||||
return new \WP_Error(
|
||||
'invalid_datetime',
|
||||
__( 'The end time must be after the start time.', 'unsupervised-schedular' ),
|
||||
[ 'status' => 400 ]
|
||||
);
|
||||
}
|
||||
|
||||
if ( substr( $startDt, 0, 10 ) !== substr( $endDt, 0, 10 ) ) {
|
||||
return new \WP_Error(
|
||||
'invalid_window',
|
||||
__( 'Availability must start and end on the same day. Use the weekly repeat to cover multiple weeks.', 'unsupervised-schedular' ),
|
||||
[ 'status' => 400 ]
|
||||
);
|
||||
}
|
||||
|
||||
// A slot may only be tied to an offering the instructor owns, so it can
|
||||
// never inherit another instructor's price or payment routing at booking.
|
||||
if ( $offeringId > 0 ) {
|
||||
$offering = $this->offerings->findById( $offeringId );
|
||||
|
||||
if ( null === $offering || $offering->instructorId !== $instructorId ) {
|
||||
return new \WP_Error(
|
||||
'invalid_offering',
|
||||
__( 'That offering is not available.', 'unsupervised-schedular' ),
|
||||
[ 'status' => 400 ]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$duration = $durationMinutes > 0 ? $durationMinutes : AvailabilitySlot::DEFAULT_DURATION_MINUTES;
|
||||
|
||||
$window = new AvailabilitySlot(
|
||||
instructorId: $instructorId,
|
||||
startDt: $startDt,
|
||||
endDt: $endDt,
|
||||
durationMinutes: $duration,
|
||||
offeringId: $offeringId > 0 ? $offeringId : null,
|
||||
);
|
||||
|
||||
// The window is stored as lesson-length slots, so one that cannot fit a
|
||||
// single lesson would persist nothing at all.
|
||||
if ( [] === $window->splitByDuration() ) {
|
||||
return new \WP_Error(
|
||||
'invalid_window',
|
||||
sprintf(
|
||||
/* translators: %d: the selected lesson length, in minutes. */
|
||||
__( 'This window is shorter than the %d-minute lesson length, so it holds no bookable slots. Choose a shorter lesson length or a longer window.', 'unsupervised-schedular' ),
|
||||
$duration
|
||||
),
|
||||
[ 'status' => 400 ]
|
||||
);
|
||||
}
|
||||
|
||||
return $window;
|
||||
}
|
||||
}
|
||||
@@ -86,13 +86,13 @@ class BlockPreview {
|
||||
private static function upcomingLessons(): string {
|
||||
return sprintf(
|
||||
'<div class="us-my-lessons"><h3>%s</h3>'
|
||||
. '<div class="us-my-lesson"><span class="us-my-lesson-info">'
|
||||
. '<div class="us-my-lesson"><div class="us-my-lesson-info">'
|
||||
. '<strong class="us-my-lesson-title">%s <span class="us-my-lesson-duration">(30 min)</span></strong>'
|
||||
. '<span class="us-my-lesson-when">%s</span></span>'
|
||||
. '<span class="us-my-lesson-actions">'
|
||||
. '<span class="us-my-lesson-when">%s</span></div>'
|
||||
. '<div class="us-my-lesson-actions">'
|
||||
. '<span class="us-lesson-status us-lesson-status-confirmed">%s</span>'
|
||||
. '<button type="button" class="us-cancel-lesson" disabled>%s</button>'
|
||||
. '</span></div></div>',
|
||||
. '</div></div></div>',
|
||||
esc_html__( 'Your upcoming lessons', 'unsupervised-schedular' ),
|
||||
esc_html__( 'Piano Lesson', 'unsupervised-schedular' ),
|
||||
esc_html__( 'Monday · 4:00 PM–4:30 PM', 'unsupervised-schedular' ),
|
||||
@@ -118,11 +118,13 @@ class BlockPreview {
|
||||
: '<p>' . esc_html__( 'A sample class shown so the page can be styled.', 'unsupervised-schedular' ) . '</p>';
|
||||
|
||||
return sprintf(
|
||||
'<div id="us-group-app">%s<div id="us-group-list"><div class="us-class"><h3>%s</h3><p class="us-class-when">%s</p>%s<p>25.00 CAD</p><p class="us-enrol-deadline">%s</p><button type="button" class="us-enrol-btn" disabled>%s</button></div></div></div>',
|
||||
'<div id="us-group-app">%s<div id="us-group-list"><div class="us-class"><h3>%s</h3><p class="us-class-when">%s</p>%s<p class="us-class-price">%s</p><p class="us-enrol-deadline">%s</p><button type="button" class="us-enrol-btn" disabled>%s</button></div></div></div>',
|
||||
self::note( $note ),
|
||||
esc_html__( 'Beginner Group Class', 'unsupervised-schedular' ),
|
||||
esc_html__( 'Saturdays 10:00 AM–11:00 AM', 'unsupervised-schedular' ),
|
||||
$description,
|
||||
// Prices on the live page always carry their cadence, so the sample does too.
|
||||
esc_html__( '25.00 CAD up front', 'unsupervised-schedular' ),
|
||||
esc_html__( 'Enrol by Sep 6, 2026', 'unsupervised-schedular' ),
|
||||
esc_html__( 'Enrol', 'unsupervised-schedular' )
|
||||
);
|
||||
|
||||
@@ -251,11 +251,18 @@ class ScheduledBillingRunner {
|
||||
/**
|
||||
* Bill one payment per calendar month of a group class, once its 1st arrives.
|
||||
*
|
||||
* A monthly group class is priced **per month**, not per session: the fee is
|
||||
* charged once for the month however many times the class meets in it. This is
|
||||
* what the student is quoted and agrees to on the way in ("40.00 CAD monthly"),
|
||||
* and it is the one place the monthly rule differs from private lessons, whose
|
||||
* per-lesson fee is multiplied by the lessons that fall in the month.
|
||||
*
|
||||
* @param list<array{start: string, end: string}> $windows
|
||||
* @param array<int, list<array{payment: Payment, label: string}>> $buckets
|
||||
*/
|
||||
private function billGroupMonthly( \DateTimeImmutable $now, string $today, Enrollment $enrollment, Offering $offering, array $windows, array &$buckets ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
|
||||
// Count this enrolment's sessions per calendar month.
|
||||
// Count this enrolment's sessions per calendar month. The count does not
|
||||
// price the month — it names it on the student's notice ("3 sessions").
|
||||
$months = [];
|
||||
foreach ( $windows as $window ) {
|
||||
$start = new \DateTimeImmutable( $window['start'] );
|
||||
@@ -278,7 +285,7 @@ class ScheduledBillingRunner {
|
||||
(int) $enrollment->id,
|
||||
$enrollment->studentId,
|
||||
$enrollment->instructorId,
|
||||
$offering->price * $count,
|
||||
$offering->price,
|
||||
$offering->currency,
|
||||
$offering->etransferEmail,
|
||||
$monthStart,
|
||||
|
||||
@@ -19,20 +19,35 @@ class PolicyController {
|
||||
wp_die( esc_html__( 'You do not have permission to manage policies.', 'unsupervised-schedular' ) );
|
||||
}
|
||||
|
||||
$notice = '';
|
||||
$viewVersionId = 0;
|
||||
|
||||
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_policy_action' ) ) {
|
||||
$this->handleFormAction();
|
||||
[ $notice, $viewVersionId ] = $this->handleFormAction();
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only policy selector.
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only policy/version selectors.
|
||||
$policyId = absint( Val::int( $_GET['policy_id'] ?? 0 ) );
|
||||
if ( 0 === $viewVersionId ) {
|
||||
$viewVersionId = absint( Val::int( $_GET['version_id'] ?? 0 ) );
|
||||
}
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Recommended
|
||||
|
||||
$policyList = $this->policies->findAll();
|
||||
$selectedPolicy = $policyId > 0 ? $this->policies->findById( $policyId ) : null;
|
||||
$policyVersions = null !== $selectedPolicy ? $this->versions->findByPolicy( (int) $selectedPolicy->id ) : null;
|
||||
$viewedVersion = null !== $selectedPolicy ? $this->loadVersionForPolicy( (int) $selectedPolicy->id, $viewVersionId ) : null;
|
||||
|
||||
include USC_PLUGIN_DIR . 'templates/admin/policies.php';
|
||||
}
|
||||
|
||||
private function handleFormAction(): void {
|
||||
/**
|
||||
* Process the posted action.
|
||||
*
|
||||
* @return array{string, int} Status notice, and the version to open in the
|
||||
* viewer (0 to leave the current selection alone).
|
||||
*/
|
||||
private function handleFormAction(): array {
|
||||
// Nonce is verified by the caller (renderPage) before this method runs.
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Missing
|
||||
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) );
|
||||
@@ -53,12 +68,12 @@ class PolicyController {
|
||||
$this->service->createPolicy( $title, $slug, $scope );
|
||||
}
|
||||
|
||||
return;
|
||||
return [ '', 0 ];
|
||||
}
|
||||
|
||||
$policyId = absint( Val::int( $_POST['policy_id'] ?? 0 ) );
|
||||
if ( $policyId <= 0 || null === $this->policies->findById( $policyId ) ) {
|
||||
return;
|
||||
return [ '', 0 ];
|
||||
}
|
||||
|
||||
if ( 'add_version' === $action ) {
|
||||
@@ -66,6 +81,40 @@ class PolicyController {
|
||||
$this->service->addDraftVersion( $policyId, $body );
|
||||
}
|
||||
|
||||
if ( 'edit_version' === $action ) {
|
||||
$source = $this->loadVersionForPolicy( $policyId, absint( Val::int( $_POST['version_id'] ?? 0 ) ) );
|
||||
if ( null === $source ) {
|
||||
return [ '', 0 ];
|
||||
}
|
||||
|
||||
$body = wp_kses_post( Val::string( wp_unslash( $_POST['body'] ?? '' ) ) );
|
||||
|
||||
// A draft has never been shown to a student, so it is edited in place.
|
||||
// A published (or archived) version is what students accepted, so an
|
||||
// edit branches a new draft and leaves the original untouched.
|
||||
if ( PolicyVersion::STATUS_DRAFT === $source->status ) {
|
||||
$this->versions->updateBody( (int) $source->id, $body );
|
||||
|
||||
return [
|
||||
sprintf(
|
||||
/* translators: %d: the edited version number. */
|
||||
__( 'Draft version %d was updated.', 'unsupervised-schedular' ),
|
||||
$source->versionNumber
|
||||
),
|
||||
(int) $source->id,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
sprintf(
|
||||
/* translators: %d: the version number the edit was based on. */
|
||||
__( 'Your changes to version %d were saved as a new draft version.', 'unsupervised-schedular' ),
|
||||
$source->versionNumber
|
||||
),
|
||||
$this->service->addDraftVersion( $policyId, $body ),
|
||||
];
|
||||
}
|
||||
|
||||
if ( 'publish_version' === $action ) {
|
||||
$versionId = absint( Val::int( $_POST['version_id'] ?? 0 ) );
|
||||
if ( $versionId > 0 ) {
|
||||
@@ -73,5 +122,20 @@ class PolicyController {
|
||||
}
|
||||
}
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||
|
||||
return [ '', 0 ];
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a version by id, confirming it belongs to the given policy.
|
||||
*/
|
||||
private function loadVersionForPolicy( int $policyId, int $versionId ): ?PolicyVersion {
|
||||
if ( $versionId <= 0 ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$version = $this->versions->findById( $versionId );
|
||||
|
||||
return null !== $version && $version->policyId === $policyId ? $version : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,9 +104,9 @@ class PolicyEndpoint {
|
||||
'policy_version_id' => $version->id,
|
||||
'version_number' => $version->versionNumber,
|
||||
// Bodies are kses'd on every write path, but the booking JS renders
|
||||
// this HTML raw — sanitise at output too so a missed write path can
|
||||
// never become stored XSS.
|
||||
'body' => wp_kses_post( (string) $version->body ),
|
||||
// this HTML raw — bodyHtml() sanitises at output too, so a missed
|
||||
// write path can never become stored XSS.
|
||||
'body' => $version->bodyHtml(),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,19 @@ class PolicyVersion {
|
||||
return self::STATUS_PUBLISHED === $this->status;
|
||||
}
|
||||
|
||||
/**
|
||||
* The body as display-ready HTML.
|
||||
*
|
||||
* Policy bodies are typed into a plain textarea, so most are written as
|
||||
* blank-line-separated prose with no markup at all — dropped into a page
|
||||
* as-is that collapses into one unreadable run of text. Running the same
|
||||
* `wpautop()` WordPress applies to post content turns those breaks into
|
||||
* paragraphs, and leaves bodies that do carry markup alone.
|
||||
*/
|
||||
public function bodyHtml(): string {
|
||||
return wpautop( wp_kses_post( (string) $this->body ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a plain array representation of the version.
|
||||
*
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Unsupervised\Schedular;
|
||||
|
||||
use Unsupervised\Schedular\Availability\AvailabilityEndpoint;
|
||||
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||||
use Unsupervised\Schedular\Availability\WindowValidator;
|
||||
use Unsupervised\Schedular\Booking\BookingEndpoint;
|
||||
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||
use Unsupervised\Schedular\Booking\CancellationPolicy;
|
||||
@@ -37,7 +38,7 @@ class RestRegistrar {
|
||||
private PaymentEndpoint $paymentEndpoint;
|
||||
|
||||
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, RegistrationGate $gate, EnrollmentRepository $enrollments, GroupAccessRepository $groupAccess, PaymentService $paymentService ) {
|
||||
$this->availabilityEndpoint = new AvailabilityEndpoint( $availability, $offerings );
|
||||
$this->availabilityEndpoint = new AvailabilityEndpoint( $availability, new WindowValidator( $offerings ) );
|
||||
$this->bookingEndpoint = new BookingEndpoint( $availability, $bookings, $offerings, $gate, $paymentService, new CancellationPolicy( new StudioSettings() ) );
|
||||
$this->offeringEndpoint = new OfferingEndpoint( $offerings, $groupAccess );
|
||||
$this->questionEndpoint = new QuestionEndpoint( $questions, $offerings );
|
||||
|
||||
@@ -64,14 +64,20 @@ class ShortcodeRegistrar {
|
||||
'nonce' => wp_create_nonce( 'wp_rest' ),
|
||||
'stripeKey' => $settings->publishableKey(),
|
||||
'startOfWeek' => Val::int( get_option( 'start_of_week', 1 ) ),
|
||||
// The studio HST rate, so a price quoted to a student on the way in
|
||||
// matches the total they are actually billed.
|
||||
'taxRate' => $settings->hstRate(),
|
||||
];
|
||||
|
||||
// Attach the shared config to the payment helper so it is defined before the
|
||||
// booking/group scripts (which depend on it) run.
|
||||
wp_localize_script( 'us-scheduler-payment', 'usScheduler', $data );
|
||||
|
||||
wp_register_script( 'us-scheduler', USC_PLUGIN_URL . 'assets/js/booking.js', [ 'us-scheduler-payment' ], USC_VERSION, true );
|
||||
wp_register_script( 'us-scheduler-group', USC_PLUGIN_URL . 'assets/js/group-classes.js', [ 'us-scheduler-payment' ], USC_VERSION, true );
|
||||
// Price formatting and the pay agreement, shared by booking and enrolment.
|
||||
wp_register_script( 'us-scheduler-pricing', USC_PLUGIN_URL . 'assets/js/pricing.js', [ 'us-scheduler-payment' ], USC_VERSION, true );
|
||||
|
||||
wp_register_script( 'us-scheduler', USC_PLUGIN_URL . 'assets/js/booking.js', [ 'us-scheduler-pricing' ], USC_VERSION, true );
|
||||
wp_register_script( 'us-scheduler-group', USC_PLUGIN_URL . 'assets/js/group-classes.js', [ 'us-scheduler-pricing' ], USC_VERSION, true );
|
||||
|
||||
// Progressive enhancement for the two-step registration form (no dependencies).
|
||||
wp_register_script( 'us-scheduler-register', USC_PLUGIN_URL . 'assets/js/register.js', [], USC_VERSION, true );
|
||||
|
||||
@@ -13,8 +13,12 @@ if (! defined('ABSPATH')) {
|
||||
* @var list<array{date: string, slots: list<\Unsupervised\Schedular\Availability\AvailabilitySlot>}> $weekDays
|
||||
* @var string $prevWeek
|
||||
* @var string $nextWeek
|
||||
* @var string $notice Success message from the submitted action; empty when none.
|
||||
* @var string $error Failure message from the submitted action; empty when none.
|
||||
*/
|
||||
|
||||
use Unsupervised\Schedular\Availability\AvailabilitySlot;
|
||||
|
||||
$baseUrl = admin_url('admin.php?page=us-availability');
|
||||
|
||||
$deleteForm = static function (\Unsupervised\Schedular\Availability\AvailabilitySlot $slot): void {
|
||||
@@ -33,9 +37,16 @@ $deleteForm = static function (\Unsupervised\Schedular\Availability\Availability
|
||||
<div class="wrap">
|
||||
<h1><?php esc_html_e('My Availability', 'unsupervised-schedular'); ?></h1>
|
||||
|
||||
<?php if ('' !== $notice) : ?>
|
||||
<div class="notice notice-success is-dismissible"><p><?php echo esc_html($notice); ?></p></div>
|
||||
<?php endif; ?>
|
||||
<?php if ('' !== $error) : ?>
|
||||
<div class="notice notice-error is-dismissible"><p><?php echo esc_html($error); ?></p></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<h2><?php esc_html_e('Add Availability', 'unsupervised-schedular'); ?></h2>
|
||||
<p><?php esc_html_e('The window must start and end on the same day. It is split into bookable slots of the chosen lesson length — for example, 9:00 AM–4:00 PM with 60-minute lessons creates seven slots.', 'unsupervised-schedular'); ?></p>
|
||||
<form method="post">
|
||||
<form method="post" id="usc-add-availability">
|
||||
<?php wp_nonce_field('usc_availability_action'); ?>
|
||||
<input type="hidden" name="usc_action" value="add">
|
||||
<table class="form-table">
|
||||
@@ -51,9 +62,20 @@ $deleteForm = static function (\Unsupervised\Schedular\Availability\Availability
|
||||
<th><label for="duration_minutes"><?php esc_html_e('Lesson length (minutes)', 'unsupervised-schedular'); ?></label></th>
|
||||
<td>
|
||||
<select name="duration_minutes" id="duration_minutes">
|
||||
<option value="30">30</option>
|
||||
<option value="60" selected>60</option>
|
||||
<?php foreach (AvailabilitySlot::DURATION_CHOICES as $choice) : ?>
|
||||
<option value="<?php echo esc_attr((string) $choice); ?>" <?php selected($choice, AvailabilitySlot::DEFAULT_DURATION_MINUTES); ?>>
|
||||
<?php echo esc_html((string) $choice); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<?php
|
||||
// Filled and revealed by availability-admin.js when the entered
|
||||
// window is too short for every lesson length. The wording lives
|
||||
// here so it stays translatable.
|
||||
?>
|
||||
<p class="description usc-duration-warning" id="usc-duration-warning" hidden>
|
||||
<?php esc_html_e('This window is too short for any lesson length. Lengthen the window or it cannot be saved.', 'unsupervised-schedular'); ?>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -73,7 +95,7 @@ $deleteForm = static function (\Unsupervised\Schedular\Availability\Availability
|
||||
<label><input type="radio" name="recurrence" value="single" checked> <?php esc_html_e('One-off', 'unsupervised-schedular'); ?></label>
|
||||
|
||||
<label><input type="radio" name="recurrence" value="weekly"> <?php esc_html_e('Weekly for', 'unsupervised-schedular'); ?></label>
|
||||
<input type="number" name="weeks" min="1" max="52" value="12" style="width:5em;"> <?php esc_html_e('weeks', 'unsupervised-schedular'); ?>
|
||||
<input type="number" name="weeks" min="1" max="<?php echo esc_attr((string) AvailabilitySlot::MAX_WEEKLY_OCCURRENCES); ?>" value="12" style="width:5em;"> <?php esc_html_e('weeks', 'unsupervised-schedular'); ?>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -12,11 +12,17 @@ if (! defined('ABSPATH')) {
|
||||
* @var list<\Unsupervised\Schedular\Policy\Policy> $policyList
|
||||
* @var \Unsupervised\Schedular\Policy\Policy|null $selectedPolicy
|
||||
* @var list<\Unsupervised\Schedular\Policy\PolicyVersion>|null $policyVersions
|
||||
* @var \Unsupervised\Schedular\Policy\PolicyVersion|null $viewedVersion Version opened in the viewer, if any.
|
||||
* @var string $notice Status message from the last save.
|
||||
*/
|
||||
?>
|
||||
<div class="wrap">
|
||||
<h1><?php esc_html_e('Policies', 'unsupervised-schedular'); ?></h1>
|
||||
|
||||
<?php if ('' !== $notice) : ?>
|
||||
<div class="notice notice-success is-dismissible"><p><?php echo esc_html($notice); ?></p></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<h2><?php esc_html_e('Add Policy', 'unsupervised-schedular'); ?></h2>
|
||||
<form method="post">
|
||||
<?php wp_nonce_field('usc_policy_action'); ?>
|
||||
@@ -98,6 +104,18 @@ if (! defined('ABSPATH')) {
|
||||
<td><?php echo esc_html($version->status); ?></td>
|
||||
<td><?php echo $version->publishedAt ? esc_html($version->publishedAt) : '—'; ?></td>
|
||||
<td>
|
||||
<a href="<?php echo esc_url(
|
||||
add_query_arg(
|
||||
[
|
||||
'page' => 'us-policies',
|
||||
'policy_id' => (string) $selectedPolicy->id,
|
||||
'version_id' => (string) $version->id,
|
||||
],
|
||||
admin_url('admin.php')
|
||||
)
|
||||
); ?>" class="button button-small">
|
||||
<?php esc_html_e('View', 'unsupervised-schedular'); ?>
|
||||
</a>
|
||||
<?php if (PolicyVersion::STATUS_PUBLISHED !== $version->status) : ?>
|
||||
<form method="post" style="display:inline;">
|
||||
<?php wp_nonce_field('usc_policy_action'); ?>
|
||||
@@ -117,5 +135,48 @@ if (! defined('ABSPATH')) {
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (null !== $viewedVersion) : ?>
|
||||
<hr>
|
||||
<h3>
|
||||
<?php echo esc_html(sprintf(
|
||||
/* translators: 1: version number, 2: version status. */
|
||||
__('Version %1$d (%2$s)', 'unsupervised-schedular'),
|
||||
$viewedVersion->versionNumber,
|
||||
$viewedVersion->status
|
||||
)); ?>
|
||||
</h3>
|
||||
|
||||
<?php // Rendered exactly as the booking and signup gates render it, so this doubles as a preview. ?>
|
||||
<div class="us-policy-version-body" style="background:#fff;border:1px solid #c3c4c7;padding:0 1em;max-width:50em;overflow-wrap:break-word;">
|
||||
<?php echo wp_kses_post($viewedVersion->bodyHtml()); ?>
|
||||
</div>
|
||||
|
||||
<?php $editingDraft = PolicyVersion::STATUS_DRAFT === $viewedVersion->status; ?>
|
||||
<h4><?php esc_html_e('Edit', 'unsupervised-schedular'); ?></h4>
|
||||
<p class="description">
|
||||
<?php
|
||||
echo esc_html(
|
||||
$editingDraft
|
||||
? __('This version is still a draft, so your changes are saved to it directly.', 'unsupervised-schedular')
|
||||
: __('This version has been published, so saving an edit creates a new draft version from this text and leaves the published one untouched.', 'unsupervised-schedular')
|
||||
);
|
||||
?>
|
||||
</p>
|
||||
<form method="post">
|
||||
<?php wp_nonce_field('usc_policy_action'); ?>
|
||||
<input type="hidden" name="usc_action" value="edit_version">
|
||||
<input type="hidden" name="policy_id" value="<?php echo esc_attr((string) $selectedPolicy->id); ?>">
|
||||
<input type="hidden" name="version_id" value="<?php echo esc_attr((string) $viewedVersion->id); ?>">
|
||||
<textarea name="body" rows="12" class="large-text"><?php echo esc_textarea((string) $viewedVersion->body); ?></textarea>
|
||||
<?php
|
||||
submit_button(
|
||||
$editingDraft
|
||||
? esc_html__('Save Draft', 'unsupervised-schedular')
|
||||
: esc_html__('Save as New Draft', 'unsupervised-schedular')
|
||||
);
|
||||
?>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
@@ -108,7 +108,7 @@ $renderQuestionField = static function (Question $question): void {
|
||||
<?php foreach ($policyForms as $form) : ?>
|
||||
<div class="us-policy">
|
||||
<h4><?php echo esc_html($form['policy']->title); ?></h4>
|
||||
<div class="us-policy-body"><?php echo wp_kses_post((string) $form['version']->body); ?></div>
|
||||
<div class="us-policy-body"><?php echo wp_kses_post($form['version']->bodyHtml()); ?></div>
|
||||
<label>
|
||||
<input type="checkbox" name="accept[]" value="<?php echo esc_attr((string) $form['version']->id); ?>" required>
|
||||
<?php
|
||||
|
||||
@@ -37,6 +37,8 @@ class RegistrationPageTest extends TestCase
|
||||
Functions\when('sanitize_email')->alias(static fn ($v) => $v);
|
||||
Functions\when('absint')->alias(static fn ($v) => (int) $v);
|
||||
Functions\when('current_time')->justReturn('2024-01-01 00:00:00');
|
||||
Functions\when('wp_enqueue_style')->justReturn(null);
|
||||
Functions\when('wp_enqueue_script')->justReturn(null);
|
||||
|
||||
$invites = Mockery::mock(InviteRepository::class);
|
||||
$policies = Mockery::mock(PolicyRepository::class);
|
||||
@@ -495,11 +497,61 @@ class RegistrationPageTest extends TestCase
|
||||
Functions\when('is_user_logged_in')->justReturn(true);
|
||||
Functions\when('sanitize_key')->alias(static fn ($v) => strtolower((string) $v));
|
||||
Functions\expect('get_permalink')->once()->with(4)->andReturn('http://home.test/welcome/');
|
||||
Functions\when('get_the_title')->justReturn('Book a Lesson');
|
||||
|
||||
$html = $this->ctx['page']->render([ 'loginPageId' => 4 ]);
|
||||
|
||||
self::assertStringContainsString('now logged in', $html);
|
||||
self::assertStringContainsString('href="http://home.test/welcome/"', $html);
|
||||
// The link names its destination rather than saying "your account".
|
||||
self::assertStringContainsString('Continue to Book a Lesson', $html);
|
||||
}
|
||||
|
||||
public function testContinueLinkFallsBackToGenericWordingForAnUntitledPage(): void
|
||||
{
|
||||
Functions\when('is_user_logged_in')->justReturn(true);
|
||||
Functions\when('sanitize_key')->alias(static fn ($v) => strtolower((string) $v));
|
||||
Functions\when('get_permalink')->justReturn('http://home.test/welcome/');
|
||||
Functions\when('get_the_title')->justReturn(' ');
|
||||
|
||||
// An untitled page must not produce a link reading "Continue to ".
|
||||
$html = $this->ctx['page']->render([ 'loginPageId' => 4 ]);
|
||||
|
||||
self::assertStringContainsString('Continue to your account', $html);
|
||||
self::assertStringContainsString('href="http://home.test/welcome/"', $html);
|
||||
}
|
||||
|
||||
public function testAlreadyLoggedInVisitorIsLinkedToTheChosenPage(): void
|
||||
{
|
||||
// No us_registered flag: someone who simply happens to be signed in and
|
||||
// lands on the registration page. They still need a way onward.
|
||||
Functions\when('is_user_logged_in')->justReturn(true);
|
||||
Functions\when('sanitize_key')->alias(static fn ($v) => strtolower((string) $v));
|
||||
Functions\expect('get_permalink')->once()->with(4)->andReturn('http://home.test/welcome/');
|
||||
Functions\when('get_the_title')->justReturn('Book a Lesson');
|
||||
|
||||
$html = $this->ctx['page']->render([ 'loginPageId' => 4 ]);
|
||||
|
||||
self::assertStringContainsString('already have an account', $html);
|
||||
self::assertStringContainsString('href="http://home.test/welcome/"', $html);
|
||||
self::assertStringContainsString('Continue to Book a Lesson', $html);
|
||||
// Not the just-registered message — that branch needs its own flag.
|
||||
self::assertStringNotContainsString('us-success', $html);
|
||||
}
|
||||
|
||||
public function testAlreadyLoggedInVisitorGetsNoLinkWithoutAChosenPage(): void
|
||||
{
|
||||
Functions\when('is_user_logged_in')->justReturn(true);
|
||||
Functions\when('sanitize_key')->alias(static fn ($v) => strtolower((string) $v));
|
||||
Functions\when('get_permalink')->justReturn(false);
|
||||
|
||||
// A deleted page resolves to false, which must not become a broken link.
|
||||
$html = $this->ctx['page']->render([ 'loginPageId' => 4 ]);
|
||||
|
||||
self::assertStringContainsString('already have an account', $html);
|
||||
self::assertStringNotContainsString('<a href', $html);
|
||||
|
||||
self::assertStringNotContainsString('<a href', $this->ctx['page']->render([]));
|
||||
}
|
||||
|
||||
public function testContinueUrlIsNullWithoutAResolvablePage(): void
|
||||
|
||||
@@ -8,6 +8,7 @@ use Mockery;
|
||||
use Unsupervised\Schedular\Availability\AvailabilityController;
|
||||
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||||
use Unsupervised\Schedular\Availability\AvailabilitySlot;
|
||||
use Unsupervised\Schedular\Availability\WindowValidator;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
@@ -23,7 +24,7 @@ class AvailabilityControllerTest extends TestCase
|
||||
|
||||
$this->repository = Mockery::mock(AvailabilityRepository::class);
|
||||
$this->offerings = Mockery::mock(OfferingRepository::class);
|
||||
$this->controller = new AvailabilityController($this->repository, $this->offerings);
|
||||
$this->controller = new AvailabilityController($this->repository, $this->offerings, new WindowValidator($this->offerings));
|
||||
|
||||
$_POST = [];
|
||||
$_GET = [];
|
||||
@@ -39,6 +40,7 @@ class AvailabilityControllerTest extends TestCase
|
||||
Functions\when('absint')->alias(static fn ($value) => abs((int) $value));
|
||||
Functions\when('get_option')->justReturn(1);
|
||||
Functions\when('current_time')->justReturn('2026-07-06');
|
||||
Functions\when('selected')->justReturn('');
|
||||
Functions\when('admin_url')->justReturn('admin.php?page=us-availability');
|
||||
Functions\when('add_query_arg')->justReturn('admin.php?page=us-availability&usc_view=week');
|
||||
Functions\when('wp_nonce_field')->justReturn('');
|
||||
@@ -131,6 +133,159 @@ class AvailabilityControllerTest extends TestCase
|
||||
self::assertStringNotContainsString('name="slot_ids[]" form="usc-bulk-delete-form" value="6"', $html);
|
||||
}
|
||||
|
||||
/**
|
||||
* The reported bug: a 30-minute window submitted with the lesson length left
|
||||
* on 60 saved nothing and said nothing. It must now say why.
|
||||
*/
|
||||
public function testAddShowsAnErrorWhenTheWindowIsShorterThanTheLessonLength(): void
|
||||
{
|
||||
$_POST = [
|
||||
'usc_action' => 'add',
|
||||
'start_dt' => '2026-09-10T17:30',
|
||||
'end_dt' => '2026-09-10T18:00',
|
||||
'duration_minutes' => '60',
|
||||
];
|
||||
|
||||
$this->repository->shouldNotReceive('createFromWindow');
|
||||
$this->repository->shouldReceive('findByInstructor')->once()->with(3)->andReturn([]);
|
||||
|
||||
$html = $this->render();
|
||||
|
||||
self::assertStringContainsString('notice-error', $html);
|
||||
self::assertStringContainsString('60-minute lesson length', $html);
|
||||
self::assertStringNotContainsString('notice-success', $html);
|
||||
}
|
||||
|
||||
/** @return array<string, array{array<string, string>, string}> */
|
||||
public static function invalidWindows(): array
|
||||
{
|
||||
return [
|
||||
'unparseable start' => [['start_dt' => 'whenever', 'end_dt' => '2026-09-10T18:00'], 'valid start and end'],
|
||||
'end before start' => [['start_dt' => '2026-09-10T18:00', 'end_dt' => '2026-09-10T17:00'], 'after the start time'],
|
||||
'spans two days' => [['start_dt' => '2026-09-10T23:00', 'end_dt' => '2026-09-11T01:00'], 'same day'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Each of these used to be a bare `return` — the page reloaded unchanged and
|
||||
* the instructor had no way to tell the save had failed.
|
||||
*
|
||||
* @dataProvider invalidWindows
|
||||
*
|
||||
* @param array<string, string> $fields
|
||||
*/
|
||||
public function testAddReportsEveryRejectedWindow(array $fields, string $expected): void
|
||||
{
|
||||
$_POST = array_merge([ 'usc_action' => 'add', 'duration_minutes' => '30' ], $fields);
|
||||
|
||||
$this->repository->shouldNotReceive('createFromWindow');
|
||||
$this->repository->shouldReceive('findByInstructor')->once()->with(3)->andReturn([]);
|
||||
|
||||
$html = $this->render();
|
||||
|
||||
self::assertStringContainsString('notice-error', $html);
|
||||
self::assertStringContainsString($expected, $html);
|
||||
}
|
||||
|
||||
public function testAddRejectsAnOfferingTheInstructorDoesNotOwn(): void
|
||||
{
|
||||
// The REST endpoint always checked this; the admin form never did, so a
|
||||
// crafted POST could tie a slot to another instructor's offering.
|
||||
$_POST = [
|
||||
'usc_action' => 'add',
|
||||
'start_dt' => '2026-09-10T17:00',
|
||||
'end_dt' => '2026-09-10T18:00',
|
||||
'duration_minutes' => '60',
|
||||
'offering_id' => '8',
|
||||
];
|
||||
|
||||
$this->offerings->shouldReceive('findById')->once()->with(8)->andReturn(null);
|
||||
$this->repository->shouldNotReceive('createFromWindow');
|
||||
$this->repository->shouldReceive('findByInstructor')->once()->with(3)->andReturn([]);
|
||||
|
||||
$html = $this->render();
|
||||
|
||||
self::assertStringContainsString('notice-error', $html);
|
||||
self::assertStringContainsString('not available', $html);
|
||||
}
|
||||
|
||||
public function testAddReportsHowManySlotsWereCreated(): void
|
||||
{
|
||||
$_POST = [
|
||||
'usc_action' => 'add',
|
||||
'start_dt' => '2026-09-10T17:00',
|
||||
'end_dt' => '2026-09-10T19:00',
|
||||
'duration_minutes' => '60',
|
||||
'recurrence' => 'weekly',
|
||||
'weeks' => '41',
|
||||
];
|
||||
|
||||
$this->repository->shouldReceive('createFromWindow')->once()
|
||||
->with(Mockery::type(AvailabilitySlot::class), true, 41)
|
||||
->andReturn(range(1, 82));
|
||||
$this->repository->shouldReceive('findByInstructor')->once()->with(3)->andReturn([]);
|
||||
|
||||
$html = $this->render();
|
||||
|
||||
self::assertStringContainsString('notice-success', $html);
|
||||
self::assertStringContainsString('Added 82 bookable slots.', $html);
|
||||
}
|
||||
|
||||
public function testAddReportsAFailedWrite(): void
|
||||
{
|
||||
// A valid window always splits into at least one slot, so an empty result
|
||||
// means the inserts themselves failed.
|
||||
$_POST = [
|
||||
'usc_action' => 'add',
|
||||
'start_dt' => '2026-09-10T17:00',
|
||||
'end_dt' => '2026-09-10T18:00',
|
||||
'duration_minutes' => '60',
|
||||
];
|
||||
|
||||
$this->repository->shouldReceive('createFromWindow')->once()->andReturn([]);
|
||||
$this->repository->shouldReceive('findByInstructor')->once()->with(3)->andReturn([]);
|
||||
|
||||
$html = $this->render();
|
||||
|
||||
self::assertStringContainsString('notice-error', $html);
|
||||
self::assertStringContainsString('could not be saved', $html);
|
||||
}
|
||||
|
||||
public function testSingleDeleteReportsAFailureToDelete(): void
|
||||
{
|
||||
$_POST = [ 'usc_action' => 'delete', 'slot_id' => '7' ];
|
||||
|
||||
$other = new AvailabilitySlot(instructorId: 4, startDt: '2026-07-08 09:00:00', endDt: '2026-07-08 10:00:00', id: 7);
|
||||
|
||||
$this->repository->shouldReceive('findById')->once()->with(7)->andReturn($other);
|
||||
$this->repository->shouldReceive('findByInstructor')->once()->with(3)->andReturn([]);
|
||||
|
||||
$html = $this->render();
|
||||
|
||||
self::assertStringContainsString('notice-error', $html);
|
||||
self::assertStringContainsString('could not be deleted', $html);
|
||||
}
|
||||
|
||||
public function testBulkDeleteReportsBothHalvesOfAPartialResult(): void
|
||||
{
|
||||
$_POST = [ 'usc_action' => 'bulk_delete', 'slot_ids' => ['5', '7'] ];
|
||||
|
||||
$owned = new AvailabilitySlot(instructorId: 3, startDt: '2026-07-08 09:00:00', endDt: '2026-07-08 10:00:00', id: 5);
|
||||
$booked = new AvailabilitySlot(instructorId: 3, startDt: '2026-07-08 10:00:00', endDt: '2026-07-08 11:00:00', isBooked: true, id: 7);
|
||||
|
||||
$this->repository->shouldReceive('findById')->once()->with(5)->andReturn($owned);
|
||||
$this->repository->shouldReceive('findById')->once()->with(7)->andReturn($booked);
|
||||
$this->repository->shouldReceive('delete')->once()->with(5)->andReturn(true);
|
||||
// The repository refuses a booked row, reporting it by returning false.
|
||||
$this->repository->shouldReceive('delete')->once()->with(7)->andReturn(false);
|
||||
$this->repository->shouldReceive('findByInstructor')->once()->with(3)->andReturn([]);
|
||||
|
||||
$html = $this->render();
|
||||
|
||||
self::assertStringContainsString('1 slot deleted.', $html);
|
||||
self::assertStringContainsString('1 slot could not be deleted', $html);
|
||||
}
|
||||
|
||||
private function render(): string
|
||||
{
|
||||
ob_start();
|
||||
|
||||
@@ -8,6 +8,7 @@ use Mockery;
|
||||
use Unsupervised\Schedular\Availability\AvailabilityEndpoint;
|
||||
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||||
use Unsupervised\Schedular\Availability\AvailabilitySlot;
|
||||
use Unsupervised\Schedular\Availability\WindowValidator;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
@@ -26,7 +27,7 @@ class AvailabilityEndpointTest extends TestCase
|
||||
|
||||
$this->repository = Mockery::mock(AvailabilityRepository::class);
|
||||
$this->offerings = Mockery::mock(OfferingRepository::class);
|
||||
$this->endpoint = new AvailabilityEndpoint($this->repository, $this->offerings);
|
||||
$this->endpoint = new AvailabilityEndpoint($this->repository, new WindowValidator($this->offerings));
|
||||
}
|
||||
|
||||
public function testCreateRejectsWindowSpanningMultipleDays(): void
|
||||
|
||||
@@ -49,6 +49,101 @@ class AvailabilityRepositoryTest extends TestCase
|
||||
self::assertSame(42, $result);
|
||||
}
|
||||
|
||||
public function testInsertReturnsZeroWhenTheWriteFails(): void
|
||||
{
|
||||
Functions\expect('current_time')->with('mysql')->andReturn('2026-04-01 12:00:00');
|
||||
|
||||
// wpdb::insert returns false on error, but insert_id still holds the
|
||||
// previous statement's id — returning it made a failed write look like a
|
||||
// successful one.
|
||||
$this->db->shouldReceive('insert')->once()->andReturn(false);
|
||||
$this->db->insert_id = 42;
|
||||
|
||||
$slot = new AvailabilitySlot(5, '2026-04-01 09:00:00', '2026-04-01 10:00:00', 30);
|
||||
|
||||
self::assertSame(0, $this->repo->insert($slot));
|
||||
}
|
||||
|
||||
public function testCreateFromWindowOmitsChunksThatFailedToInsert(): void
|
||||
{
|
||||
Functions\when('current_time')->justReturn('2026-04-01 12:00:00');
|
||||
|
||||
// Three chunks; the middle write fails.
|
||||
$results = [null, false, null];
|
||||
$ids = [11, 13];
|
||||
|
||||
$this->db->shouldReceive('insert')
|
||||
->times(3)
|
||||
->andReturnUsing(function () use (&$results, &$ids) {
|
||||
$outcome = array_shift($results);
|
||||
|
||||
if (false !== $outcome) {
|
||||
$this->db->insert_id = array_shift($ids);
|
||||
}
|
||||
|
||||
return $outcome;
|
||||
});
|
||||
|
||||
$window = new AvailabilitySlot(5, '2026-04-01 09:00:00', '2026-04-01 10:30:00', 30);
|
||||
|
||||
self::assertSame([11, 13], $this->repo->createFromWindow($window));
|
||||
}
|
||||
|
||||
public function testWeeklySeriesIsClampedToTheMaximum(): void
|
||||
{
|
||||
Functions\when('current_time')->justReturn('2026-04-01 12:00:00');
|
||||
|
||||
// The form's max is advisory; a hand-crafted POST could ask for any
|
||||
// number, so the ceiling is enforced here.
|
||||
$next = 1;
|
||||
$this->db->shouldReceive('insert')
|
||||
->times(AvailabilitySlot::MAX_WEEKLY_OCCURRENCES)
|
||||
->andReturnUsing(function () use (&$next) {
|
||||
$this->db->insert_id = $next++;
|
||||
|
||||
return null;
|
||||
});
|
||||
$this->db->shouldReceive('update')->once();
|
||||
|
||||
$first = new AvailabilitySlot(5, '2026-04-01 09:00:00', '2026-04-01 10:00:00', 60);
|
||||
|
||||
self::assertCount(
|
||||
AvailabilitySlot::MAX_WEEKLY_OCCURRENCES,
|
||||
$this->repo->createWeeklySeries($first, 10000)
|
||||
);
|
||||
}
|
||||
|
||||
public function testWeeklySeriesGroupsOnTheFirstRowThatActuallyWrote(): void
|
||||
{
|
||||
Functions\when('current_time')->justReturn('2026-04-01 12:00:00');
|
||||
|
||||
// The first insert fails. A failed row must not become the recurrence
|
||||
// group (its id is 0), which would orphan every later occurrence.
|
||||
$results = [false, null, null];
|
||||
$ids = [21, 22];
|
||||
|
||||
$this->db->shouldReceive('insert')
|
||||
->times(3)
|
||||
->andReturnUsing(function () use (&$results, &$ids) {
|
||||
$outcome = array_shift($results);
|
||||
|
||||
if (false !== $outcome) {
|
||||
$this->db->insert_id = array_shift($ids);
|
||||
}
|
||||
|
||||
return $outcome;
|
||||
});
|
||||
|
||||
// The group is set from row 21 — the first that survived.
|
||||
$this->db->shouldReceive('update')
|
||||
->once()
|
||||
->with('wp_us_availability', ['recurrence_group' => 21], ['id' => 21], ['%d'], ['%d']);
|
||||
|
||||
$first = new AvailabilitySlot(5, '2026-04-01 09:00:00', '2026-04-01 10:00:00', 60);
|
||||
|
||||
self::assertSame([21, 22], $this->repo->createWeeklySeries($first, 3));
|
||||
}
|
||||
|
||||
public function testCreateWeeklySeriesInsertsWeeklyAndSharesGroup(): void
|
||||
{
|
||||
Functions\when('current_time')->justReturn('2026-04-07 12:00:00');
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Tests\Unit\Availability;
|
||||
|
||||
use Mockery;
|
||||
use Unsupervised\Schedular\Availability\AvailabilitySlot;
|
||||
use Unsupervised\Schedular\Availability\WindowValidator;
|
||||
use Unsupervised\Schedular\Offering\Offering;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
class WindowValidatorTest extends TestCase
|
||||
{
|
||||
private OfferingRepository&Mockery\MockInterface $offerings;
|
||||
private WindowValidator $validator;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->offerings = Mockery::mock(OfferingRepository::class);
|
||||
$this->validator = new WindowValidator($this->offerings);
|
||||
}
|
||||
|
||||
/**
|
||||
* The reported bug: 5:30–6:00 PM submitted with the length select left on
|
||||
* its 60-minute default. The window fits no lesson, so it used to persist
|
||||
* nothing at all and say nothing.
|
||||
*/
|
||||
public function testRejectsAWindowShorterThanTheLessonLength(): void
|
||||
{
|
||||
$result = $this->validator->validate(3, '2026-09-10 17:30', '2026-09-10 18:00', 60, 0);
|
||||
|
||||
self::assertInstanceOf(\WP_Error::class, $result);
|
||||
self::assertSame('invalid_window', $result->get_error_code());
|
||||
// The message names the length actually chosen, so the fix is obvious.
|
||||
self::assertStringContainsString('60-minute', $result->get_error_message());
|
||||
}
|
||||
|
||||
public function testAcceptsThatSameWindowAtAFittingLessonLength(): void
|
||||
{
|
||||
$result = $this->validator->validate(3, '2026-09-10 17:30', '2026-09-10 18:00', 30, 0);
|
||||
|
||||
self::assertInstanceOf(AvailabilitySlot::class, $result);
|
||||
self::assertSame('2026-09-10 17:30:00', $result->startDt);
|
||||
self::assertSame('2026-09-10 18:00:00', $result->endDt);
|
||||
self::assertSame(30, $result->durationMinutes);
|
||||
self::assertNull($result->offeringId);
|
||||
self::assertCount(1, $result->splitByDuration());
|
||||
}
|
||||
|
||||
/** @return array<string, array{string, string}> */
|
||||
public static function badDateTimes(): array
|
||||
{
|
||||
return [
|
||||
'empty start' => ['', '2026-09-10 18:00'],
|
||||
'empty end' => ['2026-09-10 17:00', ''],
|
||||
'unparseable start' => ['tomorrow', '2026-09-10 18:00'],
|
||||
'unparseable end' => ['2026-09-10 17:00', 'not a date'],
|
||||
];
|
||||
}
|
||||
|
||||
/** @dataProvider badDateTimes */
|
||||
public function testRejectsUnusableDateTimes(string $start, string $end): void
|
||||
{
|
||||
$result = $this->validator->validate(3, $start, $end, 30, 0);
|
||||
|
||||
self::assertInstanceOf(\WP_Error::class, $result);
|
||||
self::assertSame('invalid_datetime', $result->get_error_code());
|
||||
}
|
||||
|
||||
public function testRejectsAnEndAtOrBeforeTheStart(): void
|
||||
{
|
||||
$backwards = $this->validator->validate(3, '2026-09-10 18:00', '2026-09-10 17:00', 30, 0);
|
||||
$identical = $this->validator->validate(3, '2026-09-10 18:00', '2026-09-10 18:00', 30, 0);
|
||||
|
||||
self::assertInstanceOf(\WP_Error::class, $backwards);
|
||||
self::assertInstanceOf(\WP_Error::class, $identical);
|
||||
self::assertSame('invalid_datetime', $backwards->get_error_code());
|
||||
self::assertSame('invalid_datetime', $identical->get_error_code());
|
||||
}
|
||||
|
||||
public function testRejectsAWindowSpanningTwoDays(): void
|
||||
{
|
||||
$result = $this->validator->validate(3, '2026-09-10 23:00', '2026-09-11 01:00', 30, 0);
|
||||
|
||||
self::assertInstanceOf(\WP_Error::class, $result);
|
||||
self::assertSame('invalid_window', $result->get_error_code());
|
||||
self::assertStringContainsString('same day', $result->get_error_message());
|
||||
}
|
||||
|
||||
public function testRejectsAnOfferingOwnedBySomeoneElse(): void
|
||||
{
|
||||
// Instructor 3 posting instructor 9's offering: accepting it would let a
|
||||
// slot inherit another instructor's price and payment routing.
|
||||
$this->offerings->shouldReceive('findById')->once()->with(8)
|
||||
->andReturn(new Offering(instructorId: 9, title: 'Theirs', kind: Offering::KIND_PRIVATE_LESSON, id: 8));
|
||||
|
||||
$result = $this->validator->validate(3, '2026-09-10 17:00', '2026-09-10 18:00', 60, 8);
|
||||
|
||||
self::assertInstanceOf(\WP_Error::class, $result);
|
||||
self::assertSame('invalid_offering', $result->get_error_code());
|
||||
}
|
||||
|
||||
public function testRejectsAnOfferingThatDoesNotExist(): void
|
||||
{
|
||||
$this->offerings->shouldReceive('findById')->once()->with(8)->andReturn(null);
|
||||
|
||||
$result = $this->validator->validate(3, '2026-09-10 17:00', '2026-09-10 18:00', 60, 8);
|
||||
|
||||
self::assertInstanceOf(\WP_Error::class, $result);
|
||||
self::assertSame('invalid_offering', $result->get_error_code());
|
||||
}
|
||||
|
||||
public function testKeepsAnOfferingTheInstructorOwns(): void
|
||||
{
|
||||
$this->offerings->shouldReceive('findById')->once()->with(8)
|
||||
->andReturn(new Offering(instructorId: 3, title: 'Mine', kind: Offering::KIND_PRIVATE_LESSON, id: 8));
|
||||
|
||||
$result = $this->validator->validate(3, '2026-09-10 17:00', '2026-09-10 18:00', 60, 8);
|
||||
|
||||
self::assertInstanceOf(AvailabilitySlot::class, $result);
|
||||
self::assertSame(8, $result->offeringId);
|
||||
}
|
||||
|
||||
public function testFallsBackToTheDefaultLessonLength(): void
|
||||
{
|
||||
$result = $this->validator->validate(3, '2026-09-10 09:00', '2026-09-10 10:00', 0, 0);
|
||||
|
||||
self::assertInstanceOf(AvailabilitySlot::class, $result);
|
||||
self::assertSame(AvailabilitySlot::DEFAULT_DURATION_MINUTES, $result->durationMinutes);
|
||||
}
|
||||
|
||||
public function testAcceptsTheDatetimeLocalFormTheFormActuallySubmits(): void
|
||||
{
|
||||
// The browser posts `Y-m-d\TH:i`, not the canonical space-separated form.
|
||||
$result = $this->validator->validate(3, '2026-09-10T17:00', '2026-09-10T18:00', 60, 0);
|
||||
|
||||
self::assertInstanceOf(AvailabilitySlot::class, $result);
|
||||
self::assertSame('2026-09-10 17:00:00', $result->startDt);
|
||||
}
|
||||
}
|
||||
@@ -187,7 +187,12 @@ class ScheduledBillingRunnerTest extends TestCase
|
||||
$this->runner->run();
|
||||
}
|
||||
|
||||
public function testGroupMonthlyBillsMonthTotal(): void
|
||||
/**
|
||||
* A monthly group class is priced per month, not per session: the same fee
|
||||
* is charged whether the class meets four times in the month or once. This
|
||||
* is what the class card quotes and what the student agrees to pay.
|
||||
*/
|
||||
public function testGroupMonthlyBillsTheMonthlyFeeOnceHoweverManySessions(): void
|
||||
{
|
||||
$this->now('2026-07-15 09:00:00');
|
||||
$enrollment = new Enrollment(offeringId: 9, studentId: 5, instructorId: 3, id: 44);
|
||||
@@ -197,10 +202,32 @@ class ScheduledBillingRunnerTest extends TestCase
|
||||
|
||||
$this->payments->shouldReceive('scheduledPaymentExists')->with(Payment::REG_ENROLLMENT, 44, '2026-07')->andReturn(false);
|
||||
|
||||
// One payment: 4 sessions x 20, due on the 1st.
|
||||
// One payment of the monthly fee — not 4 x 20 — due on the 1st.
|
||||
$this->payments->shouldReceive('createForRegistration')
|
||||
->once()
|
||||
->with(Payment::REG_ENROLLMENT, 44, 5, 3, 80.0, 'CAD', null, '2026-07-01', '2026-07')
|
||||
->with(Payment::REG_ENROLLMENT, 44, 5, 3, 20.0, 'CAD', null, '2026-07-01', '2026-07')
|
||||
->andReturn($this->pending(800, '2026-07-01'));
|
||||
|
||||
$this->runner->run();
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-month fee does not shrink for a short month either — a month with
|
||||
* a single session is billed the same as a month with four.
|
||||
*/
|
||||
public function testGroupMonthlyBillsTheSameFeeForAMonthWithOneSession(): void
|
||||
{
|
||||
$this->now('2026-07-15 09:00:00');
|
||||
$enrollment = new Enrollment(offeringId: 9, studentId: 5, instructorId: 3, id: 44);
|
||||
$this->enrollments->shouldReceive('findActiveByBillingModes')->andReturn([ $enrollment ]);
|
||||
// A single July session.
|
||||
$this->offerings->shouldReceive('findById')->with(9)->andReturn($this->groupOffering(Offering::BILLING_MONTHLY, '2026-07-07', '2026-07-07'));
|
||||
|
||||
$this->payments->shouldReceive('scheduledPaymentExists')->with(Payment::REG_ENROLLMENT, 44, '2026-07')->andReturn(false);
|
||||
|
||||
$this->payments->shouldReceive('createForRegistration')
|
||||
->once()
|
||||
->with(Payment::REG_ENROLLMENT, 44, 5, 3, 20.0, 'CAD', null, '2026-07-01', '2026-07')
|
||||
->andReturn($this->pending(800, '2026-07-01'));
|
||||
|
||||
$this->runner->run();
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Tests\Unit\Policy;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use Mockery;
|
||||
use Unsupervised\Schedular\Policy\Policy;
|
||||
use Unsupervised\Schedular\Policy\PolicyController;
|
||||
use Unsupervised\Schedular\Policy\PolicyRepository;
|
||||
use Unsupervised\Schedular\Policy\PolicyService;
|
||||
use Unsupervised\Schedular\Policy\PolicyVersion;
|
||||
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
class PolicyControllerTest extends TestCase
|
||||
{
|
||||
private PolicyRepository&Mockery\MockInterface $policies;
|
||||
private PolicyVersionRepository&Mockery\MockInterface $versions;
|
||||
private PolicyService&Mockery\MockInterface $service;
|
||||
private PolicyController $controller;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->policies = Mockery::mock(PolicyRepository::class);
|
||||
$this->versions = Mockery::mock(PolicyVersionRepository::class);
|
||||
$this->service = Mockery::mock(PolicyService::class);
|
||||
$this->controller = new PolicyController($this->policies, $this->versions, $this->service);
|
||||
|
||||
$_POST = [];
|
||||
$_GET = [];
|
||||
|
||||
Functions\when('current_user_can')->justReturn(true);
|
||||
Functions\when('check_admin_referer')->justReturn(true);
|
||||
Functions\when('wp_unslash')->returnArg();
|
||||
Functions\when('sanitize_text_field')->returnArg();
|
||||
Functions\when('sanitize_title')->returnArg();
|
||||
Functions\when('wp_kses_post')->returnArg();
|
||||
$this->stubAutop();
|
||||
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('selected')->justReturn('');
|
||||
Functions\when('wp_nonce_field')->justReturn('');
|
||||
Functions\when('admin_url')->justReturn('admin.php');
|
||||
Functions\when('add_query_arg')->alias(
|
||||
static fn (array $args, string $url) => $url . '?' . http_build_query($args)
|
||||
);
|
||||
Functions\when('submit_button')->alias(static function (string $text = ''): void {
|
||||
echo $text; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- test stub
|
||||
});
|
||||
}
|
||||
|
||||
public function testViewingAVersionRendersItsBodyAndAnEditForm(): void
|
||||
{
|
||||
$policy = new Policy('Cancellation', 'cancellation', currentVersionId: 7, id: 4);
|
||||
$version = new PolicyVersion(
|
||||
policyId: 4,
|
||||
versionNumber: 2,
|
||||
body: '<p>Cancel 24 hours ahead.</p>',
|
||||
status: PolicyVersion::STATUS_PUBLISHED,
|
||||
publishedAt: '2026-07-01 09:00:00',
|
||||
id: 7,
|
||||
);
|
||||
|
||||
$_GET = [
|
||||
'policy_id' => '4',
|
||||
'version_id' => '7',
|
||||
];
|
||||
|
||||
$this->policies->shouldReceive('findAll')->andReturn([$policy]);
|
||||
$this->policies->shouldReceive('findById')->with(4)->andReturn($policy);
|
||||
$this->versions->shouldReceive('findByPolicy')->with(4)->andReturn([$version]);
|
||||
$this->versions->shouldReceive('findById')->with(7)->andReturn($version);
|
||||
|
||||
$html = $this->render();
|
||||
|
||||
self::assertStringContainsString('Cancel 24 hours ahead.', $html);
|
||||
self::assertStringContainsString('value="edit_version"', $html);
|
||||
self::assertStringContainsString('Save as New Draft', $html);
|
||||
}
|
||||
|
||||
public function testVersionListLinksToTheViewer(): void
|
||||
{
|
||||
$policy = new Policy('Cancellation', 'cancellation', currentVersionId: 7, id: 4);
|
||||
$version = new PolicyVersion(policyId: 4, versionNumber: 2, body: 'text', id: 7);
|
||||
|
||||
$_GET = ['policy_id' => '4'];
|
||||
|
||||
$this->policies->shouldReceive('findAll')->andReturn([$policy]);
|
||||
$this->policies->shouldReceive('findById')->with(4)->andReturn($policy);
|
||||
$this->versions->shouldReceive('findByPolicy')->with(4)->andReturn([$version]);
|
||||
|
||||
$html = $this->render();
|
||||
|
||||
self::assertStringContainsString('page=us-policies&policy_id=4&version_id=7', $html);
|
||||
}
|
||||
|
||||
public function testAVersionBelongingToAnotherPolicyIsNotShown(): void
|
||||
{
|
||||
$policy = new Policy('Cancellation', 'cancellation', id: 4);
|
||||
$other = new PolicyVersion(policyId: 9, versionNumber: 1, body: 'Someone else\'s policy', id: 7);
|
||||
|
||||
$_GET = [
|
||||
'policy_id' => '4',
|
||||
'version_id' => '7',
|
||||
];
|
||||
|
||||
$this->policies->shouldReceive('findAll')->andReturn([$policy]);
|
||||
$this->policies->shouldReceive('findById')->with(4)->andReturn($policy);
|
||||
$this->versions->shouldReceive('findByPolicy')->with(4)->andReturn([]);
|
||||
$this->versions->shouldReceive('findById')->with(7)->andReturn($other);
|
||||
|
||||
$html = $this->render();
|
||||
|
||||
self::assertStringNotContainsString('Someone else\'s policy', $html);
|
||||
self::assertStringNotContainsString('value="edit_version"', $html);
|
||||
}
|
||||
|
||||
public function testEditingAPublishedVersionCreatesANewDraftAndLeavesTheSourceUntouched(): void
|
||||
{
|
||||
$policy = new Policy('Cancellation', 'cancellation', currentVersionId: 7, id: 4);
|
||||
$published = new PolicyVersion(
|
||||
policyId: 4,
|
||||
versionNumber: 2,
|
||||
body: '<p>Old text.</p>',
|
||||
status: PolicyVersion::STATUS_PUBLISHED,
|
||||
id: 7,
|
||||
);
|
||||
$draft = new PolicyVersion(policyId: 4, versionNumber: 3, body: '<p>New text.</p>', id: 11);
|
||||
|
||||
$_GET = ['policy_id' => '4'];
|
||||
$_POST = [
|
||||
'usc_action' => 'edit_version',
|
||||
'policy_id' => '4',
|
||||
'version_id' => '7',
|
||||
'body' => '<p>New text.</p>',
|
||||
];
|
||||
|
||||
$this->policies->shouldReceive('findAll')->andReturn([$policy]);
|
||||
$this->policies->shouldReceive('findById')->with(4)->andReturn($policy);
|
||||
$this->versions->shouldReceive('findByPolicy')->with(4)->andReturn([$draft, $published]);
|
||||
$this->versions->shouldReceive('findById')->with(7)->andReturn($published);
|
||||
$this->versions->shouldReceive('findById')->with(11)->andReturn($draft);
|
||||
|
||||
// A new draft is written; the edited version itself is never updated.
|
||||
$this->service->shouldReceive('addDraftVersion')->once()->with(4, '<p>New text.</p>')->andReturn(11);
|
||||
$this->versions->shouldNotReceive('updateBody');
|
||||
|
||||
$html = $this->render();
|
||||
|
||||
// The viewer follows through to the draft that was just created.
|
||||
self::assertStringContainsString('New text.', $html);
|
||||
self::assertStringContainsString('saved as a new draft version', $html);
|
||||
}
|
||||
|
||||
public function testEditingADraftVersionUpdatesItInPlace(): void
|
||||
{
|
||||
$policy = new Policy('Cancellation', 'cancellation', id: 4);
|
||||
$draft = new PolicyVersion(
|
||||
policyId: 4,
|
||||
versionNumber: 3,
|
||||
body: '<p>Old draft text.</p>',
|
||||
status: PolicyVersion::STATUS_DRAFT,
|
||||
id: 11,
|
||||
);
|
||||
|
||||
$_GET = ['policy_id' => '4'];
|
||||
$_POST = [
|
||||
'usc_action' => 'edit_version',
|
||||
'policy_id' => '4',
|
||||
'version_id' => '11',
|
||||
'body' => '<p>Revised draft text.</p>',
|
||||
];
|
||||
|
||||
$this->policies->shouldReceive('findAll')->andReturn([$policy]);
|
||||
$this->policies->shouldReceive('findById')->with(4)->andReturn($policy);
|
||||
$this->versions->shouldReceive('findByPolicy')->with(4)->andReturn([$draft]);
|
||||
$this->versions->shouldReceive('findById')->with(11)->andReturn($draft);
|
||||
|
||||
// An unpublished draft is rewritten rather than branched.
|
||||
$this->versions->shouldReceive('updateBody')->once()->with(11, '<p>Revised draft text.</p>')->andReturn(true);
|
||||
$this->service->shouldNotReceive('addDraftVersion');
|
||||
|
||||
$html = $this->render();
|
||||
|
||||
self::assertStringContainsString('Draft version 3 was updated', $html);
|
||||
}
|
||||
|
||||
public function testEditingAnArchivedVersionCreatesANewDraft(): void
|
||||
{
|
||||
$policy = new Policy('Cancellation', 'cancellation', currentVersionId: 9, id: 4);
|
||||
$archived = new PolicyVersion(
|
||||
policyId: 4,
|
||||
versionNumber: 1,
|
||||
body: '<p>Superseded text.</p>',
|
||||
status: PolicyVersion::STATUS_ARCHIVED,
|
||||
id: 7,
|
||||
);
|
||||
$draft = new PolicyVersion(policyId: 4, versionNumber: 4, body: '<p>Reinstated text.</p>', id: 12);
|
||||
|
||||
$_GET = ['policy_id' => '4'];
|
||||
$_POST = [
|
||||
'usc_action' => 'edit_version',
|
||||
'policy_id' => '4',
|
||||
'version_id' => '7',
|
||||
'body' => '<p>Reinstated text.</p>',
|
||||
];
|
||||
|
||||
$this->policies->shouldReceive('findAll')->andReturn([$policy]);
|
||||
$this->policies->shouldReceive('findById')->with(4)->andReturn($policy);
|
||||
$this->versions->shouldReceive('findByPolicy')->with(4)->andReturn([$draft, $archived]);
|
||||
$this->versions->shouldReceive('findById')->with(7)->andReturn($archived);
|
||||
$this->versions->shouldReceive('findById')->with(12)->andReturn($draft);
|
||||
|
||||
$this->service->shouldReceive('addDraftVersion')->once()->with(4, '<p>Reinstated text.</p>')->andReturn(12);
|
||||
$this->versions->shouldNotReceive('updateBody');
|
||||
|
||||
$this->render();
|
||||
}
|
||||
|
||||
public function testEditingAVersionOfAnotherPolicyIsRejected(): void
|
||||
{
|
||||
$policy = new Policy('Cancellation', 'cancellation', id: 4);
|
||||
$other = new PolicyVersion(policyId: 9, versionNumber: 1, body: 'Other', id: 7);
|
||||
|
||||
$_GET = ['policy_id' => '4'];
|
||||
$_POST = [
|
||||
'usc_action' => 'edit_version',
|
||||
'policy_id' => '4',
|
||||
'version_id' => '7',
|
||||
'body' => 'Injected text',
|
||||
];
|
||||
|
||||
$this->policies->shouldReceive('findAll')->andReturn([$policy]);
|
||||
$this->policies->shouldReceive('findById')->with(4)->andReturn($policy);
|
||||
$this->versions->shouldReceive('findByPolicy')->with(4)->andReturn([]);
|
||||
$this->versions->shouldReceive('findById')->with(7)->andReturn($other);
|
||||
|
||||
$this->service->shouldNotReceive('addDraftVersion');
|
||||
$this->versions->shouldNotReceive('updateBody');
|
||||
|
||||
$this->render();
|
||||
}
|
||||
|
||||
private function render(): string
|
||||
{
|
||||
ob_start();
|
||||
$this->controller->renderPage();
|
||||
|
||||
return (string) ob_get_clean();
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,14 @@ use Unsupervised\Schedular\Policy\Policy;
|
||||
use Unsupervised\Schedular\Policy\PolicyEndpoint;
|
||||
use Unsupervised\Schedular\Policy\PolicyRepository;
|
||||
use Unsupervised\Schedular\Policy\PolicyService;
|
||||
use Unsupervised\Schedular\Policy\PolicyVersion;
|
||||
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
class PolicyEndpointTest extends TestCase
|
||||
{
|
||||
private PolicyRepository&Mockery\MockInterface $policies;
|
||||
private PolicyVersionRepository&Mockery\MockInterface $versions;
|
||||
private PolicyService&Mockery\MockInterface $service;
|
||||
private PolicyEndpoint $endpoint;
|
||||
|
||||
@@ -24,16 +26,43 @@ class PolicyEndpointTest extends TestCase
|
||||
|
||||
Functions\when('sanitize_text_field')->returnArg();
|
||||
Functions\when('sanitize_title')->returnArg();
|
||||
Functions\when('wp_kses_post')->returnArg();
|
||||
$this->stubAutop();
|
||||
|
||||
$this->policies = Mockery::mock(PolicyRepository::class);
|
||||
$this->versions = Mockery::mock(PolicyVersionRepository::class);
|
||||
$this->service = Mockery::mock(PolicyService::class);
|
||||
$this->endpoint = new PolicyEndpoint(
|
||||
$this->policies,
|
||||
Mockery::mock(PolicyVersionRepository::class),
|
||||
$this->versions,
|
||||
$this->service,
|
||||
);
|
||||
}
|
||||
|
||||
public function testIndexReturnsPlainTextBodiesAsParagraphs(): void
|
||||
{
|
||||
$policy = new Policy('Cancellation', 'cancellation', currentVersionId: 7, id: 4);
|
||||
$version = new PolicyVersion(
|
||||
policyId: 4,
|
||||
versionNumber: 2,
|
||||
body: "Give 24 hours notice.\n\nLate cancellations are billed in full.",
|
||||
status: PolicyVersion::STATUS_PUBLISHED,
|
||||
id: 7,
|
||||
);
|
||||
|
||||
$this->policies->shouldReceive('findAll')->andReturn([$policy]);
|
||||
$this->versions->shouldReceive('findById')->with(7)->andReturn($version);
|
||||
|
||||
$body = $this->endpoint->index(new \WP_REST_Request([]))->get_data();
|
||||
|
||||
// The admin typed no markup, so the gate still receives real paragraphs
|
||||
// rather than one unbroken run of text.
|
||||
self::assertSame(
|
||||
'<p>Give 24 hours notice.</p><p>Late cancellations are billed in full.</p>',
|
||||
$body[0]['body']
|
||||
);
|
||||
}
|
||||
|
||||
public function testCreateRejectsTitleLongerThanColumnLimit(): void
|
||||
{
|
||||
$this->service->shouldNotReceive('createPolicy');
|
||||
|
||||
@@ -27,6 +27,16 @@ class PolicyValueObjectsTest extends TestCase
|
||||
self::assertArrayHasKey('acceptance_scope', $policy->toArray());
|
||||
}
|
||||
|
||||
public function testAnEmptyBodyRendersAsNothingRatherThanAnEmptyParagraph(): void
|
||||
{
|
||||
\Brain\Monkey\Functions\when('wp_kses_post')->returnArg();
|
||||
$this->stubAutop();
|
||||
|
||||
$version = new PolicyVersion(policyId: 4, versionNumber: 1, body: null);
|
||||
|
||||
self::assertSame('', $version->bodyHtml());
|
||||
}
|
||||
|
||||
public function testPolicyHandlesNullCurrentVersion(): void
|
||||
{
|
||||
$policy = Policy::fromRow((object) [
|
||||
|
||||
@@ -23,6 +23,9 @@ class ShortcodeRegistrarTest extends TestCase
|
||||
/** @var array<string, callable> */
|
||||
private array $shortcodes = [];
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
private array $localized = [];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
@@ -84,6 +87,69 @@ class ShortcodeRegistrarTest extends TestCase
|
||||
self::assertSame('group', $this->shortcodes['us_group_classes'](''));
|
||||
}
|
||||
|
||||
/**
|
||||
* The booking and group-class scripts both read prices through the shared
|
||||
* pricing helper, so it must be registered ahead of them (and behind the
|
||||
* payment helper, which carries the localized config it reads).
|
||||
*/
|
||||
public function testPricingHelperIsRegisteredAheadOfTheBookingAndGroupScripts(): void
|
||||
{
|
||||
$scripts = $this->captureEnqueuedAssets();
|
||||
|
||||
self::assertSame(['us-scheduler-payment'], $scripts['us-scheduler-pricing']);
|
||||
self::assertSame(['us-scheduler-pricing'], $scripts['us-scheduler']);
|
||||
self::assertSame(['us-scheduler-pricing'], $scripts['us-scheduler-group']);
|
||||
}
|
||||
|
||||
/**
|
||||
* The studio HST rate reaches the front end so a price quoted on a booking
|
||||
* form matches the total the student is actually billed.
|
||||
*/
|
||||
public function testStudioTaxRateIsLocalizedToTheFrontEnd(): void
|
||||
{
|
||||
$this->captureEnqueuedAssets();
|
||||
|
||||
self::assertSame(13.0, $this->localized['taxRate']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, string>> Registered script handle => dependencies.
|
||||
*/
|
||||
private function captureEnqueuedAssets(): array
|
||||
{
|
||||
$scripts = [];
|
||||
$localized = &$this->localized;
|
||||
|
||||
Functions\when('wp_register_style')->justReturn(true);
|
||||
Functions\when('rest_url')->justReturn('https://example.test/wp-json/us-scheduler/v1/');
|
||||
Functions\when('wp_create_nonce')->justReturn('nonce');
|
||||
Functions\when('get_option')->alias(
|
||||
static fn (string $name, mixed $default = false): mixed => match ($name) {
|
||||
'us_hst_rate' => '13',
|
||||
'start_of_week' => 1,
|
||||
default => $default,
|
||||
}
|
||||
);
|
||||
Functions\when('wp_register_script')->alias(
|
||||
static function (string $handle, string $src, array $deps = []) use (&$scripts): bool {
|
||||
$scripts[$handle] = $deps;
|
||||
|
||||
return true;
|
||||
}
|
||||
);
|
||||
Functions\when('wp_localize_script')->alias(
|
||||
static function (string $handle, string $object, array $data) use (&$localized): bool {
|
||||
$localized = $data;
|
||||
|
||||
return true;
|
||||
}
|
||||
);
|
||||
|
||||
$this->registrar->enqueueAssets();
|
||||
|
||||
return $scripts;
|
||||
}
|
||||
|
||||
public function testShortcodeAttributesArePassedThroughUnchanged(): void
|
||||
{
|
||||
$this->registrar->register();
|
||||
|
||||
@@ -24,4 +24,17 @@ abstract class TestCase extends BaseTestCase
|
||||
Monkey\tearDown();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub `wpautop()` with a minimal blank-line-to-paragraph transform — enough
|
||||
* to assert that unmarked-up text reaches the page as real paragraphs.
|
||||
*/
|
||||
protected function stubAutop(): void
|
||||
{
|
||||
Monkey\Functions\when('wpautop')->alias(static function (string $text): string {
|
||||
$text = trim($text);
|
||||
|
||||
return '' === $text ? '' : '<p>' . implode('</p><p>', (array) preg_split('/\n\s*\n/', $text)) . '</p>';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Plugin Name: Unsupervised Scheduler
|
||||
* Plugin URI: https://git.unsupervised.ca/Unsupervised/unsupervised-scheduler
|
||||
* Description: Instructor/student lesson scheduling for WordPress.
|
||||
* Version: 1.2.2
|
||||
* Version: 1.2.4
|
||||
* Requires at least: 6.2
|
||||
* Requires PHP: 8.1
|
||||
* Author: Unsupervised
|
||||
@@ -21,7 +21,7 @@ if (! defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
define('USC_VERSION', '1.2.2');
|
||||
define('USC_VERSION', '1.2.4');
|
||||
define('USC_PLUGIN_FILE', __FILE__);
|
||||
define('USC_PLUGIN_DIR', plugin_dir_path(__FILE__));
|
||||
define('USC_PLUGIN_URL', plugin_dir_url(__FILE__));
|
||||
|
||||
Reference in New Issue
Block a user