Compare commits
27
Commits
v1.2.0
..
1975630136
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1975630136
|
||
|
|
f36c235060
|
||
|
|
fabbd35fa7 | ||
|
|
3a954bac57
|
||
|
|
907f665876 | ||
|
|
bfdc3b3380
|
||
|
|
a276d53c1b
|
||
|
|
9344ab7193
|
||
|
|
d6a515cc93 | ||
|
|
ae07930d6d | ||
|
|
3a25c397c5 | ||
|
|
264d9cba01
|
||
|
|
689ec833f3 | ||
|
|
0f30f28e92
|
||
|
|
9d11cc3b01
|
||
|
|
add6605141 | ||
|
|
edcacae816
|
||
|
|
17487cde46 | ||
|
|
13d6b3e14e
|
||
|
|
d866aa7295 | ||
|
|
c4acdb7ca4
|
||
|
|
30928addf8 | ||
|
|
27793fa0aa | ||
|
|
c611268bdb | ||
|
|
721c4be1d6
|
||
|
|
3aa65bad06 | ||
|
|
f3ba09b195 |
@@ -11,6 +11,34 @@ 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
|
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.
|
each change under the current top section as you work.
|
||||||
|
|
||||||
|
## [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
|
||||||
|
- The **Lesson Booking** block gained three embedding options in its sidebar. **Lesson type** pins the block to a single private-lesson type — only the times bookable as that type are listed and it is the only thing bookable there, auto-selected on the registration form — so a page about one lesson type can carry its own calendar. **Show the lesson-type filter** turns the **Show Only** control on or off. **Sections** embeds just one half of the page: booking calendar only, or the student's upcoming lessons only, so the two can live on different pages. All three are available to the shortcode as `[us_booking lesson_type="…" show_filter="no" show="booking|upcoming"]`, and the block's editor preview follows the chosen sections.
|
||||||
|
- The booking calendar now has a **Show Only** button beside the List/Week toggle that opens a lesson-type filter, so a student browsing open times can narrow them to the types they actually want. Because not every open time can be booked as every private-lesson type — some times are tied to a specific type, others only take types of a matching length — the filter shows just the times bookable as the ticked types, and re-anchors the week view on the earliest one so it never opens on an empty week. Picking one of those times narrows the **Lesson type** picker on the registration form to the same list, and when only one type is left it is chosen automatically with its questions loaded. The type list starts collapsed and can be tucked away again without losing the filter; the button shows how many types are ticked. Tick nothing (or use **Show all types**) to see every open time as before. The filter is hidden when the studio only offers one private-lesson type.
|
||||||
|
- The **Group Classes** block can now be pinned to a single class, under **Classes shown → Class** in the block sidebar (shortcode: `[us_group_classes offering="…"]`). Pick a class and the block shows only that one, so it can be embedded on a page that describes the class. In this mode the class's own description is left out to avoid repeating the page copy — the card shows the schedule, instructor, price, enrolment deadline and the enrol/withdraw controls. Leaving it on **All classes** keeps the full browsable catalog with descriptions.
|
||||||
|
- The **Student Registration** block can now send students onward to a page of your choosing once they finish registering. Its **After email confirmation** panel is now **After registration**: the page you pick there is where the link shown to a newly registered student points — the "Sign in to your account" link after they confirm their email, and a "Continue to your account" link for an invited student, who is signed in immediately. A new **Redirect automatically** option takes them straight there instead of showing the link. Registration errors are never skipped — a failed sign-up and an expired confirmation link still show their message on the page, as does the "check your email to confirm your address" step. The redirect needs a page to be chosen; with none set, students see the link (or, for invited students, just the confirmation) as before.
|
||||||
|
|
||||||
|
## [1.2.1]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Registration questions, offering titles/notes, and policy names longer than their storage limit are no longer silently discarded. Previously typing a fixed-size field past its maximum length reported success but saved nothing — the database quietly rejected the over-long value. These fields now cap the input in the form, and the API rejects an over-long value with a clear error.
|
||||||
|
- Students can no longer reach the WordPress dashboard. A student who navigates to `wp-admin` is redirected to the site front end and the admin toolbar is hidden for them, so they only ever see the studio's booking pages. Anyone who runs the studio — administrators, studio admins, and instructors — keeps full `wp-admin` access.
|
||||||
|
- The instructor picker on the **Add/Edit Offering** form no longer comes up empty for a solo studio owner. When the person running the studio teaches from a WordPress administrator account (the default single-account setup), they now appear in the instructor dropdown and can be assigned to a class.
|
||||||
|
|
||||||
## [1.2.0]
|
## [1.2.0]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+154
-1
@@ -117,10 +117,67 @@
|
|||||||
color: #8a6d1a;
|
color: #8a6d1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.us-calendar-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.us-filter-toggle {
|
||||||
|
padding: 6px 16px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.us-filter-toggle.us-active {
|
||||||
|
background: #333;
|
||||||
|
border-color: #333;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.us-type-filter {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #eee;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.us-type-filter-heading {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.us-type-filter-choices {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.us-type-filter-choice {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.us-type-filter-clear {
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.us-view-toggle {
|
.us-view-toggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.us-view-toggle button {
|
.us-view-toggle button {
|
||||||
@@ -184,6 +241,102 @@
|
|||||||
opacity: 0.4;
|
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) {
|
@media (max-width: 640px) {
|
||||||
.us-week-grid {
|
.us-week-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
+104
-20
@@ -83,6 +83,47 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dropdown of active private-lesson types fetched from the plugin's public
|
||||||
|
* offerings endpoint. Values are offering IDs; 0 means every type.
|
||||||
|
*/
|
||||||
|
function LessonTypeSelect(props) {
|
||||||
|
const [offerings, setOfferings] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiFetch({ path: '/us-scheduler/v1/offerings?kind=private_lesson' })
|
||||||
|
.then(setOfferings)
|
||||||
|
.catch(() => setOfferings([]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const options = [{ label: __('All lesson types', 'unsupervised-schedular'), value: '0' }].concat(
|
||||||
|
(offerings || []).map((o) => ({
|
||||||
|
label: o.duration_minutes
|
||||||
|
? `${o.title} (${o.duration_minutes} min)`
|
||||||
|
: (o.title || __('(no title)', 'unsupervised-schedular')),
|
||||||
|
value: String(o.id),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
// A previously chosen type that is no longer offered keeps its stored
|
||||||
|
// id visible instead of silently pretending "All lesson types" is set.
|
||||||
|
const value = String(props.value || 0);
|
||||||
|
if (offerings !== null && !options.some((opt) => opt.value === value)) {
|
||||||
|
options.push({
|
||||||
|
label: __('Unavailable lesson type #', 'unsupervised-schedular') + value,
|
||||||
|
value: value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return el(SelectControl, {
|
||||||
|
label: props.label,
|
||||||
|
help: props.help,
|
||||||
|
value: value,
|
||||||
|
options: options,
|
||||||
|
onChange: (newValue) => props.onChange(parseInt(newValue, 10) || 0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const blocks = [
|
const blocks = [
|
||||||
{
|
{
|
||||||
name: 'us-scheduler/booking',
|
name: 'us-scheduler/booking',
|
||||||
@@ -94,24 +135,60 @@
|
|||||||
attributes: {
|
attributes: {
|
||||||
loginPageId: { type: 'number', default: 0 },
|
loginPageId: { type: 'number', default: 0 },
|
||||||
autoRedirect: { type: 'boolean', default: false },
|
autoRedirect: { type: 'boolean', default: false },
|
||||||
|
lessonTypeId: { type: 'number', default: 0 },
|
||||||
|
showTypeFilter: { type: 'boolean', default: true },
|
||||||
|
displayMode: { type: 'string', default: 'both' },
|
||||||
},
|
},
|
||||||
inspector: (attributes, setAttributes) => el(
|
inspector: (attributes, setAttributes) => [
|
||||||
PanelBody,
|
el(
|
||||||
{ title: __('Logged-out visitors', 'unsupervised-schedular') },
|
PanelBody,
|
||||||
el(PageSelect, {
|
{ title: __('What to show', 'unsupervised-schedular'), key: 'display' },
|
||||||
label: __('Login page', 'unsupervised-schedular'),
|
el(SelectControl, {
|
||||||
help: __('Where the log-in link sends visitors who are not logged in.', 'unsupervised-schedular'),
|
label: __('Sections', 'unsupervised-schedular'),
|
||||||
defaultLabel: __('WordPress login screen', 'unsupervised-schedular'),
|
help: __('Split the page in two: a booking calendar here, the student’s upcoming lessons somewhere else.', 'unsupervised-schedular'),
|
||||||
value: attributes.loginPageId,
|
value: attributes.displayMode || 'both',
|
||||||
onChange: (loginPageId) => setAttributes({ loginPageId }),
|
options: [
|
||||||
}),
|
{ label: __('Booking and upcoming lessons', 'unsupervised-schedular'), value: 'both' },
|
||||||
el(ToggleControl, {
|
{ label: __('Booking only', 'unsupervised-schedular'), value: 'booking' },
|
||||||
label: __('Redirect automatically', 'unsupervised-schedular'),
|
{ label: __('Upcoming lessons only', 'unsupervised-schedular'), value: 'upcoming' },
|
||||||
help: __('Send logged-out visitors straight to the login page instead of showing a link.', 'unsupervised-schedular'),
|
],
|
||||||
checked: !!attributes.autoRedirect,
|
onChange: (displayMode) => setAttributes({ displayMode }),
|
||||||
onChange: (autoRedirect) => setAttributes({ autoRedirect }),
|
})
|
||||||
})
|
),
|
||||||
),
|
el(
|
||||||
|
PanelBody,
|
||||||
|
{ title: __('Lesson types', 'unsupervised-schedular'), key: 'lesson-types' },
|
||||||
|
el(LessonTypeSelect, {
|
||||||
|
label: __('Lesson type', 'unsupervised-schedular'),
|
||||||
|
help: __('Show only the times bookable as one lesson type, for embedding on a page dedicated to it. That type is then the only one students can book here.', 'unsupervised-schedular'),
|
||||||
|
value: attributes.lessonTypeId,
|
||||||
|
onChange: (lessonTypeId) => setAttributes({ lessonTypeId }),
|
||||||
|
}),
|
||||||
|
el(ToggleControl, {
|
||||||
|
label: __('Show the lesson-type filter', 'unsupervised-schedular'),
|
||||||
|
help: __('Offer students the “Show Only” button that narrows the calendar to chosen lesson types. Not used when a single lesson type is set above.', 'unsupervised-schedular'),
|
||||||
|
checked: attributes.showTypeFilter !== false,
|
||||||
|
onChange: (showTypeFilter) => setAttributes({ showTypeFilter }),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
el(
|
||||||
|
PanelBody,
|
||||||
|
{ title: __('Logged-out visitors', 'unsupervised-schedular'), key: 'logged-out' },
|
||||||
|
el(PageSelect, {
|
||||||
|
label: __('Login page', 'unsupervised-schedular'),
|
||||||
|
help: __('Where the log-in link sends visitors who are not logged in.', 'unsupervised-schedular'),
|
||||||
|
defaultLabel: __('WordPress login screen', 'unsupervised-schedular'),
|
||||||
|
value: attributes.loginPageId,
|
||||||
|
onChange: (loginPageId) => setAttributes({ loginPageId }),
|
||||||
|
}),
|
||||||
|
el(ToggleControl, {
|
||||||
|
label: __('Redirect automatically', 'unsupervised-schedular'),
|
||||||
|
help: __('Send logged-out visitors straight to the login page instead of showing a link.', 'unsupervised-schedular'),
|
||||||
|
checked: !!attributes.autoRedirect,
|
||||||
|
onChange: (autoRedirect) => setAttributes({ autoRedirect }),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'us-scheduler/student-login',
|
name: 'us-scheduler/student-login',
|
||||||
@@ -151,18 +228,25 @@
|
|||||||
shortcode: 'us_student_register',
|
shortcode: 'us_student_register',
|
||||||
attributes: {
|
attributes: {
|
||||||
loginPageId: { type: 'number', default: 0 },
|
loginPageId: { type: 'number', default: 0 },
|
||||||
|
autoRedirect: { type: 'boolean', default: false },
|
||||||
inviteOnlyMessage: { type: 'string', default: '' },
|
inviteOnlyMessage: { type: 'string', default: '' },
|
||||||
},
|
},
|
||||||
inspector: (attributes, setAttributes) => [
|
inspector: (attributes, setAttributes) => [
|
||||||
el(
|
el(
|
||||||
PanelBody,
|
PanelBody,
|
||||||
{ title: __('After email confirmation', 'unsupervised-schedular'), key: 'confirmation' },
|
{ title: __('After registration', 'unsupervised-schedular'), key: 'confirmation' },
|
||||||
el(PageSelect, {
|
el(PageSelect, {
|
||||||
label: __('Sign-in page', 'unsupervised-schedular'),
|
label: __('Sign-in page', 'unsupervised-schedular'),
|
||||||
help: __('Where the sign-in link shown after a student confirms their email address sends them.', 'unsupervised-schedular'),
|
help: __('Where students are sent once registration finishes — after they confirm their email address, or straight away for an invited student.', 'unsupervised-schedular'),
|
||||||
defaultLabel: __('WordPress login screen', 'unsupervised-schedular'),
|
defaultLabel: __('WordPress login screen', 'unsupervised-schedular'),
|
||||||
value: attributes.loginPageId,
|
value: attributes.loginPageId,
|
||||||
onChange: (loginPageId) => setAttributes({ loginPageId }),
|
onChange: (loginPageId) => setAttributes({ loginPageId }),
|
||||||
|
}),
|
||||||
|
el(ToggleControl, {
|
||||||
|
label: __('Redirect automatically', 'unsupervised-schedular'),
|
||||||
|
help: __('Send students straight to that page instead of showing the link. Requires a page to be chosen; errors and the "check your email" step are never skipped.', 'unsupervised-schedular'),
|
||||||
|
checked: !!attributes.autoRedirect,
|
||||||
|
onChange: (autoRedirect) => setAttributes({ autoRedirect }),
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
el(
|
el(
|
||||||
@@ -192,7 +276,7 @@
|
|||||||
{ title: __('Classes shown', 'unsupervised-schedular') },
|
{ title: __('Classes shown', 'unsupervised-schedular') },
|
||||||
el(GroupClassSelect, {
|
el(GroupClassSelect, {
|
||||||
label: __('Class', 'unsupervised-schedular'),
|
label: __('Class', 'unsupervised-schedular'),
|
||||||
help: __('Show only one group class, for embedding on a page dedicated to it.', 'unsupervised-schedular'),
|
help: __('Show only one group class, for embedding on a page dedicated to it. That class’s description is left out — the card shows just the schedule, price and enrolment controls.', 'unsupervised-schedular'),
|
||||||
value: attributes.offeringId,
|
value: attributes.offeringId,
|
||||||
onChange: (offeringId) => setAttributes({ offeringId }),
|
onChange: (offeringId) => setAttributes({ offeringId }),
|
||||||
})
|
})
|
||||||
|
|||||||
+264
-50
@@ -11,6 +11,11 @@
|
|||||||
const errorBox = document.getElementById('us-booking-error');
|
const errorBox = document.getElementById('us-booking-error');
|
||||||
const { restUrl, nonce } = usScheduler;
|
const { restUrl, nonce } = usScheduler;
|
||||||
|
|
||||||
|
// Per-instance options from the block/shortcode: pin the page to a single
|
||||||
|
// lesson type, and whether the "Show Only" filter is offered at all.
|
||||||
|
const pinnedTypeId = Number(app.dataset.lessonType) || 0;
|
||||||
|
const filterEnabled = app.dataset.typeFilter !== '0';
|
||||||
|
|
||||||
function apiFetch(path, options = {}) {
|
function apiFetch(path, options = {}) {
|
||||||
return fetch(restUrl + path, {
|
return fetch(restUrl + path, {
|
||||||
...options,
|
...options,
|
||||||
@@ -81,6 +86,45 @@
|
|||||||
let view = 'week';
|
let view = 'week';
|
||||||
let weekStart = null;
|
let weekStart = null;
|
||||||
|
|
||||||
|
// Every active private-lesson type the student may book, across instructors.
|
||||||
|
let catalog = [];
|
||||||
|
|
||||||
|
// Lesson types the student has filtered the calendar down to; empty means
|
||||||
|
// "no filter" — every open slot is shown. The list starts collapsed behind
|
||||||
|
// the "Show Only" button and stays open across re-renders once revealed.
|
||||||
|
const selectedTypeIds = new Set();
|
||||||
|
let filterOpen = false;
|
||||||
|
|
||||||
|
// Whether an offering can be booked into a slot — the client-side mirror of
|
||||||
|
// the rule `POST /bookings` enforces: a slot tied to an offering takes that
|
||||||
|
// offering only, and a generic slot takes any of its instructor's types
|
||||||
|
// whose length fits.
|
||||||
|
function offeringFitsSlot(offering, slot) {
|
||||||
|
if (Number(offering.instructor_id) !== Number(slot.instructor_id)) return false;
|
||||||
|
|
||||||
|
const tiedId = Number(slot.offering_id) || 0;
|
||||||
|
if (tiedId) return Number(offering.id) === tiedId;
|
||||||
|
|
||||||
|
return !offering.duration_minutes
|
||||||
|
|| Number(offering.duration_minutes) === Number(slot.duration_minutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterActive = () => selectedTypeIds.size > 0;
|
||||||
|
|
||||||
|
const typeSelected = (offering) => !filterActive() || selectedTypeIds.has(Number(offering.id));
|
||||||
|
|
||||||
|
// The lesson types this slot could be booked as, honouring the filter.
|
||||||
|
function slotChoices(slot) {
|
||||||
|
return catalog.filter((o) => offeringFitsSlot(o, slot) && typeSelected(o));
|
||||||
|
}
|
||||||
|
|
||||||
|
// With a filter set, a slot is only shown when one of the chosen lesson
|
||||||
|
// types can actually be booked into it.
|
||||||
|
function visibleSlots() {
|
||||||
|
if (!filterActive()) return allSlots;
|
||||||
|
return allSlots.filter((slot) => slotChoices(slot).length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
const pad = (n) => String(n).padStart(2, '0');
|
const pad = (n) => String(n).padStart(2, '0');
|
||||||
const toKey = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
const toKey = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||||
|
|
||||||
@@ -98,17 +142,67 @@
|
|||||||
return addDays(key, -((date.getDay() - startOfWeek + 7) % 7));
|
return addDays(key, -((date.getDay() - startOfWeek + 7) % 7));
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleHtml() {
|
// The calendar's control row: the view toggle, and the button that reveals
|
||||||
|
// the lesson-type filter beneath it.
|
||||||
|
function controlsHtml() {
|
||||||
return `
|
return `
|
||||||
<div class="us-view-toggle" role="group" aria-label="Calendar view">
|
<div class="us-calendar-controls">
|
||||||
<button type="button" id="us-view-list" class="${view === 'list' ? 'us-active' : ''}">List</button>
|
<div class="us-view-toggle" role="group" aria-label="Calendar view">
|
||||||
<button type="button" id="us-view-week" class="${view === 'week' ? 'us-active' : ''}">Week</button>
|
<button type="button" id="us-view-list" class="${view === 'list' ? 'us-active' : ''}">List</button>
|
||||||
|
<button type="button" id="us-view-week" class="${view === 'week' ? 'us-active' : ''}">Week</button>
|
||||||
|
</div>
|
||||||
|
${filterToggleHtml()}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing to filter with a single bookable type, so the control only
|
||||||
|
// appears once there is a choice to make.
|
||||||
|
function filterToggleHtml() {
|
||||||
|
if (!filterEnabled || catalog.length < 2) return '';
|
||||||
|
|
||||||
|
const count = filterActive() ? ` (${selectedTypeIds.size})` : '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<button type="button" id="us-filter-toggle" class="us-filter-toggle${filterActive() ? ' us-active' : ''}"
|
||||||
|
aria-expanded="${filterOpen}" aria-controls="us-type-filter">Show Only${count}</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Piano Lesson (30 min)" — the instructor's name is only worth the space
|
||||||
|
// when the catalog spans more than one of them.
|
||||||
|
function filterLabel(offering) {
|
||||||
|
const duration = offering.duration_minutes ? ` (${offering.duration_minutes} min)` : '';
|
||||||
|
const instructors = new Set(catalog.map((o) => Number(o.instructor_id)));
|
||||||
|
const who = instructors.size > 1 && offering.instructor_name
|
||||||
|
? ` — ${offering.instructor_name}`
|
||||||
|
: '';
|
||||||
|
return `${offering.title}${duration}${who}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The lesson-type list itself — collapsed until the student opens it, and
|
||||||
|
// rendered between the control row and the calendar.
|
||||||
|
function filterHtml() {
|
||||||
|
if (!filterEnabled || catalog.length < 2 || !filterOpen) return '';
|
||||||
|
|
||||||
|
const choices = catalog.map((o) => `
|
||||||
|
<label class="us-type-filter-choice">
|
||||||
|
<input type="checkbox" class="us-type-filter-option" value="${o.id}" ${selectedTypeIds.has(Number(o.id)) ? 'checked' : ''}>
|
||||||
|
${escHtml(filterLabel(o))}
|
||||||
|
</label>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="us-type-filter" id="us-type-filter" role="group" aria-label="Filter by lesson type">
|
||||||
|
<span class="us-type-filter-heading">Lesson type</span>
|
||||||
|
<div class="us-type-filter-choices">
|
||||||
|
${choices}
|
||||||
|
${filterActive() ? '<button type="button" id="us-type-filter-clear" class="us-type-filter-clear">Show all types</button>' : ''}
|
||||||
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Agenda-style calendar: available slots grouped by day.
|
// Agenda-style calendar: available slots grouped by day.
|
||||||
function listHtml() {
|
function listHtml(slots) {
|
||||||
return groupByDay(allSlots).map(([key, daySlots]) => `
|
return groupByDay(slots).map(([key, daySlots]) => `
|
||||||
<div class="us-day">
|
<div class="us-day">
|
||||||
<h3 class="us-day-heading">${escHtml(dayLabel(key))}</h3>
|
<h3 class="us-day-heading">${escHtml(dayLabel(key))}</h3>
|
||||||
${daySlots.map((slot) => `
|
${daySlots.map((slot) => `
|
||||||
@@ -122,8 +216,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Weekly calendar: seven day columns with a bookable button per slot.
|
// Weekly calendar: seven day columns with a bookable button per slot.
|
||||||
function weekHtml() {
|
function weekHtml(slots) {
|
||||||
const byDay = new Map(groupByDay(allSlots));
|
const byDay = new Map(groupByDay(slots));
|
||||||
const days = [...Array(7).keys()].map((i) => addDays(weekStart, i));
|
const days = [...Array(7).keys()].map((i) => addDays(weekStart, i));
|
||||||
|
|
||||||
const columns = days.map((key) => {
|
const columns = days.map((key) => {
|
||||||
@@ -151,20 +245,41 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
|
const slots = visibleSlots();
|
||||||
|
|
||||||
|
// The pinned lesson type is no longer on offer (deactivated or
|
||||||
|
// deleted), so this page has nothing it is allowed to book.
|
||||||
|
if (pinnedTypeId && !catalog.length) {
|
||||||
|
slotList.innerHTML = '<p>This lesson type is not available for booking right now.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing open at all: there is nothing for the controls to act on.
|
||||||
if (!allSlots.length) {
|
if (!allSlots.length) {
|
||||||
slotList.innerHTML = '<p>No available lesson slots at this time.</p>';
|
slotList.innerHTML = '<p>No available lesson slots at this time.</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Anchor the week view to the week of the earliest open slot (the API
|
if (!slots.length) {
|
||||||
// returns slots ordered by start), so the first look is never empty.
|
const message = pinnedTypeId
|
||||||
if (view === 'week' && !weekStart) weekStart = weekStartOf(dayKey(allSlots[0].start_dt));
|
? '<p>No open times for this lesson type right now.</p>'
|
||||||
|
: '<p>No open times match the selected lesson types.</p>';
|
||||||
|
|
||||||
slotList.innerHTML = toggleHtml() + (view === 'week' ? weekHtml() : listHtml());
|
slotList.innerHTML = controlsHtml() + filterHtml() + message;
|
||||||
|
wireControlEvents();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anchor the week view to the week of the earliest matching slot (the
|
||||||
|
// API returns slots ordered by start), so the first look is never empty.
|
||||||
|
if (view === 'week' && !weekStart) weekStart = weekStartOf(dayKey(slots[0].start_dt));
|
||||||
|
|
||||||
|
slotList.innerHTML = controlsHtml() + filterHtml() + (view === 'week' ? weekHtml(slots) : listHtml(slots));
|
||||||
|
wireControlEvents();
|
||||||
wireCalendarEvents();
|
wireCalendarEvents();
|
||||||
}
|
}
|
||||||
|
|
||||||
function wireCalendarEvents() {
|
function wireControlEvents() {
|
||||||
document.getElementById('us-view-list').addEventListener('click', () => {
|
document.getElementById('us-view-list').addEventListener('click', () => {
|
||||||
view = 'list';
|
view = 'list';
|
||||||
render();
|
render();
|
||||||
@@ -174,6 +289,40 @@
|
|||||||
render();
|
render();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const toggle = document.getElementById('us-filter-toggle');
|
||||||
|
if (toggle) {
|
||||||
|
toggle.addEventListener('click', () => {
|
||||||
|
filterOpen = !filterOpen;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
slotList.querySelectorAll('.us-type-filter-option').forEach((input) => {
|
||||||
|
input.addEventListener('change', () => {
|
||||||
|
const id = Number(input.value);
|
||||||
|
if (input.checked) {
|
||||||
|
selectedTypeIds.add(id);
|
||||||
|
} else {
|
||||||
|
selectedTypeIds.delete(id);
|
||||||
|
}
|
||||||
|
// The nearest matching time may be weeks away, so re-anchor the
|
||||||
|
// week view instead of leaving the student on an empty week.
|
||||||
|
weekStart = null;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const clear = document.getElementById('us-type-filter-clear');
|
||||||
|
if (clear) {
|
||||||
|
clear.addEventListener('click', () => {
|
||||||
|
selectedTypeIds.clear();
|
||||||
|
weekStart = null;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireCalendarEvents() {
|
||||||
const prev = document.getElementById('us-week-prev');
|
const prev = document.getElementById('us-week-prev');
|
||||||
const next = document.getElementById('us-week-next');
|
const next = document.getElementById('us-week-next');
|
||||||
if (prev) prev.addEventListener('click', () => { weekStart = addDays(weekStart, -7); render(); });
|
if (prev) prev.addEventListener('click', () => { weekStart = addDays(weekStart, -7); render(); });
|
||||||
@@ -211,39 +360,34 @@
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Active private-lesson offerings per instructor, so revisiting the
|
// "Piano Lesson (60 min — 50.00 CAD at booking)" / "Trial Lesson (Free)"
|
||||||
// registration form does not refetch the same catalog.
|
|
||||||
const offeringCache = new Map();
|
|
||||||
|
|
||||||
function instructorOfferings(instructorId) {
|
|
||||||
if (offeringCache.has(instructorId)) {
|
|
||||||
return Promise.resolve(offeringCache.get(instructorId));
|
|
||||||
}
|
|
||||||
return apiFetch(`offerings?instructor_id=${instructorId}&kind=private_lesson`).then((list) => {
|
|
||||||
offeringCache.set(instructorId, list);
|
|
||||||
return list;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// "Piano Lesson (60 min — $50.00 CAD)" / "Trial Lesson (Free)"
|
|
||||||
function offeringLabel(o) {
|
function offeringLabel(o) {
|
||||||
const duration = o.duration_minutes ? `${o.duration_minutes} min — ` : '';
|
const duration = o.duration_minutes ? `${o.duration_minutes} min — ` : '';
|
||||||
const price = Number(o.price) > 0
|
return `${o.title} (${duration}${window.usPricing.priceLabel(o)})`;
|
||||||
? `$${Number(o.price).toFixed(2)} ${o.currency}`
|
}
|
||||||
: 'Free';
|
|
||||||
return `${o.title} (${duration}${price})`;
|
// 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) {
|
function openRegistration(slot) {
|
||||||
clearError();
|
clearError();
|
||||||
|
|
||||||
Promise.all([
|
apiFetch('policies?scope=booking')
|
||||||
instructorOfferings(Number(slot.instructor_id)),
|
.then((policies) => renderRegistration(slot, policies))
|
||||||
apiFetch('policies?scope=booking'),
|
|
||||||
])
|
|
||||||
.then(([offerings, policies]) => {
|
|
||||||
renderRegistration(slot, offerings, policies);
|
|
||||||
})
|
|
||||||
.catch((err) => showError(err.message));
|
.catch((err) => showError(err.message));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,6 +402,19 @@
|
|||||||
<select id="us-offering" disabled><option>${escHtml(label)}</option></select></label>
|
<select id="us-offering" disabled><option>${escHtml(label)}</option></select></label>
|
||||||
</p>`;
|
</p>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only one type is left to book this slot as — usually because the
|
||||||
|
// filter narrowed it down — so it is chosen for the student.
|
||||||
|
if (choices.length === 1) {
|
||||||
|
return `
|
||||||
|
<p class="us-offering">
|
||||||
|
<label>Lesson type<br>
|
||||||
|
<select id="us-offering" required>
|
||||||
|
<option value="${choices[0].id}" selected>${escHtml(offeringLabel(choices[0]))}</option>
|
||||||
|
</select></label>
|
||||||
|
</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<p class="us-offering">
|
<p class="us-offering">
|
||||||
<label>Lesson type<br>
|
<label>Lesson type<br>
|
||||||
@@ -268,14 +425,13 @@
|
|||||||
</p>`;
|
</p>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderRegistration(slot, offerings, policies) {
|
function renderRegistration(slot, policies) {
|
||||||
const tiedId = Number(slot.offering_id) || 0;
|
const tiedId = Number(slot.offering_id) || 0;
|
||||||
const tied = tiedId ? offerings.find((o) => Number(o.id) === tiedId) : null;
|
const tied = tiedId ? catalog.find((o) => Number(o.id) === tiedId) : null;
|
||||||
|
|
||||||
// Generic slots offer every lesson type that fits the slot's length.
|
// Generic slots offer every lesson type that fits the slot — narrowed to
|
||||||
const choices = tiedId
|
// the filtered types when the student has set a filter.
|
||||||
? []
|
const choices = tiedId ? [] : slotChoices(slot);
|
||||||
: offerings.filter((o) => !o.duration_minutes || Number(o.duration_minutes) === Number(slot.duration_minutes));
|
|
||||||
|
|
||||||
if (!tiedId && !choices.length) {
|
if (!tiedId && !choices.length) {
|
||||||
// The server rejects offering-less bookings, so without a matching
|
// The server rejects offering-less bookings, so without a matching
|
||||||
@@ -301,6 +457,7 @@
|
|||||||
<div id="us-questions"></div>
|
<div id="us-questions"></div>
|
||||||
${policies.map(policyField).join('')}
|
${policies.map(policyField).join('')}
|
||||||
${weekly}
|
${weekly}
|
||||||
|
<div id="us-price-summary"></div>
|
||||||
<p>
|
<p>
|
||||||
<button type="submit" class="us-book-btn">Confirm Booking</button>
|
<button type="submit" class="us-book-btn">Confirm Booking</button>
|
||||||
<button type="button" id="us-cancel" class="us-cancel-btn">Back</button>
|
<button type="button" id="us-cancel" class="us-cancel-btn">Back</button>
|
||||||
@@ -309,11 +466,34 @@
|
|||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
// The intake questions belong to the selected offering, so they follow
|
// The intake questions belong to the selected offering, so they follow
|
||||||
// the picker instead of being fixed at render time.
|
// the picker instead of being fixed at render time. A tied slot — or a
|
||||||
let selectedId = tiedId;
|
// lone remaining type — is already decided, so its questions load
|
||||||
|
// straight away.
|
||||||
|
let selectedId = tiedId || (choices.length === 1 ? Number(choices[0].id) : 0);
|
||||||
let questions = [];
|
let questions = [];
|
||||||
|
|
||||||
const questionsBox = document.getElementById('us-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() {
|
function loadQuestions() {
|
||||||
questions = [];
|
questions = [];
|
||||||
@@ -331,10 +511,14 @@
|
|||||||
document.getElementById('us-offering').addEventListener('change', (e) => {
|
document.getElementById('us-offering').addEventListener('change', (e) => {
|
||||||
selectedId = Number(e.target.value) || 0;
|
selectedId = Number(e.target.value) || 0;
|
||||||
loadQuestions();
|
loadQuestions();
|
||||||
|
renderPrice();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (weeklyEl) weeklyEl.addEventListener('change', renderPrice);
|
||||||
|
|
||||||
loadQuestions();
|
loadQuestions();
|
||||||
|
renderPrice();
|
||||||
|
|
||||||
document.getElementById('us-cancel').addEventListener('click', loadSlots);
|
document.getElementById('us-cancel').addEventListener('click', loadSlots);
|
||||||
document.getElementById('us-register-form').addEventListener('submit', (e) => {
|
document.getElementById('us-register-form').addEventListener('submit', (e) => {
|
||||||
@@ -343,6 +527,10 @@
|
|||||||
showError('Please choose a lesson type.');
|
showError('Please choose a lesson type.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!window.usPricing.agreed(e.target)) {
|
||||||
|
showError(window.usPricing.AGREE_REQUIRED);
|
||||||
|
return;
|
||||||
|
}
|
||||||
submitBooking(e.target, slot, selectedId, questions);
|
submitBooking(e.target, slot, selectedId, questions);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -467,13 +655,39 @@
|
|||||||
confirm.style.display = 'block';
|
confirm.style.display = 'block';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The private-lesson catalog drives both the filter and the registration
|
||||||
|
// form's lesson-type picker, and it does not change while the student
|
||||||
|
// browses — so it is fetched once and kept.
|
||||||
|
let catalogLoaded = false;
|
||||||
|
|
||||||
|
function loadCatalog() {
|
||||||
|
if (catalogLoaded) return Promise.resolve(catalog);
|
||||||
|
return apiFetch('offerings?kind=private_lesson').then((list) => {
|
||||||
|
// A pinned lesson type is the only one this page may book, so the
|
||||||
|
// catalog is narrowed to it and the filter is fixed on it. With a
|
||||||
|
// single type left the "Show Only" control hides itself.
|
||||||
|
catalog = pinnedTypeId
|
||||||
|
? list.filter((o) => Number(o.id) === pinnedTypeId)
|
||||||
|
: list;
|
||||||
|
|
||||||
|
if (pinnedTypeId) selectedTypeIds.add(pinnedTypeId);
|
||||||
|
|
||||||
|
catalogLoaded = true;
|
||||||
|
return catalog;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function loadSlots() {
|
function loadSlots() {
|
||||||
clearError();
|
clearError();
|
||||||
|
loadMyLessons();
|
||||||
|
|
||||||
|
// An upcoming-lessons-only embed has no calendar to fill.
|
||||||
|
if (!slotList) return;
|
||||||
|
|
||||||
slotList.style.display = 'block';
|
slotList.style.display = 'block';
|
||||||
confirm.style.display = 'none';
|
confirm.style.display = 'none';
|
||||||
loadMyLessons();
|
Promise.all([apiFetch('availability'), loadCatalog()])
|
||||||
apiFetch('availability')
|
.then(([slots]) => {
|
||||||
.then((slots) => {
|
|
||||||
allSlots = slots;
|
allSlots = slots;
|
||||||
render();
|
render();
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,6 +12,9 @@
|
|||||||
|
|
||||||
// When the shortcode/block pins a single offering, only that class is
|
// When the shortcode/block pins a single offering, only that class is
|
||||||
// shown, so the page can be embedded alongside a full class description.
|
// shown, so the page can be embedded alongside a full class description.
|
||||||
|
// The class's own description is then omitted from the card — the page it
|
||||||
|
// sits on already describes the class — leaving the schedule, price and
|
||||||
|
// enrolment controls.
|
||||||
const singleOfferingId = Number(app.dataset.offering || 0);
|
const singleOfferingId = Number(app.dataset.offering || 0);
|
||||||
|
|
||||||
function apiFetch(path, options = {}) {
|
function apiFetch(path, options = {}) {
|
||||||
@@ -148,8 +151,8 @@
|
|||||||
${whenLabel(o) ? `<p class="us-class-when">${escHtml(whenLabel(o))}</p>` : ''}
|
${whenLabel(o) ? `<p class="us-class-when">${escHtml(whenLabel(o))}</p>` : ''}
|
||||||
${o.instructor_name ? `<p class="us-class-instructor">With ${escHtml(o.instructor_name)}</p>` : ''}
|
${o.instructor_name ? `<p class="us-class-instructor">With ${escHtml(o.instructor_name)}</p>` : ''}
|
||||||
${o.schedule_note ? `<p>${escHtml(o.schedule_note)}</p>` : ''}
|
${o.schedule_note ? `<p>${escHtml(o.schedule_note)}</p>` : ''}
|
||||||
${o.description ? `<p>${escHtml(o.description)}</p>` : ''}
|
${!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)
|
${!enrolledMap.has(Number(o.id)) && isEnrollmentOpen(o) && enrolmentDeadline(o)
|
||||||
? `<p class="us-enrol-deadline">Enrol by ${escHtml(formatDate(enrolmentDeadline(o)))}</p>`
|
? `<p class="us-enrol-deadline">Enrol by ${escHtml(formatDate(enrolmentDeadline(o)))}</p>`
|
||||||
: ''}
|
: ''}
|
||||||
@@ -201,6 +204,7 @@
|
|||||||
<form id="us-enrol-form">
|
<form id="us-enrol-form">
|
||||||
${questions.map(questionField).join('')}
|
${questions.map(questionField).join('')}
|
||||||
${policies.map(policyField).join('')}
|
${policies.map(policyField).join('')}
|
||||||
|
${window.usPricing.summaryHtml(offering)}
|
||||||
<p>
|
<p>
|
||||||
<button type="submit" class="us-enrol-btn">Confirm Enrolment</button>
|
<button type="submit" class="us-enrol-btn">Confirm Enrolment</button>
|
||||||
<button type="button" id="us-group-cancel" class="us-cancel-btn">Back</button>
|
<button type="button" id="us-group-cancel" class="us-cancel-btn">Back</button>
|
||||||
@@ -211,6 +215,10 @@
|
|||||||
document.getElementById('us-group-cancel').addEventListener('click', loadClasses);
|
document.getElementById('us-group-cancel').addEventListener('click', loadClasses);
|
||||||
document.getElementById('us-enrol-form').addEventListener('submit', (e) => {
|
document.getElementById('us-enrol-form').addEventListener('submit', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (!window.usPricing.agreed(e.target)) {
|
||||||
|
showError(window.usPricing.AGREE_REQUIRED);
|
||||||
|
return;
|
||||||
|
}
|
||||||
submitEnrolment(e.target, offering, questions);
|
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.',
|
||||||
|
};
|
||||||
|
}());
|
||||||
@@ -128,6 +128,13 @@ recorded in `us_policy_acceptances` with `registration_type = account` and
|
|||||||
- `[us_student_register]` — the registration page. In `invite` mode: shows the form for a valid pending invite, else an "by invitation only" message. In `self_approval` mode: shows the form to anyone (editable email), and renders confirmation-result notices from `?us_confirmed=1|expired`.
|
- `[us_student_register]` — the registration page. In `invite` mode: shows the form for a valid pending invite, else an "by invitation only" message. In `self_approval` mode: shows the form to anyone (editable email), and renders confirmation-result notices from `?us_confirmed=1|expired`.
|
||||||
- The invitation-only message is customisable: block attribute `inviteOnlyMessage` (set under the block's **Invitation-only notice** panel) / shortcode attribute `invite_only_message`. Blank falls back to the default wording (`RegistrationPage::inviteOnlyMessage()`).
|
- The invitation-only message is customisable: block attribute `inviteOnlyMessage` (set under the block's **Invitation-only notice** panel) / shortcode attribute `invite_only_message`. Blank falls back to the default wording (`RegistrationPage::inviteOnlyMessage()`).
|
||||||
|
|
||||||
|
## Where Students Go Next
|
||||||
|
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 _<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
|
## Token Redirect
|
||||||
A `template_redirect` handler (`RegistrationPage::maybeRedirectToRegistrationPage()`)
|
A `template_redirect` handler (`RegistrationPage::maybeRedirectToRegistrationPage()`)
|
||||||
sends any front-end request carrying a `us_invite` token to the configured
|
sends any front-end request carrying a `us_invite` token to the configured
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ 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
|
either as an agenda-style list grouped by day or as a **weekly calendar** with
|
||||||
previous/next-week navigation (toggle rendered by `assets/js/booking.js`; the
|
previous/next-week navigation (toggle rendered by `assets/js/booking.js`; the
|
||||||
site's `start_of_week` option is passed through the `usScheduler` JS config).
|
site's `start_of_week` option is passed through the `usScheduler` JS config).
|
||||||
|
Both views can be narrowed to the slots bookable as chosen private-lesson types
|
||||||
|
with the **Show Only** lesson-type filter — see `lesson-booking.md`.
|
||||||
|
|
||||||
## REST API
|
## REST API
|
||||||
| Method | Endpoint | Permission |
|
| Method | Endpoint | Permission |
|
||||||
|
|||||||
@@ -27,17 +27,29 @@ Four blocks have sidebar (inspector) options:
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `us-scheduler/booking` | `loginPageId` (number) | `0` | Page the "log in to book a lesson" link points to for logged-out visitors. `0` = the WordPress login screen (with a redirect back to the current page). |
|
| `us-scheduler/booking` | `loginPageId` (number) | `0` | Page the "log in to book a lesson" link points to for logged-out visitors. `0` = the WordPress login screen (with a redirect back to the current page). |
|
||||||
| `us-scheduler/booking` | `autoRedirect` (boolean) | `false` | Send logged-out visitors straight to the login page instead of showing the link. |
|
| `us-scheduler/booking` | `autoRedirect` (boolean) | `false` | Send logged-out visitors straight to the login page instead of showing the link. |
|
||||||
|
| `us-scheduler/booking` | `lessonTypeId` (number) | `0` | Pin the calendar to a single private-lesson type: only the times bookable as that type are listed, and it is the only type students can book here (auto-selected on the registration form). `0` = every type. Shortcode equivalent: `[us_booking lesson_type="…"]`. |
|
||||||
|
| `us-scheduler/booking` | `showTypeFilter` (boolean) | `true` | Whether students get the **Show Only** button that narrows the calendar to chosen lesson types. Unused when a single type is pinned (there is nothing to choose). Shortcode equivalent: `[us_booking show_filter="no"]`. |
|
||||||
|
| `us-scheduler/booking` | `displayMode` (string) | `both` | Which halves of the page to embed: `both`, `booking` (calendar only, no upcoming-lessons panel) or `upcoming` (the student's lessons only, nothing bookable) — so the two halves can live on different pages. Anything unrecognised falls back to `both`. Shortcode equivalent: `[us_booking show="booking"]`. |
|
||||||
| `us-scheduler/student-login` | `bookingPageId` (number) | `0` | Page the "View available lessons" link points to for logged-in visitors, and the post-login redirect target. `0` = the current page. |
|
| `us-scheduler/student-login` | `bookingPageId` (number) | `0` | Page the "View available lessons" link points to for logged-in visitors, and the post-login redirect target. `0` = the current page. |
|
||||||
| `us-scheduler/student-login` | `autoRedirect` (boolean) | `false` | Send logged-in visitors straight to the booking page instead of showing the link. Does nothing until a booking page is chosen. |
|
| `us-scheduler/student-login` | `autoRedirect` (boolean) | `false` | Send logged-in visitors straight to the booking page instead of showing the link. Does nothing until a booking page is chosen. |
|
||||||
| `us-scheduler/student-register` | `loginPageId` (number) | `0` | Page the "Sign in to your account" link points to after a student confirms their email. `0` = the WordPress login screen. Shortcode equivalent: `[us_student_register login_page_id="…"]`. |
|
| `us-scheduler/student-register` | `loginPageId` (number) | `0` | Page students continue to once registration finishes — the "Sign in to your account" link after they confirm their email, and the "Continue to your account" link an invited student gets on the spot. `0` = the WordPress login screen for the confirmation link, and no link at all for the (already signed-in) invited student. Shortcode equivalent: `[us_student_register login_page_id="…"]`. |
|
||||||
| `us-scheduler/group-classes` | `offeringId` (number) | `0` | Restrict the page to a single group class, for embedding on a page dedicated to that class. `0` = browse all classes. Shortcode equivalent: `[us_group_classes offering="…"]`. |
|
| `us-scheduler/student-register` | `autoRedirect` (boolean) | `false` | Send students straight to that page instead of showing the link. Does nothing until a page is chosen — there is no login-screen fallback here. |
|
||||||
|
| `us-scheduler/group-classes` | `offeringId` (number) | `0` | Restrict the page to a single group class, for embedding on a page dedicated to that class. The class description is then omitted — only the schedule, instructor, price and enrolment controls are shown, so the surrounding page's own copy is not repeated. `0` = browse all classes, descriptions included. Shortcode equivalent: `[us_group_classes offering="…"]`. |
|
||||||
|
|
||||||
The page selects list all published pages; if a chosen page is later deleted,
|
The page selects list all published pages; if a chosen page is later deleted,
|
||||||
the blocks fall back to their defaults. The group-classes block's class
|
the blocks fall back to their defaults. The group-classes block's class
|
||||||
select is a dropdown of active group classes fetched from
|
select is a dropdown of active group classes fetched from
|
||||||
`GET /us-scheduler/v1/offerings?kind=group_class`; a stored class that is no
|
`GET /us-scheduler/v1/offerings?kind=group_class`; a stored class that is no
|
||||||
longer offered shows as "Unavailable class #N" rather than silently falling
|
longer offered shows as "Unavailable class #N" rather than silently falling
|
||||||
back to all classes. The link targets are also available
|
back to all classes. The booking block's lesson-type select works the same way
|
||||||
|
against `?kind=private_lesson` ("Unavailable lesson type #N"), and the live
|
||||||
|
page says so plainly when the pinned type has been withdrawn.
|
||||||
|
|
||||||
|
The booking block's options reach the front end as data attributes on
|
||||||
|
`#us-booking-app` (`data-lesson-type`, `data-type-filter`) or as omitted
|
||||||
|
containers (`displayMode`), which `assets/js/booking.js` reads on load — see
|
||||||
|
`lesson-booking.md`. Its editor preview follows `displayMode`, showing the
|
||||||
|
calendar, the upcoming-lessons panel, or both. The link targets are also available
|
||||||
to the shortcodes as `[us_booking login_page_id="…"]` and
|
to the shortcodes as `[us_booking login_page_id="…"]` and
|
||||||
`[us_student_login booking_page_id="…"]`; auto-redirect is block-only.
|
`[us_student_login booking_page_id="…"]`; auto-redirect is block-only.
|
||||||
|
|
||||||
@@ -48,6 +60,15 @@ queried singular post's content for the block (including inside nested
|
|||||||
blocks), and redirects when the block opts in. A block whose target is its
|
blocks), and redirects when the block opts in. A block whose target is its
|
||||||
own page is ignored to avoid a redirect loop.
|
own page is ignored to avoid a redirect loop.
|
||||||
|
|
||||||
|
The registration block's auto-redirect additionally only fires on a
|
||||||
|
**finished** registration — `RegistrationPage::isRegistrationComplete()`: an
|
||||||
|
invited student who is now logged in (`?us_registered=invite`), or a
|
||||||
|
self-signup back from the emailed confirmation link (`?us_confirmed=ready|1`).
|
||||||
|
The intermediate "check your email" step and every failure (a validation
|
||||||
|
error, `?us_confirmed=expired`) stay on the page so the student reads the
|
||||||
|
message. That check runs before the content is parsed, so an ordinary page
|
||||||
|
view does not pay for the extra block scan.
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
- **`BlockRegistrar`** (`src/BlockRegistrar.php`) hooks `init` and registers
|
- **`BlockRegistrar`** (`src/BlockRegistrar.php`) hooks `init` and registers
|
||||||
@@ -78,7 +99,9 @@ placeholder content:
|
|||||||
- **Booking** — `#us-booking-app` with sample `.us-day` / `.us-slot` rows and
|
- **Booking** — `#us-booking-app` with sample `.us-day` / `.us-slot` rows and
|
||||||
disabled Book buttons.
|
disabled Book buttons.
|
||||||
- **Group classes** — `#us-group-app` with a sample `.us-class` card and a
|
- **Group classes** — `#us-group-app` with a sample `.us-class` card and a
|
||||||
disabled Enrol button.
|
disabled Enrol button. When `offeringId` pins a single class the preview
|
||||||
|
drops the sample description, matching what the live page renders in that
|
||||||
|
mode.
|
||||||
- **Login** — the real `templates/frontend/login-page.php` template (it has
|
- **Login** — the real `templates/frontend/login-page.php` template (it has
|
||||||
no request-state dependencies).
|
no request-state dependencies).
|
||||||
- **Registration** — a disabled sample of the `.us-register-form` fields.
|
- **Registration** — a disabled sample of the `.us-register-form` fields.
|
||||||
|
|||||||
@@ -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
|
server would reject the duplicate with `409 already_enrolled` regardless — a
|
||||||
cancelled enrolment does not block re-enrolling).
|
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`).
|
2. Student answers the offering's questions (`GET /offerings/{id}/questions`).
|
||||||
3. Student accepts the current published policy versions (`GET /policies`) — required to continue.
|
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`.
|
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. `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.
|
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. On successful payment (or comp) a receipt is emailed.
|
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;
|
Capacity is enforced at enrolment time by counting `active` rows for the offering;
|
||||||
a class at capacity rejects further enrolments.
|
a class at capacity rejects further enrolments.
|
||||||
@@ -176,7 +177,7 @@ class becomes enrollable for them — they choose whether to enrol.
|
|||||||
- Model: `Unsupervised\Schedular\GroupClass\Enrollment`
|
- Model: `Unsupervised\Schedular\GroupClass\Enrollment`
|
||||||
- Admin controller: `Unsupervised\Schedular\GroupClass\GroupClassController` — `renderPage` (studio admin per-class summary, `view_all_lessons`) and `renderInstructorPage` (instructor summary + `?class_id` roster detail, `view_own_lessons`)
|
- Admin controller: `Unsupervised\Schedular\GroupClass\GroupClassController` — `renderPage` (studio admin per-class summary, `view_all_lessons`) and `renderInstructorPage` (instructor summary + `?class_id` roster detail, `view_own_lessons`)
|
||||||
- REST endpoint: `Unsupervised\Schedular\GroupClass\EnrollmentEndpoint`
|
- REST endpoint: `Unsupervised\Schedular\GroupClass\EnrollmentEndpoint`
|
||||||
- Frontend: `Unsupervised\Schedular\GroupClass\GroupClassPage` (`[us_group_classes]` shortcode; `offering="…"` restricts it to a single class for embedding on a dedicated page — the block equivalent is the `offeringId` attribute)
|
- Frontend: `Unsupervised\Schedular\GroupClass\GroupClassPage` (`[us_group_classes]` shortcode; `offering="…"` restricts it to a single class for embedding on a dedicated page — the block equivalent is the `offeringId` attribute). In single-class mode `assets/js/group-classes.js` leaves the class description out of the card, since the page it is embedded on already describes the class; the schedule, instructor, schedule note, price and enrolment controls are still shown.
|
||||||
- Reuses `Registration\RegistrationGate` (intake answers + booking-scoped policy acceptance, type `enrollment`)
|
- Reuses `Registration\RegistrationGate` (intake answers + booking-scoped policy acceptance, type `enrollment`)
|
||||||
|
|
||||||
> **Payment:** a priced enrolment creates a payment via `Payment\PaymentService`
|
> **Payment:** a priced enrolment creates a payment via `Payment\PaymentService`
|
||||||
|
|||||||
@@ -20,16 +20,66 @@ Students register for a private lesson by choosing an offering, picking a time (
|
|||||||
| `created_at` | DATETIME | Insertion time |
|
| `created_at` | DATETIME | Insertion time |
|
||||||
|
|
||||||
## Registration Flow
|
## Registration Flow
|
||||||
1. Student opens the page with the `[us_booking]` shortcode and browses open slots as a weekly calendar (the default, anchored to the week of the earliest open slot) or an agenda list (view toggle with previous/next-week navigation; times shown in 12-hour AM/PM form).
|
1. Student opens the page with the `[us_booking]` shortcode and browses open slots as a weekly calendar (the default, anchored to the week of the earliest open slot) or an agenda list (view toggle with previous/next-week navigation; times shown in 12-hour AM/PM form). A **Show Only** button beside the view toggle opens a lesson-type filter that narrows the open times to those bookable as the chosen types (see **Lesson-Type Filter**).
|
||||||
2. Student picks a slot and an **offering** (a 30 or 60-minute private-lesson type). When the slot is tied to an offering the form shows it locked (the student sees exactly what they are booking); otherwise the form presents the instructor's active private-lesson offerings whose duration fits the slot. Every booking requires an offering — a generic slot with no fitting offering cannot be booked online.
|
2. Student picks a slot and an **offering** (a 30 or 60-minute private-lesson type). When the slot is tied to an offering the form shows it locked (the student sees exactly what they are booking); otherwise the form presents the instructor's active private-lesson offerings whose duration fits the slot, narrowed to the filtered types. When exactly one type remains it is pre-selected (its intake questions load immediately). Every booking requires an offering — a generic slot with no fitting offering cannot be booked online.
|
||||||
3. For a `weekly` reservation, the same weekday/time is held for the rest of the offering's term.
|
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`).
|
4. Student answers the offering's questions (`GET /offerings/{id}/questions`).
|
||||||
5. Student accepts the current published policy versions (`GET /policies`) — required to continue.
|
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`.
|
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. `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.
|
7. Payment is taken per the student's billing method (card by default; `pending` for e-transfer; skipped for comp). See `payments.md`.
|
||||||
8. On successful payment (or comp) the lesson is `confirmed` and a receipt is emailed.
|
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. Instructor sees the booking under **My Lessons** and may update status via `PATCH /bookings/{id}/status`.
|
9. On successful payment (or comp) the lesson is `confirmed` and a receipt is emailed.
|
||||||
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.
|
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
|
||||||
|
an offering takes that offering only, and a generic slot only takes types whose
|
||||||
|
length fits. The booking calendar therefore carries a lesson-type filter,
|
||||||
|
collapsed behind a **Show Only** button that sits in the calendar's control row
|
||||||
|
beside the List/Week toggle. Opening it reveals the type list between that row
|
||||||
|
and the calendar: a checkbox per active private-lesson type (from
|
||||||
|
`GET /offerings?kind=private_lesson`, fetched once per page load), showing the
|
||||||
|
instructor's name alongside the title when the catalog spans more than one
|
||||||
|
instructor. The button carries the number of ticked types and stays highlighted
|
||||||
|
while the filter is on, so a collapsed filter is never invisible. Both button and
|
||||||
|
list are hidden when there is only one bookable type.
|
||||||
|
|
||||||
|
Ticking one or more types narrows the calendar to the slots bookable as one of
|
||||||
|
them; no ticks means no filter, and collapsing the list leaves the filter
|
||||||
|
applied. Picking a filtered slot narrows the registration form's **Lesson type**
|
||||||
|
picker the same way, and when exactly one type remains it is pre-selected and its
|
||||||
|
intake questions load immediately. Changing the filter re-anchors the week view
|
||||||
|
on the earliest matching slot, so the student never lands on an empty week.
|
||||||
|
**Show all types** clears the filter.
|
||||||
|
|
||||||
|
Bookability is decided client-side by `offeringFitsSlot()` in
|
||||||
|
`assets/js/booking.js` — the mirror of the rule `POST /bookings` enforces (same
|
||||||
|
instructor, the tied offering when there is one, otherwise a matching
|
||||||
|
`duration_minutes`). The filter is a browsing aid only: the server re-checks
|
||||||
|
every booking regardless.
|
||||||
|
|
||||||
|
Two block/shortcode options change what the filter has to work with (see
|
||||||
|
`editor-blocks.md`), passed to the script as data attributes on
|
||||||
|
`#us-booking-app`:
|
||||||
|
|
||||||
|
- **A pinned lesson type** (`data-lesson-type`) narrows the catalog to that one
|
||||||
|
offering, so the page lists only the times bookable as it and books nothing
|
||||||
|
else — the filter control hides itself, there being one type left. A pinned
|
||||||
|
type that is no longer offered shows "This lesson type is not available for
|
||||||
|
booking right now" rather than an empty calendar.
|
||||||
|
- **Filter off** (`data-type-filter="0"`) drops the **Show Only** button
|
||||||
|
entirely; every open time is listed, as before the filter existed.
|
||||||
|
|
||||||
|
## Embedding Halves of the Page
|
||||||
|
The page has two halves — the booking calendar and the student's upcoming
|
||||||
|
lessons — and the block/shortcode can embed either on its own (`displayMode` /
|
||||||
|
`show`: `both` (default), `booking`, `upcoming`). The template simply omits the
|
||||||
|
containers of the half that is not wanted, and the script skips the work that
|
||||||
|
belongs to a missing container: an upcoming-only embed never requests
|
||||||
|
availability or the offering catalog, and a booking-only embed never requests
|
||||||
|
`GET /bookings`. An unrecognised value renders the whole page, so a typo cannot
|
||||||
|
silently hide half of it.
|
||||||
|
|
||||||
## Cancellation
|
## Cancellation
|
||||||
Students cancel their own lessons via `POST /bookings/{id}/cancel` (idempotent).
|
Students cancel their own lessons via `POST /bookings/{id}/cancel` (idempotent).
|
||||||
@@ -93,7 +143,7 @@ acceptance time and IP), and their intake-question answers. On **My Lessons** an
|
|||||||
instructor may only open their own lessons; the studio **Scheduler** may open any.
|
instructor may only open their own lessons; the studio **Scheduler** may open any.
|
||||||
|
|
||||||
## Frontend Shortcodes
|
## Frontend Shortcodes
|
||||||
- `[us_booking]` — student calendar + registration flow; requires `book_lesson` capability
|
- `[us_booking]` — student calendar + registration flow; requires `book_lesson` capability. Attributes: `login_page_id`, `lesson_type` (pin one private-lesson offering), `show_filter` (`no` hides the **Show Only** filter), `show` (`both` / `booking` / `upcoming`)
|
||||||
- `[us_student_login]` — front-end login form for students
|
- `[us_student_login]` — front-end login form for students
|
||||||
|
|
||||||
## Implementation
|
## Implementation
|
||||||
|
|||||||
@@ -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).
|
- `one_time` — charged once at booking (a single private lesson).
|
||||||
- `full_term` — charged in full upfront at registration (a weekly private reservation or a year-long group class). See `payments.md`.
|
- `full_term` — charged in full upfront at registration (a weekly private reservation or a year-long group class). See `payments.md`.
|
||||||
- `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.
|
- `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
|
`weekly` and `monthly` are *scheduled* billing (`Offering::isScheduledBilling()`): the
|
||||||
booking/enrolment succeeds with no payment step, and payments are created later by the
|
booking/enrolment succeeds with no payment step, and payments are created later by the
|
||||||
@@ -107,7 +112,7 @@ Studio admin and instructors manage offerings under **Offerings** in wp-admin.
|
|||||||
## REST API
|
## REST API
|
||||||
| Method | Endpoint | Permission |
|
| Method | Endpoint | Permission |
|
||||||
|----------|---------------------------------------------|----------------------------------|
|
|----------|---------------------------------------------|----------------------------------|
|
||||||
| `GET` | `/wp-json/us-scheduler/v1/offerings` | Public (active offerings only) |
|
| `GET` | `/wp-json/us-scheduler/v1/offerings` | `book_lesson` or `manage_offerings` (active offerings only) |
|
||||||
| `POST` | `/wp-json/us-scheduler/v1/offerings` | `manage_offerings` |
|
| `POST` | `/wp-json/us-scheduler/v1/offerings` | `manage_offerings` |
|
||||||
| `PATCH` | `/wp-json/us-scheduler/v1/offerings/{id}` | `manage_offerings` + owner |
|
| `PATCH` | `/wp-json/us-scheduler/v1/offerings/{id}` | `manage_offerings` + owner |
|
||||||
| `DELETE` | `/wp-json/us-scheduler/v1/offerings/{id}` | `manage_offerings` + owner |
|
| `DELETE` | `/wp-json/us-scheduler/v1/offerings/{id}` | `manage_offerings` + owner |
|
||||||
|
|||||||
@@ -98,6 +98,59 @@ After booking, the destination on a payment can be corrected per booking:
|
|||||||
| `created_at` | DATETIME | Insertion time |
|
| `created_at` | DATETIME | Insertion time |
|
||||||
| `paid_at` | DATETIME | When marked `paid`; NULL otherwise |
|
| `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
|
## 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.)
|
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.
|
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`
|
- Receipts: `Unsupervised\Schedular\Payment\ReceiptMailer`
|
||||||
- Settings page: `Unsupervised\Schedular\Payment\StudioSettings`
|
- Settings page: `Unsupervised\Schedular\Payment\StudioSettings`
|
||||||
- REST endpoint: `Unsupervised\Schedular\Payment\PaymentEndpoint`
|
- 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
|
||||||
|
- `tests/Unit/ShortcodeRegistrarTest.php` (pricing helper registration + localized `taxRate`)
|
||||||
- `tests/Unit/Payment/PaymentRepositoryTest.php`
|
- `tests/Unit/Payment/PaymentRepositoryTest.php`
|
||||||
- `tests/Unit/Payment/PaymentTest.php`
|
- `tests/Unit/Payment/PaymentTest.php`
|
||||||
- `tests/Unit/Payment/StripeGatewayTest.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
|
## Versioning & Acceptance Rules
|
||||||
- Editing a published policy creates a new `draft` version; the old version stays `published` until the draft is published.
|
- 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.
|
- 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.
|
- 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
|
## Admin Interface
|
||||||
**Policies** in wp-admin (`manage_policies`, studio admin only):
|
**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
|
- 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
|
## REST API
|
||||||
| Method | Endpoint | Permission |
|
| 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/PolicyVersionRepositoryTest.php`
|
||||||
- `tests/Unit/Policy/AcceptanceRepositoryTest.php`
|
- `tests/Unit/Policy/AcceptanceRepositoryTest.php`
|
||||||
- `tests/Unit/Policy/PolicyServiceTest.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.
|
- **`weekly`** — one payment per lesson, due **24 hours before** that lesson.
|
||||||
- **`monthly`** — one payment per calendar month, due on the **1st**, covering every
|
- **`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
|
Both apply to **private lessons** and **group classes**. At registration the
|
||||||
booking/enrolment succeeds with `payment: null` (no payment step); the lesson is
|
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 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 |
|
| **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 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
|
- 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
|
unbilled. A monthly group links its earliest lesson via `createForRegistration` and the
|
||||||
|
|||||||
@@ -65,13 +65,21 @@ class RegistrationPage {
|
|||||||
$registered = sanitize_key( Val::string( wp_unslash( $_GET['us_registered'] ?? '' ) ) );
|
$registered = sanitize_key( Val::string( wp_unslash( $_GET['us_registered'] ?? '' ) ) );
|
||||||
|
|
||||||
if ( is_user_logged_in() ) {
|
if ( is_user_logged_in() ) {
|
||||||
|
// 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 ) {
|
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">'
|
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' )
|
. esc_html__( 'Your account has been created and you are now logged in.', 'unsupervised-schedular' )
|
||||||
. '</p></div>';
|
. '</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.
|
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- token identifies the invite; the form submit is nonce-checked in maybeHandleSubmit.
|
||||||
@@ -98,13 +106,17 @@ class RegistrationPage {
|
|||||||
$confirmResult = sanitize_key( Val::string( wp_unslash( $_GET['us_confirmed'] ?? '' ) ) );
|
$confirmResult = sanitize_key( Val::string( wp_unslash( $_GET['us_confirmed'] ?? '' ) ) );
|
||||||
|
|
||||||
// Where the post-confirmation prompt sends students to sign in.
|
// Where the post-confirmation prompt sends students to sign in.
|
||||||
$loginUrl = $this->loginUrl( Val::int( $atts['loginPageId'] ?? $atts['login_page_id'] ?? 0 ) );
|
$loginUrl = $this->loginUrl( $this->successPageId( $atts ) );
|
||||||
|
|
||||||
$policyForms = $this->signupPolicies();
|
$policyForms = $this->signupPolicies();
|
||||||
$accountQuestions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true );
|
$accountQuestions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true );
|
||||||
$canRegister = $open || $inviteValid;
|
$canRegister = $open || $inviteValid;
|
||||||
$inviteOnlyMessage = $this->inviteOnlyMessage( $atts );
|
$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.
|
// The two-step script only matters when there is a second step to reveal.
|
||||||
if ( $canRegister && '' === $successType && [] !== $accountQuestions ) {
|
if ( $canRegister && '' === $successType && [] !== $accountQuestions ) {
|
||||||
wp_enqueue_script( 'us-scheduler-register' );
|
wp_enqueue_script( 'us-scheduler-register' );
|
||||||
@@ -319,21 +331,102 @@ class RegistrationPage {
|
|||||||
return $autoApprove ? self::RESULT_CONFIRM_GROUP : self::RESULT_CONFIRM;
|
return $autoApprove ? self::RESULT_CONFIRM_GROUP : self::RESULT_CONFIRM;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The page id chosen for the post-registration destination, from either the
|
||||||
|
* block (`loginPageId`) or shortcode (`login_page_id`) attribute.
|
||||||
|
*
|
||||||
|
* @param array<int|string, mixed> $atts
|
||||||
|
*/
|
||||||
|
private function successPageId( array $atts ): int {
|
||||||
|
return Val::int( $atts['loginPageId'] ?? $atts['login_page_id'] ?? 0 );
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* URL the post-confirmation sign-in link points to: the chosen login page
|
* URL the post-confirmation sign-in link points to: the chosen login page
|
||||||
* when one is configured (and still exists), otherwise the WordPress login
|
* when one is configured (and still exists), otherwise the WordPress login
|
||||||
* screen.
|
* screen.
|
||||||
*/
|
*/
|
||||||
private function loginUrl( int $loginPageId ): string {
|
private function loginUrl( int $loginPageId ): string {
|
||||||
if ( $loginPageId > 0 ) {
|
return $this->continueUrl( $loginPageId ) ?? wp_login_url();
|
||||||
$url = get_permalink( $loginPageId );
|
}
|
||||||
|
|
||||||
if ( is_string( $url ) ) {
|
/**
|
||||||
return $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 '';
|
||||||
}
|
}
|
||||||
|
|
||||||
return wp_login_url();
|
$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
|
||||||
|
* WordPress-login-screen fallback, so callers that need a page the student
|
||||||
|
* was actually sent to — the invited-student link and the block's
|
||||||
|
* auto-redirect — can tell "not configured" from "configured".
|
||||||
|
*/
|
||||||
|
public function continueUrl( int $pageId ): ?string {
|
||||||
|
if ( $pageId <= 0 ) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = get_permalink( $pageId );
|
||||||
|
|
||||||
|
return is_string( $url ) ? $url : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this request is a *finished* registration — the states the
|
||||||
|
* block's auto-redirect may act on:
|
||||||
|
*
|
||||||
|
* - an invited student who just signed up and is now logged in, and
|
||||||
|
* - a self-signup returning from the emailed confirmation link, whether
|
||||||
|
* their account is ready (`ready`) or awaiting studio approval (`1`).
|
||||||
|
*
|
||||||
|
* Deliberately excluded: the intermediate "check your email" step (the
|
||||||
|
* student would never see the instruction) and every failure — a validation
|
||||||
|
* error or an expired confirmation link (`expired`) — so the message always
|
||||||
|
* gets shown. The `us_confirmed` values are set by
|
||||||
|
* {@see EmailConfirmationHandler::maybeConfirm()}.
|
||||||
|
*/
|
||||||
|
public function isRegistrationComplete(): bool {
|
||||||
|
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display flag; the submit that set it was nonce-checked.
|
||||||
|
$registered = sanitize_key( Val::string( wp_unslash( $_GET['us_registered'] ?? '' ) ) );
|
||||||
|
|
||||||
|
if ( self::RESULT_INVITE === $registered ) {
|
||||||
|
return is_user_logged_in();
|
||||||
|
}
|
||||||
|
|
||||||
|
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display flag set by EmailConfirmationHandler's redirect.
|
||||||
|
$confirmed = sanitize_key( Val::string( wp_unslash( $_GET['us_confirmed'] ?? '' ) ) );
|
||||||
|
|
||||||
|
return in_array( $confirmed, [ '1', 'ready' ], true );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Unsupervised\Schedular\Auth;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps front-end-only users (students) out of wp-admin entirely.
|
||||||
|
*
|
||||||
|
* Students authenticate through the front-end login shortcode and do all of
|
||||||
|
* their work — booking, viewing lessons, paying — on the site's public pages.
|
||||||
|
* They have no reason to see the WordPress dashboard, profile screen, or admin
|
||||||
|
* bar, so this guard redirects them to the front end if they reach wp-admin and
|
||||||
|
* hides the admin bar for them everywhere.
|
||||||
|
*
|
||||||
|
* Access is decided by capability, not role: anyone holding a back-office
|
||||||
|
* capability (a WordPress administrator, studio admin, or instructor) keeps full
|
||||||
|
* wp-admin access, while a user with none of them is treated as front-end only.
|
||||||
|
*/
|
||||||
|
class StudentAdminGuard {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capabilities that grant a genuine reason to be in wp-admin. A user holding
|
||||||
|
* none of these is front-end only and is kept out of the dashboard.
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
private const BACK_OFFICE_CAPS = [
|
||||||
|
'manage_options',
|
||||||
|
RoleManager::CAP_MANAGE_INSTRUCTORS,
|
||||||
|
RoleManager::CAP_MANAGE_STUDENTS,
|
||||||
|
RoleManager::CAP_MANAGE_OFFERINGS,
|
||||||
|
RoleManager::CAP_MANAGE_QUESTIONS,
|
||||||
|
RoleManager::CAP_MANAGE_POLICIES,
|
||||||
|
RoleManager::CAP_MANAGE_BILLING,
|
||||||
|
RoleManager::CAP_MANAGE_AVAILABILITY,
|
||||||
|
RoleManager::CAP_VIEW_ALL_LESSONS,
|
||||||
|
RoleManager::CAP_VIEW_ALL_PAYMENTS,
|
||||||
|
RoleManager::CAP_VIEW_OWN_PAYMENTS,
|
||||||
|
RoleManager::CAP_EXPORT_PAYMENTS,
|
||||||
|
];
|
||||||
|
|
||||||
|
public function register(): void {
|
||||||
|
add_action( 'admin_init', [ $this, 'redirectFromDashboard' ] );
|
||||||
|
add_filter( 'show_admin_bar', [ $this, 'hideAdminBar' ] );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redirect a front-end-only user away from any wp-admin page to the site
|
||||||
|
* home, so the dashboard and profile screens are never reachable.
|
||||||
|
*/
|
||||||
|
public function redirectFromDashboard(): void {
|
||||||
|
if ( ! $this->shouldBlockAdminAccess() ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
wp_safe_redirect( home_url( '/' ) );
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the current request into wp-admin should be bounced to the front
|
||||||
|
* end. AJAX requests are always allowed through so front-end features that
|
||||||
|
* call admin-ajax keep working.
|
||||||
|
*/
|
||||||
|
public function shouldBlockAdminAccess(): bool {
|
||||||
|
if ( wp_doing_ajax() ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( ! is_user_logged_in() ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ! $this->hasBackOfficeAccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide the admin bar for front-end-only users; leave it untouched for anyone
|
||||||
|
* with back-office access.
|
||||||
|
*
|
||||||
|
* @param bool $show Whether WordPress would otherwise show the admin bar.
|
||||||
|
*/
|
||||||
|
public function hideAdminBar( bool $show ): bool {
|
||||||
|
if ( is_user_logged_in() && ! $this->hasBackOfficeAccess() ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $show;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the current user holds any capability that warrants wp-admin access.
|
||||||
|
*/
|
||||||
|
private function hasBackOfficeAccess(): bool {
|
||||||
|
foreach ( self::BACK_OFFICE_CAPS as $cap ) {
|
||||||
|
if ( current_user_can( $cap ) ) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
+67
-6
@@ -15,7 +15,23 @@ namespace Unsupervised\Schedular;
|
|||||||
*/
|
*/
|
||||||
class BlockPreview {
|
class BlockPreview {
|
||||||
|
|
||||||
public static function booking(): string {
|
/**
|
||||||
|
* Sample booking page.
|
||||||
|
*
|
||||||
|
* @param string $mode Which halves the block embeds — one of
|
||||||
|
* {@see Booking\BookingPage::MODE_BOTH},
|
||||||
|
* `MODE_BOOKING` or `MODE_UPCOMING`. The preview shows
|
||||||
|
* the same sections the published page would.
|
||||||
|
*/
|
||||||
|
public static function booking( string $mode = Booking\BookingPage::MODE_BOTH ): string {
|
||||||
|
if ( Booking\BookingPage::MODE_UPCOMING === $mode ) {
|
||||||
|
return sprintf(
|
||||||
|
'<div id="us-booking-app">%s<div id="us-my-lessons">%s</div></div>',
|
||||||
|
self::note( __( 'Editor preview — students see their own lessons on the published page.', 'unsupervised-schedular' ) ),
|
||||||
|
self::upcomingLessons()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
$days = [
|
$days = [
|
||||||
[
|
[
|
||||||
'label' => __( 'Monday', 'unsupervised-schedular' ),
|
'label' => __( 'Monday', 'unsupervised-schedular' ),
|
||||||
@@ -51,20 +67,65 @@ class BlockPreview {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$lessons = Booking\BookingPage::MODE_BOOKING === $mode
|
||||||
|
? ''
|
||||||
|
: sprintf( '<div id="us-my-lessons">%s</div>', self::upcomingLessons() );
|
||||||
|
|
||||||
return sprintf(
|
return sprintf(
|
||||||
'<div id="us-booking-app">%s<div id="us-slot-list">%s</div></div>',
|
'<div id="us-booking-app">%s%s<div id="us-slot-list">%s</div></div>',
|
||||||
self::note( __( 'Editor preview — students see live availability on the published page.', 'unsupervised-schedular' ) ),
|
self::note( __( 'Editor preview — students see live availability on the published page.', 'unsupervised-schedular' ) ),
|
||||||
|
$lessons,
|
||||||
$dayHtml
|
$dayHtml
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function groupClasses(): string {
|
/**
|
||||||
|
* Sample "your upcoming lessons" panel, shared by the booking preview's
|
||||||
|
* full and upcoming-only modes.
|
||||||
|
*/
|
||||||
|
private static function upcomingLessons(): string {
|
||||||
return sprintf(
|
return sprintf(
|
||||||
'<div id="us-group-app">%s<div id="us-group-list"><div class="us-class"><h3>%s</h3><p>%s</p><p>%s</p><p>25.00 CAD</p><button type="button" class="us-enrol-btn" disabled>%s</button></div></div></div>',
|
'<div class="us-my-lessons"><h3>%s</h3>'
|
||||||
self::note( __( 'Editor preview — students see live group classes on the published page.', 'unsupervised-schedular' ) ),
|
. '<div class="us-my-lesson"><span 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-lesson-status us-lesson-status-confirmed">%s</span>'
|
||||||
|
. '<button type="button" class="us-cancel-lesson" disabled>%s</button>'
|
||||||
|
. '</span></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' ),
|
||||||
|
esc_html__( 'Confirmed', 'unsupervised-schedular' ),
|
||||||
|
esc_html__( 'Cancel', 'unsupervised-schedular' )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sample group-class card.
|
||||||
|
*
|
||||||
|
* @param bool $singleClass Whether the block is pinned to one class, in
|
||||||
|
* which case the live page omits the class
|
||||||
|
* description and the preview does too.
|
||||||
|
*/
|
||||||
|
public static function groupClasses( bool $singleClass = false ): string {
|
||||||
|
$note = $singleClass
|
||||||
|
? __( 'Editor preview — the published page shows the chosen class with its live schedule and enrolment status.', 'unsupervised-schedular' )
|
||||||
|
: __( 'Editor preview — students see live group classes on the published page.', 'unsupervised-schedular' );
|
||||||
|
|
||||||
|
$description = $singleClass
|
||||||
|
? ''
|
||||||
|
: '<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 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__( 'Beginner Group Class', 'unsupervised-schedular' ),
|
||||||
esc_html__( 'Saturdays 10:00 AM–11:00 AM', 'unsupervised-schedular' ),
|
esc_html__( 'Saturdays 10:00 AM–11:00 AM', 'unsupervised-schedular' ),
|
||||||
esc_html__( 'A sample class shown so the page can be styled.', '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' )
|
esc_html__( 'Enrol', 'unsupervised-schedular' )
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+73
-8
@@ -85,11 +85,23 @@ class BlockRegistrar {
|
|||||||
'us-scheduler/booking' => [
|
'us-scheduler/booking' => [
|
||||||
'render' => [ $this, 'renderBooking' ],
|
'render' => [ $this, 'renderBooking' ],
|
||||||
'attributes' => [
|
'attributes' => [
|
||||||
'loginPageId' => [
|
'loginPageId' => [
|
||||||
'type' => 'number',
|
'type' => 'number',
|
||||||
'default' => 0,
|
'default' => 0,
|
||||||
],
|
],
|
||||||
'autoRedirect' => $redirectToggle,
|
'autoRedirect' => $redirectToggle,
|
||||||
|
'lessonTypeId' => [
|
||||||
|
'type' => 'number',
|
||||||
|
'default' => 0,
|
||||||
|
],
|
||||||
|
'showTypeFilter' => [
|
||||||
|
'type' => 'boolean',
|
||||||
|
'default' => true,
|
||||||
|
],
|
||||||
|
'displayMode' => [
|
||||||
|
'type' => 'string',
|
||||||
|
'default' => BookingPage::MODE_BOTH,
|
||||||
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
'us-scheduler/student-login' => [
|
'us-scheduler/student-login' => [
|
||||||
@@ -109,6 +121,7 @@ class BlockRegistrar {
|
|||||||
'type' => 'number',
|
'type' => 'number',
|
||||||
'default' => 0,
|
'default' => 0,
|
||||||
],
|
],
|
||||||
|
'autoRedirect' => $redirectToggle,
|
||||||
'inviteOnlyMessage' => [
|
'inviteOnlyMessage' => [
|
||||||
'type' => 'string',
|
'type' => 'string',
|
||||||
'default' => '',
|
'default' => '',
|
||||||
@@ -133,7 +146,11 @@ class BlockRegistrar {
|
|||||||
* @param array<string, mixed> $attributes Block attributes.
|
* @param array<string, mixed> $attributes Block attributes.
|
||||||
*/
|
*/
|
||||||
public function renderBooking( array $attributes = [] ): string {
|
public function renderBooking( array $attributes = [] ): string {
|
||||||
return $this->isEditorPreview() ? BlockPreview::booking() : $this->bookingPage->render( $attributes );
|
if ( ! $this->isEditorPreview() ) {
|
||||||
|
return $this->bookingPage->render( $attributes );
|
||||||
|
}
|
||||||
|
|
||||||
|
return BlockPreview::booking( Val::string( $attributes['displayMode'] ?? BookingPage::MODE_BOTH ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -160,16 +177,21 @@ class BlockRegistrar {
|
|||||||
* @param array<string, mixed> $attributes Block attributes.
|
* @param array<string, mixed> $attributes Block attributes.
|
||||||
*/
|
*/
|
||||||
public function renderGroupClasses( array $attributes = [] ): string {
|
public function renderGroupClasses( array $attributes = [] ): string {
|
||||||
return $this->isEditorPreview() ? BlockPreview::groupClasses() : $this->groupClassPage->render( $attributes );
|
if ( ! $this->isEditorPreview() ) {
|
||||||
|
return $this->groupClassPage->render( $attributes );
|
||||||
|
}
|
||||||
|
|
||||||
|
return BlockPreview::groupClasses( Val::int( $attributes['offeringId'] ?? 0 ) > 0 );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Server-side auto-redirect for blocks that opt in via their autoRedirect
|
* Server-side auto-redirect for blocks that opt in via their autoRedirect
|
||||||
* attribute: logged-out visitors on a page containing the booking block
|
* attribute: logged-out visitors on a page containing the booking block
|
||||||
* are sent to its login page, and logged-in visitors on a page containing
|
* are sent to its login page, logged-in visitors on a page containing the
|
||||||
* the student-login block are sent to its booking page. Hooked on
|
* student-login block are sent to its booking page, and a student who has
|
||||||
* `template_redirect` because block rendering happens after output has
|
* just finished registering is sent to the register block's chosen page.
|
||||||
* started, too late to send a Location header.
|
* Hooked on `template_redirect` because block rendering happens after
|
||||||
|
* output has started, too late to send a Location header.
|
||||||
*/
|
*/
|
||||||
public function maybeAutoRedirect(): void {
|
public function maybeAutoRedirect(): void {
|
||||||
if ( is_admin() || ! is_singular() ) {
|
if ( is_admin() || ! is_singular() ) {
|
||||||
@@ -181,6 +203,10 @@ class BlockRegistrar {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ( $this->maybeRedirectAfterRegistration( $post ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if ( is_user_logged_in() ) {
|
if ( is_user_logged_in() ) {
|
||||||
$attrs = $this->firstBlockAttrs( $post->post_content, 'us-scheduler/student-login' );
|
$attrs = $this->firstBlockAttrs( $post->post_content, 'us-scheduler/student-login' );
|
||||||
if ( null === $attrs || ! Val::bool( $attrs['autoRedirect'] ?? false ) ) {
|
if ( null === $attrs || ! Val::bool( $attrs['autoRedirect'] ?? false ) ) {
|
||||||
@@ -213,6 +239,45 @@ class BlockRegistrar {
|
|||||||
$this->redirect( $this->bookingPage->loginUrl( $loginPageId ) );
|
$this->redirect( $this->bookingPage->loginUrl( $loginPageId ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a student whose registration has just completed to the register
|
||||||
|
* block's chosen page, when the block opts in. Only the finished states
|
||||||
|
* qualify (see {@see RegistrationPage::isRegistrationComplete()}): a
|
||||||
|
* failure or the "check your email" step stays put so its message is read.
|
||||||
|
* Unlike the other blocks there is no login-screen fallback — with no page
|
||||||
|
* chosen there is nowhere to send them, so the link is shown instead.
|
||||||
|
*
|
||||||
|
* Returns whether the redirect was issued (it only ever returns in tests;
|
||||||
|
* {@see redirect()} exits in production).
|
||||||
|
*/
|
||||||
|
private function maybeRedirectAfterRegistration( \WP_Post $post ): bool {
|
||||||
|
// Checked before parsing the content because it is a couple of query
|
||||||
|
// args, whereas every front-end request would otherwise pay for a
|
||||||
|
// third block scan.
|
||||||
|
if ( ! $this->registrationPage->isRegistrationComplete() ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$attrs = $this->firstBlockAttrs( $post->post_content, 'us-scheduler/student-register' );
|
||||||
|
if ( null === $attrs || ! Val::bool( $attrs['autoRedirect'] ?? false ) ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pageId = Val::int( $attrs['loginPageId'] ?? 0 );
|
||||||
|
if ( $pageId === $post->ID ) {
|
||||||
|
return false; // Redirecting the page to itself would loop.
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = $this->registrationPage->continueUrl( $pageId );
|
||||||
|
if ( null === $url ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->redirect( $url );
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attributes of the first occurrence of the named block in the content,
|
* Attributes of the first occurrence of the named block in the content,
|
||||||
* searching inner blocks so blocks nested inside groups or columns are
|
* searching inner blocks so blocks nested inside groups or columns are
|
||||||
|
|||||||
@@ -9,11 +9,30 @@ use Unsupervised\Schedular\Val;
|
|||||||
|
|
||||||
class BookingPage {
|
class BookingPage {
|
||||||
|
|
||||||
|
/** Booking calendar and the student's upcoming lessons (the default). */
|
||||||
|
public const MODE_BOTH = 'both';
|
||||||
|
|
||||||
|
/** Booking calendar only — no upcoming-lessons panel. */
|
||||||
|
public const MODE_BOOKING = 'booking';
|
||||||
|
|
||||||
|
/** The student's upcoming lessons only — nothing bookable. */
|
||||||
|
public const MODE_UPCOMING = 'upcoming';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the booking shortcode/block output.
|
* Renders the booking shortcode/block output.
|
||||||
*
|
*
|
||||||
* @param array<int|string, mixed> $atts Block attributes (`loginPageId`) or
|
* Supported attributes (block / shortcode form):
|
||||||
* shortcode attributes (`login_page_id`).
|
* - `loginPageId` / `login_page_id` — where logged-out visitors are sent.
|
||||||
|
* - `lessonTypeId` / `lesson_type` — a private-lesson offering id that pins
|
||||||
|
* the calendar to one lesson type: only the times bookable as that type
|
||||||
|
* are listed, and only it can be booked. 0 or absent shows every type.
|
||||||
|
* - `showTypeFilter` / `show_filter` — whether the "Show Only" lesson-type
|
||||||
|
* filter is offered (default true; irrelevant when a type is pinned).
|
||||||
|
* - `displayMode` / `show` — which halves of the page to embed:
|
||||||
|
* {@see self::MODE_BOTH} (default), {@see self::MODE_BOOKING} (calendar
|
||||||
|
* only) or {@see self::MODE_UPCOMING} (the student's lessons only).
|
||||||
|
*
|
||||||
|
* @param array<int|string, mixed> $atts Block or shortcode attributes.
|
||||||
*/
|
*/
|
||||||
public function render( array $atts ): string {
|
public function render( array $atts ): string {
|
||||||
if ( ! is_user_logged_in() ) {
|
if ( ! is_user_logged_in() ) {
|
||||||
@@ -38,11 +57,42 @@ class BookingPage {
|
|||||||
wp_enqueue_style( 'us-scheduler' );
|
wp_enqueue_style( 'us-scheduler' );
|
||||||
wp_enqueue_script( 'us-scheduler' );
|
wp_enqueue_script( 'us-scheduler' );
|
||||||
|
|
||||||
|
$lessonTypeId = absint( Val::int( $atts['lessonTypeId'] ?? $atts['lesson_type'] ?? 0 ) );
|
||||||
|
$showTypeFilter = self::toBool( $atts['showTypeFilter'] ?? $atts['show_filter'] ?? true );
|
||||||
|
|
||||||
|
$mode = self::mode( $atts['displayMode'] ?? $atts['show'] ?? self::MODE_BOTH );
|
||||||
|
$showBooking = self::MODE_UPCOMING !== $mode;
|
||||||
|
$showUpcoming = self::MODE_BOOKING !== $mode;
|
||||||
|
|
||||||
ob_start();
|
ob_start();
|
||||||
include USC_PLUGIN_DIR . 'templates/frontend/booking-page.php';
|
include USC_PLUGIN_DIR . 'templates/frontend/booking-page.php';
|
||||||
return (string) ob_get_clean();
|
return (string) ob_get_clean();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalises the display-mode attribute; anything unrecognised embeds the
|
||||||
|
* whole page, so a typo never silently hides half of it.
|
||||||
|
*/
|
||||||
|
private static function mode( mixed $value ): string {
|
||||||
|
$mode = strtolower( trim( Val::string( $value ) ) );
|
||||||
|
|
||||||
|
return in_array( $mode, [ self::MODE_BOOKING, self::MODE_UPCOMING ], true ) ? $mode : self::MODE_BOTH;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads a boolean attribute. Block attributes arrive as real booleans,
|
||||||
|
* shortcode attributes as strings — where the words people actually write
|
||||||
|
* for "off" ("no", "false", "off") are all truthy to PHP, so they are
|
||||||
|
* matched explicitly rather than cast.
|
||||||
|
*/
|
||||||
|
private static function toBool( mixed $value ): bool {
|
||||||
|
if ( is_string( $value ) ) {
|
||||||
|
return ! in_array( strtolower( trim( $value ) ), [ '', '0', 'no', 'false', 'off' ], true );
|
||||||
|
}
|
||||||
|
|
||||||
|
return Val::bool( $value );
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* URL the logged-out prompt sends visitors to: the chosen login page when
|
* URL the logged-out prompt sends visitors to: the chosen login page when
|
||||||
* one is configured (and still exists), otherwise the WordPress login
|
* one is configured (and still exists), otherwise the WordPress login
|
||||||
|
|||||||
@@ -54,6 +54,15 @@ class Offering {
|
|||||||
*/
|
*/
|
||||||
public const VALID_ACCESS_MODES = [ self::ACCESS_PUBLIC, self::ACCESS_INVITE_ONLY ];
|
public const VALID_ACCESS_MODES = [ self::ACCESS_PUBLIC, self::ACCESS_INVITE_ONLY ];
|
||||||
|
|
||||||
|
/** Maximum length of the title, matching the `title` VARCHAR(191) column. */
|
||||||
|
public const MAX_TITLE_LENGTH = 191;
|
||||||
|
|
||||||
|
/** Maximum length of the schedule note, matching the `schedule_note` VARCHAR(191) column. */
|
||||||
|
public const MAX_SCHEDULE_NOTE_LENGTH = 191;
|
||||||
|
|
||||||
|
/** Maximum length of the e-transfer email, matching the `etransfer_email` VARCHAR(191) column. */
|
||||||
|
public const MAX_ETRANSFER_EMAIL_LENGTH = 191;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public readonly int $instructorId,
|
public readonly int $instructorId,
|
||||||
public readonly string $kind,
|
public readonly string $kind,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Unsupervised\Schedular\Offering;
|
namespace Unsupervised\Schedular\Offering;
|
||||||
|
|
||||||
|
use Unsupervised\Schedular\Auth\AccessSettings;
|
||||||
use Unsupervised\Schedular\Auth\RoleManager;
|
use Unsupervised\Schedular\Auth\RoleManager;
|
||||||
use Unsupervised\Schedular\Val;
|
use Unsupervised\Schedular\Val;
|
||||||
|
|
||||||
@@ -11,6 +12,7 @@ class OfferingController {
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private OfferingRepository $repository,
|
private OfferingRepository $repository,
|
||||||
private ClassSlotReconciler $reconciler,
|
private ClassSlotReconciler $reconciler,
|
||||||
|
private AccessSettings $access = new AccessSettings(),
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function renderPage(): void {
|
public function renderPage(): void {
|
||||||
@@ -137,17 +139,28 @@ class OfferingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registered instructors offered in the assignment select, by display name.
|
* Instructors offered in the assignment select, by display name.
|
||||||
|
*
|
||||||
|
* Includes everyone holding the `us_instructor` role plus, when the site owner
|
||||||
|
* has left administrators acting as instructors (the default single-account
|
||||||
|
* setup), WordPress administrators — who teach through the dynamic capability
|
||||||
|
* grant rather than the role. Without them a solo studio owner running the
|
||||||
|
* business from an admin account would find no one to assign a class to.
|
||||||
*
|
*
|
||||||
* @return list<array{id: int, name: string}>
|
* @return list<array{id: int, name: string}>
|
||||||
*/
|
*/
|
||||||
private function instructorOptions(): array {
|
private function instructorOptions(): array {
|
||||||
|
$roles = [ RoleManager::INSTRUCTOR ];
|
||||||
|
if ( $this->access->adminsAreInstructors() ) {
|
||||||
|
$roles[] = 'administrator';
|
||||||
|
}
|
||||||
|
|
||||||
$users = array_filter(
|
$users = array_filter(
|
||||||
get_users(
|
get_users(
|
||||||
[
|
[
|
||||||
'role' => RoleManager::INSTRUCTOR,
|
'role__in' => $roles,
|
||||||
'orderby' => 'display_name',
|
'orderby' => 'display_name',
|
||||||
'order' => 'ASC',
|
'order' => 'ASC',
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
static fn( mixed $u ): bool => $u instanceof \WP_User
|
static fn( mixed $u ): bool => $u instanceof \WP_User
|
||||||
@@ -184,6 +197,17 @@ class OfferingController {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$scheduleNote = $this->nullableText( sanitize_text_field( Val::string( wp_unslash( $_POST['schedule_note'] ?? '' ) ) ) );
|
||||||
|
$etransferEmail = $this->nullableText( sanitize_email( Val::string( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) ) );
|
||||||
|
|
||||||
|
// Reject over-long fixed-size fields rather than let the DB silently drop them.
|
||||||
|
if ( mb_strlen( $title ) > Offering::MAX_TITLE_LENGTH
|
||||||
|
|| ( null !== $scheduleNote && mb_strlen( $scheduleNote ) > Offering::MAX_SCHEDULE_NOTE_LENGTH )
|
||||||
|
|| ( null !== $etransferEmail && mb_strlen( $etransferEmail ) > Offering::MAX_ETRANSFER_EMAIL_LENGTH )
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
$billingMode = sanitize_key( Val::string( wp_unslash( $_POST['billing_mode'] ?? Offering::BILLING_ONE_TIME ) ) );
|
$billingMode = sanitize_key( Val::string( wp_unslash( $_POST['billing_mode'] ?? Offering::BILLING_ONE_TIME ) ) );
|
||||||
if ( ! in_array( $billingMode, Offering::VALID_BILLING_MODES, true ) ) {
|
if ( ! in_array( $billingMode, Offering::VALID_BILLING_MODES, true ) ) {
|
||||||
$billingMode = Offering::BILLING_ONE_TIME;
|
$billingMode = Offering::BILLING_ONE_TIME;
|
||||||
@@ -234,8 +258,8 @@ class OfferingController {
|
|||||||
classTime: $classTime,
|
classTime: $classTime,
|
||||||
enrollmentDeadline: $enrollmentDeadline,
|
enrollmentDeadline: $enrollmentDeadline,
|
||||||
withdrawalDeadline: $withdrawalDeadline,
|
withdrawalDeadline: $withdrawalDeadline,
|
||||||
scheduleNote: $this->nullableText( sanitize_text_field( Val::string( wp_unslash( $_POST['schedule_note'] ?? '' ) ) ) ),
|
scheduleNote: $scheduleNote,
|
||||||
etransferEmail: $this->nullableText( sanitize_email( Val::string( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) ) ),
|
etransferEmail: $etransferEmail,
|
||||||
cancellationCutoffHours: $cutoffHours,
|
cancellationCutoffHours: $cutoffHours,
|
||||||
accessMode: isset( $_POST['invite_only'] ) ? Offering::ACCESS_INVITE_ONLY : Offering::ACCESS_PUBLIC,
|
accessMode: isset( $_POST['invite_only'] ) ? Offering::ACCESS_INVITE_ONLY : Offering::ACCESS_PUBLIC,
|
||||||
isActive: isset( $_POST['is_active'] ),
|
isActive: isset( $_POST['is_active'] ),
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ class OfferingEndpoint {
|
|||||||
[
|
[
|
||||||
'methods' => \WP_REST_Server::READABLE,
|
'methods' => \WP_REST_Server::READABLE,
|
||||||
'callback' => [ $this, 'index' ],
|
'callback' => [ $this, 'index' ],
|
||||||
'permission_callback' => [ $this, 'canBook' ],
|
'permission_callback' => [ $this, 'canRead' ],
|
||||||
'args' => [
|
'args' => [
|
||||||
'instructor_id' => [
|
'instructor_id' => [
|
||||||
'type' => 'integer',
|
'type' => 'integer',
|
||||||
@@ -148,6 +148,14 @@ class OfferingEndpoint {
|
|||||||
return $this->invalid( __( 'Invalid billing mode.', 'unsupervised-schedular' ) );
|
return $this->invalid( __( 'Invalid billing mode.', 'unsupervised-schedular' ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$scheduleNote = $this->nullableText( $request->get_param( 'schedule_note' ) );
|
||||||
|
$etransferEmail = $this->nullableEmail( $request->get_param( 'etransfer_email' ) );
|
||||||
|
|
||||||
|
$lengthError = $this->checkLengths( $title, $scheduleNote, $etransferEmail );
|
||||||
|
if ( $lengthError instanceof \WP_Error ) {
|
||||||
|
return $lengthError;
|
||||||
|
}
|
||||||
|
|
||||||
$offering = new Offering(
|
$offering = new Offering(
|
||||||
instructorId: get_current_user_id(),
|
instructorId: get_current_user_id(),
|
||||||
kind: $kind,
|
kind: $kind,
|
||||||
@@ -162,8 +170,8 @@ class OfferingEndpoint {
|
|||||||
termStart: $this->nullableText( $request->get_param( 'term_start' ) ),
|
termStart: $this->nullableText( $request->get_param( 'term_start' ) ),
|
||||||
termEnd: $this->nullableText( $request->get_param( 'term_end' ) ),
|
termEnd: $this->nullableText( $request->get_param( 'term_end' ) ),
|
||||||
enrollmentDeadline: $this->nullableText( $request->get_param( 'enrollment_deadline' ) ),
|
enrollmentDeadline: $this->nullableText( $request->get_param( 'enrollment_deadline' ) ),
|
||||||
scheduleNote: $this->nullableText( $request->get_param( 'schedule_note' ) ),
|
scheduleNote: $scheduleNote,
|
||||||
etransferEmail: $this->nullableEmail( $request->get_param( 'etransfer_email' ) ),
|
etransferEmail: $etransferEmail,
|
||||||
cancellationCutoffHours: $this->nullableInt( $request->get_param( 'cancellation_cutoff_hours' ) ),
|
cancellationCutoffHours: $this->nullableInt( $request->get_param( 'cancellation_cutoff_hours' ) ),
|
||||||
accessMode: $this->accessMode( $request->get_param( 'access_mode' ), Offering::ACCESS_PUBLIC ),
|
accessMode: $this->accessMode( $request->get_param( 'access_mode' ), Offering::ACCESS_PUBLIC ),
|
||||||
isActive: null === $request->get_param( 'is_active' ) ? true : (bool) $request->get_param( 'is_active' ),
|
isActive: null === $request->get_param( 'is_active' ) ? true : (bool) $request->get_param( 'is_active' ),
|
||||||
@@ -196,10 +204,19 @@ class OfferingEndpoint {
|
|||||||
return $this->invalid( __( 'Invalid billing mode.', 'unsupervised-schedular' ) );
|
return $this->invalid( __( 'Invalid billing mode.', 'unsupervised-schedular' ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$title = $request->has_param( 'title' ) ? sanitize_text_field( Val::string( $request->get_param( 'title' ) ) ) : $existing->title;
|
||||||
|
$scheduleNote = $request->has_param( 'schedule_note' ) ? $this->nullableText( $request->get_param( 'schedule_note' ) ) : $existing->scheduleNote;
|
||||||
|
$etransferEmail = $request->has_param( 'etransfer_email' ) ? $this->nullableEmail( $request->get_param( 'etransfer_email' ) ) : $existing->etransferEmail;
|
||||||
|
|
||||||
|
$lengthError = $this->checkLengths( $title, $scheduleNote, $etransferEmail );
|
||||||
|
if ( $lengthError instanceof \WP_Error ) {
|
||||||
|
return $lengthError;
|
||||||
|
}
|
||||||
|
|
||||||
$offering = new Offering(
|
$offering = new Offering(
|
||||||
instructorId: $existing->instructorId,
|
instructorId: $existing->instructorId,
|
||||||
kind: $kind,
|
kind: $kind,
|
||||||
title: $request->has_param( 'title' ) ? sanitize_text_field( Val::string( $request->get_param( 'title' ) ) ) : $existing->title,
|
title: $title,
|
||||||
price: $request->has_param( 'price' ) ? $this->price( $request->get_param( 'price' ) ) : $existing->price,
|
price: $request->has_param( 'price' ) ? $this->price( $request->get_param( 'price' ) ) : $existing->price,
|
||||||
currency: $request->has_param( 'currency' ) ? sanitize_text_field( Val::string( $request->get_param( 'currency' ) ) ) : $existing->currency,
|
currency: $request->has_param( 'currency' ) ? sanitize_text_field( Val::string( $request->get_param( 'currency' ) ) ) : $existing->currency,
|
||||||
billingMode: $billingMode,
|
billingMode: $billingMode,
|
||||||
@@ -210,8 +227,8 @@ class OfferingEndpoint {
|
|||||||
termStart: $request->has_param( 'term_start' ) ? $this->nullableText( $request->get_param( 'term_start' ) ) : $existing->termStart,
|
termStart: $request->has_param( 'term_start' ) ? $this->nullableText( $request->get_param( 'term_start' ) ) : $existing->termStart,
|
||||||
termEnd: $request->has_param( 'term_end' ) ? $this->nullableText( $request->get_param( 'term_end' ) ) : $existing->termEnd,
|
termEnd: $request->has_param( 'term_end' ) ? $this->nullableText( $request->get_param( 'term_end' ) ) : $existing->termEnd,
|
||||||
enrollmentDeadline: $request->has_param( 'enrollment_deadline' ) ? $this->nullableText( $request->get_param( 'enrollment_deadline' ) ) : $existing->enrollmentDeadline,
|
enrollmentDeadline: $request->has_param( 'enrollment_deadline' ) ? $this->nullableText( $request->get_param( 'enrollment_deadline' ) ) : $existing->enrollmentDeadline,
|
||||||
scheduleNote: $request->has_param( 'schedule_note' ) ? $this->nullableText( $request->get_param( 'schedule_note' ) ) : $existing->scheduleNote,
|
scheduleNote: $scheduleNote,
|
||||||
etransferEmail: $request->has_param( 'etransfer_email' ) ? $this->nullableEmail( $request->get_param( 'etransfer_email' ) ) : $existing->etransferEmail,
|
etransferEmail: $etransferEmail,
|
||||||
cancellationCutoffHours: $request->has_param( 'cancellation_cutoff_hours' ) ? $this->nullableInt( $request->get_param( 'cancellation_cutoff_hours' ) ) : $existing->cancellationCutoffHours,
|
cancellationCutoffHours: $request->has_param( 'cancellation_cutoff_hours' ) ? $this->nullableInt( $request->get_param( 'cancellation_cutoff_hours' ) ) : $existing->cancellationCutoffHours,
|
||||||
accessMode: $request->has_param( 'access_mode' ) ? $this->accessMode( $request->get_param( 'access_mode' ), $existing->accessMode ) : $existing->accessMode,
|
accessMode: $request->has_param( 'access_mode' ) ? $this->accessMode( $request->get_param( 'access_mode' ), $existing->accessMode ) : $existing->accessMode,
|
||||||
isActive: $request->has_param( 'is_active' ) ? (bool) $request->get_param( 'is_active' ) : $existing->isActive,
|
isActive: $request->has_param( 'is_active' ) ? (bool) $request->get_param( 'is_active' ) : $existing->isActive,
|
||||||
@@ -245,12 +262,16 @@ class OfferingEndpoint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reading the offerings catalogue is only needed by the logged-in student
|
* Reading the offerings catalogue has no anonymous consumer, so it stays
|
||||||
* booking flow, so it requires the same capability as booking — there is no
|
* behind a login. Students reach it through the booking flow, and studio
|
||||||
* anonymous consumer.
|
* admins and instructors reach it from the block editor's group-class
|
||||||
|
* pickers — an administrator holds `manage_offerings` but not
|
||||||
|
* `book_lesson`, so both capabilities open the listing.
|
||||||
*/
|
*/
|
||||||
public function canBook(): bool {
|
public function canRead(): bool {
|
||||||
return is_user_logged_in() && current_user_can( RoleManager::CAP_BOOK_LESSON );
|
return is_user_logged_in()
|
||||||
|
&& ( current_user_can( RoleManager::CAP_BOOK_LESSON )
|
||||||
|
|| current_user_can( RoleManager::CAP_MANAGE_OFFERINGS ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -266,6 +287,34 @@ class OfferingEndpoint {
|
|||||||
return new \WP_Error( 'invalid_offering', $message, [ 'status' => 400 ] );
|
return new \WP_Error( 'invalid_offering', $message, [ 'status' => 400 ] );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reject any fixed-size field whose value exceeds its column length, so an
|
||||||
|
* over-long value is refused with a clear 400 rather than silently dropped
|
||||||
|
* by the database.
|
||||||
|
*/
|
||||||
|
private function checkLengths( string $title, ?string $scheduleNote, ?string $etransferEmail ): ?\WP_Error {
|
||||||
|
$fields = [
|
||||||
|
[ __( 'title', 'unsupervised-schedular' ), $title, Offering::MAX_TITLE_LENGTH ],
|
||||||
|
[ __( 'schedule note', 'unsupervised-schedular' ), $scheduleNote, Offering::MAX_SCHEDULE_NOTE_LENGTH ],
|
||||||
|
[ __( 'e-transfer email', 'unsupervised-schedular' ), $etransferEmail, Offering::MAX_ETRANSFER_EMAIL_LENGTH ],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ( $fields as [ $name, $value, $max ] ) {
|
||||||
|
if ( null !== $value && mb_strlen( $value ) > $max ) {
|
||||||
|
return $this->invalid(
|
||||||
|
sprintf(
|
||||||
|
/* translators: 1: field name, 2: maximum character count. */
|
||||||
|
__( 'The %1$s must be %2$d characters or fewer.', 'unsupervised-schedular' ),
|
||||||
|
$name,
|
||||||
|
$max
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private function price( mixed $value ): float {
|
private function price( mixed $value ): float {
|
||||||
return max( 0.0, Val::float( $value ) );
|
return max( 0.0, Val::float( $value ) );
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -251,11 +251,18 @@ class ScheduledBillingRunner {
|
|||||||
/**
|
/**
|
||||||
* Bill one payment per calendar month of a group class, once its 1st arrives.
|
* 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 list<array{start: string, end: string}> $windows
|
||||||
* @param array<int, list<array{payment: Payment, label: string}>> $buckets
|
* @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
|
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 = [];
|
$months = [];
|
||||||
foreach ( $windows as $window ) {
|
foreach ( $windows as $window ) {
|
||||||
$start = new \DateTimeImmutable( $window['start'] );
|
$start = new \DateTimeImmutable( $window['start'] );
|
||||||
@@ -278,7 +285,7 @@ class ScheduledBillingRunner {
|
|||||||
(int) $enrollment->id,
|
(int) $enrollment->id,
|
||||||
$enrollment->studentId,
|
$enrollment->studentId,
|
||||||
$enrollment->instructorId,
|
$enrollment->instructorId,
|
||||||
$offering->price * $count,
|
$offering->price,
|
||||||
$offering->currency,
|
$offering->currency,
|
||||||
$offering->etransferEmail,
|
$offering->etransferEmail,
|
||||||
$monthStart,
|
$monthStart,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use Unsupervised\Schedular\Auth\RegistrationLoginGate;
|
|||||||
use Unsupervised\Schedular\Auth\RegistrationMailer;
|
use Unsupervised\Schedular\Auth\RegistrationMailer;
|
||||||
use Unsupervised\Schedular\Auth\RegistrationPage;
|
use Unsupervised\Schedular\Auth\RegistrationPage;
|
||||||
use Unsupervised\Schedular\Auth\RoleManager;
|
use Unsupervised\Schedular\Auth\RoleManager;
|
||||||
|
use Unsupervised\Schedular\Auth\StudentAdminGuard;
|
||||||
use Unsupervised\Schedular\Booking\BookingPage;
|
use Unsupervised\Schedular\Booking\BookingPage;
|
||||||
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||||||
use Unsupervised\Schedular\Booking\BookingRepository;
|
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||||
@@ -96,6 +97,7 @@ class Plugin {
|
|||||||
( new UpdateChecker() )->register();
|
( new UpdateChecker() )->register();
|
||||||
( new RoleManager() )->register();
|
( new RoleManager() )->register();
|
||||||
( new RegistrationLoginGate() )->register();
|
( new RegistrationLoginGate() )->register();
|
||||||
|
( new StudentAdminGuard() )->register();
|
||||||
( new EmailConfirmationHandler( $settings, $registrationMailer ) )->register();
|
( new EmailConfirmationHandler( $settings, $registrationMailer ) )->register();
|
||||||
( new AdminMenu( $availability, $bookings, $offerings, $questions, $answers, $policies, $policyVersions, $policyService, $acceptances, $invites, $enrollments, $groupAccess, $settings, $paymentRepo, $paymentService, $resolver, $registrationMailer, $creditRepo ) )->register();
|
( new AdminMenu( $availability, $bookings, $offerings, $questions, $answers, $policies, $policyVersions, $policyService, $acceptances, $invites, $enrollments, $groupAccess, $settings, $paymentRepo, $paymentService, $resolver, $registrationMailer, $creditRepo ) )->register();
|
||||||
( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate, $enrollments, $groupAccess, $paymentService ) )->register();
|
( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate, $enrollments, $groupAccess, $paymentService ) )->register();
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ class Policy {
|
|||||||
*/
|
*/
|
||||||
public const VALID_SCOPES = [ self::SCOPE_SIGNUP, self::SCOPE_BOOKING, self::SCOPE_BOTH ];
|
public const VALID_SCOPES = [ self::SCOPE_SIGNUP, self::SCOPE_BOOKING, self::SCOPE_BOTH ];
|
||||||
|
|
||||||
|
/** Maximum length of the title, matching the `title` VARCHAR(191) column. */
|
||||||
|
public const MAX_TITLE_LENGTH = 191;
|
||||||
|
|
||||||
|
/** Maximum length of the slug, matching the `slug` VARCHAR(191) column. */
|
||||||
|
public const MAX_SLUG_LENGTH = 191;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public readonly string $title,
|
public readonly string $title,
|
||||||
public readonly string $slug,
|
public readonly string $slug,
|
||||||
|
|||||||
@@ -19,20 +19,35 @@ class PolicyController {
|
|||||||
wp_die( esc_html__( 'You do not have permission to manage policies.', 'unsupervised-schedular' ) );
|
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' ) ) {
|
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 ) );
|
$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();
|
$policyList = $this->policies->findAll();
|
||||||
$selectedPolicy = $policyId > 0 ? $this->policies->findById( $policyId ) : null;
|
$selectedPolicy = $policyId > 0 ? $this->policies->findById( $policyId ) : null;
|
||||||
$policyVersions = null !== $selectedPolicy ? $this->versions->findByPolicy( (int) $selectedPolicy->id ) : 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';
|
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.
|
// Nonce is verified by the caller (renderPage) before this method runs.
|
||||||
// phpcs:disable WordPress.Security.NonceVerification.Missing
|
// phpcs:disable WordPress.Security.NonceVerification.Missing
|
||||||
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) );
|
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ?? '' ) ) );
|
||||||
@@ -47,16 +62,18 @@ class PolicyController {
|
|||||||
$scope = Policy::SCOPE_BOOKING;
|
$scope = Policy::SCOPE_BOOKING;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ( '' !== $title && '' !== $slug && null === $this->policies->findBySlug( $slug ) ) {
|
$withinLimits = mb_strlen( $title ) <= Policy::MAX_TITLE_LENGTH && mb_strlen( $slug ) <= Policy::MAX_SLUG_LENGTH;
|
||||||
|
|
||||||
|
if ( '' !== $title && '' !== $slug && $withinLimits && null === $this->policies->findBySlug( $slug ) ) {
|
||||||
$this->service->createPolicy( $title, $slug, $scope );
|
$this->service->createPolicy( $title, $slug, $scope );
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return [ '', 0 ];
|
||||||
}
|
}
|
||||||
|
|
||||||
$policyId = absint( Val::int( $_POST['policy_id'] ?? 0 ) );
|
$policyId = absint( Val::int( $_POST['policy_id'] ?? 0 ) );
|
||||||
if ( $policyId <= 0 || null === $this->policies->findById( $policyId ) ) {
|
if ( $policyId <= 0 || null === $this->policies->findById( $policyId ) ) {
|
||||||
return;
|
return [ '', 0 ];
|
||||||
}
|
}
|
||||||
|
|
||||||
if ( 'add_version' === $action ) {
|
if ( 'add_version' === $action ) {
|
||||||
@@ -64,6 +81,40 @@ class PolicyController {
|
|||||||
$this->service->addDraftVersion( $policyId, $body );
|
$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 ) {
|
if ( 'publish_version' === $action ) {
|
||||||
$versionId = absint( Val::int( $_POST['version_id'] ?? 0 ) );
|
$versionId = absint( Val::int( $_POST['version_id'] ?? 0 ) );
|
||||||
if ( $versionId > 0 ) {
|
if ( $versionId > 0 ) {
|
||||||
@@ -71,5 +122,20 @@ class PolicyController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
// 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,
|
'policy_version_id' => $version->id,
|
||||||
'version_number' => $version->versionNumber,
|
'version_number' => $version->versionNumber,
|
||||||
// Bodies are kses'd on every write path, but the booking JS renders
|
// 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
|
// this HTML raw — bodyHtml() sanitises at output too, so a missed
|
||||||
// never become stored XSS.
|
// write path can never become stored XSS.
|
||||||
'body' => wp_kses_post( (string) $version->body ),
|
'body' => $version->bodyHtml(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,12 +118,30 @@ class PolicyEndpoint {
|
|||||||
if ( '' === $title ) {
|
if ( '' === $title ) {
|
||||||
return $this->invalid( __( 'A policy title is required.', 'unsupervised-schedular' ) );
|
return $this->invalid( __( 'A policy title is required.', 'unsupervised-schedular' ) );
|
||||||
}
|
}
|
||||||
|
if ( mb_strlen( $title ) > Policy::MAX_TITLE_LENGTH ) {
|
||||||
|
return $this->invalid(
|
||||||
|
sprintf(
|
||||||
|
/* translators: %d: maximum character count. */
|
||||||
|
__( 'The policy title must be %d characters or fewer.', 'unsupervised-schedular' ),
|
||||||
|
Policy::MAX_TITLE_LENGTH
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
$slugParam = sanitize_text_field( Val::string( $request->get_param( 'slug' ) ) );
|
$slugParam = sanitize_text_field( Val::string( $request->get_param( 'slug' ) ) );
|
||||||
$slug = sanitize_title( '' !== $slugParam ? $slugParam : $title );
|
$slug = sanitize_title( '' !== $slugParam ? $slugParam : $title );
|
||||||
if ( '' === $slug ) {
|
if ( '' === $slug ) {
|
||||||
return $this->invalid( __( 'A valid policy slug is required.', 'unsupervised-schedular' ) );
|
return $this->invalid( __( 'A valid policy slug is required.', 'unsupervised-schedular' ) );
|
||||||
}
|
}
|
||||||
|
if ( mb_strlen( $slug ) > Policy::MAX_SLUG_LENGTH ) {
|
||||||
|
return $this->invalid(
|
||||||
|
sprintf(
|
||||||
|
/* translators: %d: maximum character count. */
|
||||||
|
__( 'The policy slug must be %d characters or fewer.', 'unsupervised-schedular' ),
|
||||||
|
Policy::MAX_SLUG_LENGTH
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if ( null !== $this->policies->findBySlug( $slug ) ) {
|
if ( null !== $this->policies->findBySlug( $slug ) ) {
|
||||||
return new \WP_Error( 'duplicate_slug', __( 'A policy with that slug already exists.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
|
return new \WP_Error( 'duplicate_slug', __( 'A policy with that slug already exists.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
|
||||||
|
|||||||
@@ -42,6 +42,19 @@ class PolicyVersion {
|
|||||||
return self::STATUS_PUBLISHED === $this->status;
|
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.
|
* Returns a plain array representation of the version.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ class Question {
|
|||||||
public const FIELD_SELECT = 'select';
|
public const FIELD_SELECT = 'select';
|
||||||
public const FIELD_CHECKBOX = 'checkbox';
|
public const FIELD_CHECKBOX = 'checkbox';
|
||||||
|
|
||||||
|
/** Maximum length of a question label, matching the `label` VARCHAR(255) column. */
|
||||||
|
public const MAX_LABEL_LENGTH = 255;
|
||||||
|
|
||||||
/** Question is scoped to a single offering, asked at booking/enrolment time. */
|
/** Question is scoped to a single offering, asked at booking/enrolment time. */
|
||||||
public const SCOPE_OFFERING = 'offering';
|
public const SCOPE_OFFERING = 'offering';
|
||||||
|
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ class QuestionController {
|
|||||||
$label = sanitize_text_field( Val::string( wp_unslash( $_POST['label'] ?? '' ) ) );
|
$label = sanitize_text_field( Val::string( wp_unslash( $_POST['label'] ?? '' ) ) );
|
||||||
$fieldType = sanitize_key( Val::string( wp_unslash( $_POST['field_type'] ?? Question::FIELD_TEXT ) ) );
|
$fieldType = sanitize_key( Val::string( wp_unslash( $_POST['field_type'] ?? Question::FIELD_TEXT ) ) );
|
||||||
|
|
||||||
if ( '' === $label || ! in_array( $fieldType, Question::VALID_FIELD_TYPES, true ) ) {
|
if ( '' === $label || mb_strlen( $label ) > Question::MAX_LABEL_LENGTH || ! in_array( $fieldType, Question::VALID_FIELD_TYPES, true ) ) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,9 @@ class QuestionEndpoint {
|
|||||||
if ( '' === $label ) {
|
if ( '' === $label ) {
|
||||||
return $this->invalid( __( 'A question label is required.', 'unsupervised-schedular' ) );
|
return $this->invalid( __( 'A question label is required.', 'unsupervised-schedular' ) );
|
||||||
}
|
}
|
||||||
|
if ( mb_strlen( $label ) > Question::MAX_LABEL_LENGTH ) {
|
||||||
|
return $this->invalid( $this->tooLongMessage( __( 'question', 'unsupervised-schedular' ), Question::MAX_LABEL_LENGTH ) );
|
||||||
|
}
|
||||||
|
|
||||||
$fieldType = Val::string( $request->get_param( 'field_type' ) ?? Question::FIELD_TEXT );
|
$fieldType = Val::string( $request->get_param( 'field_type' ) ?? Question::FIELD_TEXT );
|
||||||
if ( ! in_array( $fieldType, Question::VALID_FIELD_TYPES, true ) ) {
|
if ( ! in_array( $fieldType, Question::VALID_FIELD_TYPES, true ) ) {
|
||||||
@@ -118,9 +121,17 @@ class QuestionEndpoint {
|
|||||||
return $this->invalid( __( 'Invalid field type.', 'unsupervised-schedular' ) );
|
return $this->invalid( __( 'Invalid field type.', 'unsupervised-schedular' ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$label = $request->has_param( 'label' ) ? sanitize_text_field( Val::string( $request->get_param( 'label' ) ) ) : $existing->label;
|
||||||
|
if ( '' === $label ) {
|
||||||
|
return $this->invalid( __( 'A question label is required.', 'unsupervised-schedular' ) );
|
||||||
|
}
|
||||||
|
if ( mb_strlen( $label ) > Question::MAX_LABEL_LENGTH ) {
|
||||||
|
return $this->invalid( $this->tooLongMessage( __( 'question', 'unsupervised-schedular' ), Question::MAX_LABEL_LENGTH ) );
|
||||||
|
}
|
||||||
|
|
||||||
$question = new Question(
|
$question = new Question(
|
||||||
offeringId: $existing->offeringId,
|
offeringId: $existing->offeringId,
|
||||||
label: $request->has_param( 'label' ) ? sanitize_text_field( Val::string( $request->get_param( 'label' ) ) ) : $existing->label,
|
label: $label,
|
||||||
fieldType: $fieldType,
|
fieldType: $fieldType,
|
||||||
options: $request->has_param( 'options' ) ? $this->sanitizeOptions( $request->get_param( 'options' ) ) : $existing->options,
|
options: $request->has_param( 'options' ) ? $this->sanitizeOptions( $request->get_param( 'options' ) ) : $existing->options,
|
||||||
isRequired: $request->has_param( 'is_required' ) ? (bool) $request->get_param( 'is_required' ) : $existing->isRequired,
|
isRequired: $request->has_param( 'is_required' ) ? (bool) $request->get_param( 'is_required' ) : $existing->isRequired,
|
||||||
@@ -217,4 +228,16 @@ class QuestionEndpoint {
|
|||||||
private function invalid( string $message ): \WP_Error {
|
private function invalid( string $message ): \WP_Error {
|
||||||
return new \WP_Error( 'invalid_question', $message, [ 'status' => 400 ] );
|
return new \WP_Error( 'invalid_question', $message, [ 'status' => 400 ] );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a uniform "too long" validation message for a named field.
|
||||||
|
*/
|
||||||
|
private function tooLongMessage( string $field, int $max ): string {
|
||||||
|
return sprintf(
|
||||||
|
/* translators: 1: field name, 2: maximum character count. */
|
||||||
|
__( 'The %1$s must be %2$d characters or fewer.', 'unsupervised-schedular' ),
|
||||||
|
$field,
|
||||||
|
$max
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,14 +64,20 @@ class ShortcodeRegistrar {
|
|||||||
'nonce' => wp_create_nonce( 'wp_rest' ),
|
'nonce' => wp_create_nonce( 'wp_rest' ),
|
||||||
'stripeKey' => $settings->publishableKey(),
|
'stripeKey' => $settings->publishableKey(),
|
||||||
'startOfWeek' => Val::int( get_option( 'start_of_week', 1 ) ),
|
'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
|
// Attach the shared config to the payment helper so it is defined before the
|
||||||
// booking/group scripts (which depend on it) run.
|
// booking/group scripts (which depend on it) run.
|
||||||
wp_localize_script( 'us-scheduler-payment', 'usScheduler', $data );
|
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 );
|
// Price formatting and the pay agreement, shared by booking and enrolment.
|
||||||
wp_register_script( 'us-scheduler-group', USC_PLUGIN_URL . 'assets/js/group-classes.js', [ 'us-scheduler-payment' ], USC_VERSION, true );
|
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).
|
// 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 );
|
wp_register_script( 'us-scheduler-register', USC_PLUGIN_URL . 'assets/js/register.js', [], USC_VERSION, true );
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $e
|
|||||||
<table class="form-table">
|
<table class="form-table">
|
||||||
<tr>
|
<tr>
|
||||||
<th><label for="title"><?php esc_html_e('Title', 'unsupervised-schedular'); ?></label></th>
|
<th><label for="title"><?php esc_html_e('Title', 'unsupervised-schedular'); ?></label></th>
|
||||||
<td><input type="text" name="title" id="title" class="regular-text" required value="<?php echo esc_attr($editing->title ?? ''); ?>"></td>
|
<td><input type="text" name="title" id="title" class="regular-text" maxlength="<?php echo esc_attr((string) Offering::MAX_TITLE_LENGTH); ?>" required value="<?php echo esc_attr($editing->title ?? ''); ?>"></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th><label for="kind"><?php esc_html_e('Kind', 'unsupervised-schedular'); ?></label></th>
|
<th><label for="kind"><?php esc_html_e('Kind', 'unsupervised-schedular'); ?></label></th>
|
||||||
@@ -140,11 +140,11 @@ if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $e
|
|||||||
</tr>
|
</tr>
|
||||||
<tr class="us-group-only">
|
<tr class="us-group-only">
|
||||||
<th><label for="schedule_note"><?php esc_html_e('Schedule note', 'unsupervised-schedular'); ?></label></th>
|
<th><label for="schedule_note"><?php esc_html_e('Schedule note', 'unsupervised-schedular'); ?></label></th>
|
||||||
<td><input type="text" name="schedule_note" id="schedule_note" class="regular-text" placeholder="<?php esc_attr_e('e.g. Tuesdays 4:00pm', 'unsupervised-schedular'); ?>" value="<?php echo esc_attr($editing->scheduleNote ?? ''); ?>"></td>
|
<td><input type="text" name="schedule_note" id="schedule_note" class="regular-text" maxlength="<?php echo esc_attr((string) Offering::MAX_SCHEDULE_NOTE_LENGTH); ?>" placeholder="<?php esc_attr_e('e.g. Tuesdays 4:00pm', 'unsupervised-schedular'); ?>" value="<?php echo esc_attr($editing->scheduleNote ?? ''); ?>"></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th><label for="etransfer_email"><?php esc_html_e('E-transfer email', 'unsupervised-schedular'); ?></label></th>
|
<th><label for="etransfer_email"><?php esc_html_e('E-transfer email', 'unsupervised-schedular'); ?></label></th>
|
||||||
<td><input type="email" name="etransfer_email" id="etransfer_email" class="regular-text" placeholder="<?php esc_attr_e('Overrides the studio default', 'unsupervised-schedular'); ?>" value="<?php echo esc_attr($editing->etransferEmail ?? ''); ?>"></td>
|
<td><input type="email" name="etransfer_email" id="etransfer_email" class="regular-text" maxlength="<?php echo esc_attr((string) Offering::MAX_ETRANSFER_EMAIL_LENGTH); ?>" placeholder="<?php esc_attr_e('Overrides the studio default', 'unsupervised-schedular'); ?>" value="<?php echo esc_attr($editing->etransferEmail ?? ''); ?>"></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th><label for="cancellation_cutoff_hours"><?php esc_html_e('Cancellation cutoff (hours)', 'unsupervised-schedular'); ?></label></th>
|
<th><label for="cancellation_cutoff_hours"><?php esc_html_e('Cancellation cutoff (hours)', 'unsupervised-schedular'); ?></label></th>
|
||||||
|
|||||||
@@ -12,11 +12,17 @@ if (! defined('ABSPATH')) {
|
|||||||
* @var list<\Unsupervised\Schedular\Policy\Policy> $policyList
|
* @var list<\Unsupervised\Schedular\Policy\Policy> $policyList
|
||||||
* @var \Unsupervised\Schedular\Policy\Policy|null $selectedPolicy
|
* @var \Unsupervised\Schedular\Policy\Policy|null $selectedPolicy
|
||||||
* @var list<\Unsupervised\Schedular\Policy\PolicyVersion>|null $policyVersions
|
* @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">
|
<div class="wrap">
|
||||||
<h1><?php esc_html_e('Policies', 'unsupervised-schedular'); ?></h1>
|
<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>
|
<h2><?php esc_html_e('Add Policy', 'unsupervised-schedular'); ?></h2>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<?php wp_nonce_field('usc_policy_action'); ?>
|
<?php wp_nonce_field('usc_policy_action'); ?>
|
||||||
@@ -24,12 +30,12 @@ if (! defined('ABSPATH')) {
|
|||||||
<table class="form-table">
|
<table class="form-table">
|
||||||
<tr>
|
<tr>
|
||||||
<th><label for="title"><?php esc_html_e('Title', 'unsupervised-schedular'); ?></label></th>
|
<th><label for="title"><?php esc_html_e('Title', 'unsupervised-schedular'); ?></label></th>
|
||||||
<td><input type="text" name="title" id="title" class="regular-text" required></td>
|
<td><input type="text" name="title" id="title" class="regular-text" maxlength="<?php echo esc_attr((string) Policy::MAX_TITLE_LENGTH); ?>" required></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th><label for="slug"><?php esc_html_e('Slug', 'unsupervised-schedular'); ?></label></th>
|
<th><label for="slug"><?php esc_html_e('Slug', 'unsupervised-schedular'); ?></label></th>
|
||||||
<td>
|
<td>
|
||||||
<input type="text" name="slug" id="slug" class="regular-text" placeholder="<?php esc_attr_e('e.g. cancellation (defaults from title)', 'unsupervised-schedular'); ?>">
|
<input type="text" name="slug" id="slug" class="regular-text" maxlength="<?php echo esc_attr((string) Policy::MAX_SLUG_LENGTH); ?>" placeholder="<?php esc_attr_e('e.g. cancellation (defaults from title)', 'unsupervised-schedular'); ?>">
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -98,6 +104,18 @@ if (! defined('ABSPATH')) {
|
|||||||
<td><?php echo esc_html($version->status); ?></td>
|
<td><?php echo esc_html($version->status); ?></td>
|
||||||
<td><?php echo $version->publishedAt ? esc_html($version->publishedAt) : '—'; ?></td>
|
<td><?php echo $version->publishedAt ? esc_html($version->publishedAt) : '—'; ?></td>
|
||||||
<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) : ?>
|
<?php if (PolicyVersion::STATUS_PUBLISHED !== $version->status) : ?>
|
||||||
<form method="post" style="display:inline;">
|
<form method="post" style="display:inline;">
|
||||||
<?php wp_nonce_field('usc_policy_action'); ?>
|
<?php wp_nonce_field('usc_policy_action'); ?>
|
||||||
@@ -117,5 +135,48 @@ if (! defined('ABSPATH')) {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<?php endif; ?>
|
<?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; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ if (! defined('ABSPATH')) {
|
|||||||
<table class="form-table">
|
<table class="form-table">
|
||||||
<tr>
|
<tr>
|
||||||
<th><label for="label"><?php esc_html_e('Question', 'unsupervised-schedular'); ?></label></th>
|
<th><label for="label"><?php esc_html_e('Question', 'unsupervised-schedular'); ?></label></th>
|
||||||
<td><input type="text" name="label" id="label" class="regular-text" required></td>
|
<td><input type="text" name="label" id="label" class="regular-text" maxlength="<?php echo esc_attr((string) Question::MAX_LABEL_LENGTH); ?>" required></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th><label for="field_type"><?php esc_html_e('Field type', 'unsupervised-schedular'); ?></label></th>
|
<th><label for="field_type"><?php esc_html_e('Field type', 'unsupervised-schedular'); ?></label></th>
|
||||||
|
|||||||
@@ -4,14 +4,23 @@ declare(strict_types=1);
|
|||||||
if (! defined('ABSPATH')) {
|
if (! defined('ABSPATH')) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @var int $lessonTypeId Offering id when the calendar is pinned to one lesson type; 0 for every type. */
|
||||||
|
/** @var bool $showTypeFilter Whether the "Show Only" lesson-type filter is offered. */
|
||||||
|
/** @var bool $showBooking Whether the booking calendar is part of this embed. */
|
||||||
|
/** @var bool $showUpcoming Whether the student's upcoming-lessons panel is part of this embed. */
|
||||||
?>
|
?>
|
||||||
<div id="us-booking-app" data-nonce="<?php echo esc_attr(wp_create_nonce('wp_rest')); ?>">
|
<div id="us-booking-app" data-nonce="<?php echo esc_attr(wp_create_nonce('wp_rest')); ?>"<?php echo $lessonTypeId > 0 ? ' data-lesson-type="' . esc_attr((string) $lessonTypeId) . '"' : ''; ?><?php echo $showTypeFilter ? '' : ' data-type-filter="0"'; ?>>
|
||||||
|
<?php if ($showUpcoming) : ?>
|
||||||
<div id="us-my-lessons"></div>
|
<div id="us-my-lessons"></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($showBooking) : ?>
|
||||||
<div id="us-slot-list">
|
<div id="us-slot-list">
|
||||||
<p><?php esc_html_e('Loading available slots…', 'unsupervised-schedular'); ?></p>
|
<p><?php esc_html_e('Loading available slots…', 'unsupervised-schedular'); ?></p>
|
||||||
</div>
|
</div>
|
||||||
<div id="us-booking-confirmation" style="display:none;">
|
<div id="us-booking-confirmation" style="display:none;">
|
||||||
<p><?php esc_html_e('Your lesson has been booked. The instructor will confirm shortly.', 'unsupervised-schedular'); ?></p>
|
<p><?php esc_html_e('Your lesson has been booked. The instructor will confirm shortly.', 'unsupervised-schedular'); ?></p>
|
||||||
</div>
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
<div id="us-booking-error" style="display:none;" role="alert"></div>
|
<div id="us-booking-error" style="display:none;" role="alert"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ if (! defined('ABSPATH')) {
|
|||||||
* @var bool $canRegister
|
* @var bool $canRegister
|
||||||
* @var string $inviteOnlyMessage Text shown when registration is closed and no valid invite is present.
|
* @var string $inviteOnlyMessage Text shown when registration is closed and no valid invite is present.
|
||||||
* @var bool $open Whether open (self-approval) registration is enabled.
|
* @var bool $open Whether open (self-approval) registration is enabled.
|
||||||
* @var string $successType '' | 'invite' (created + logged in) | 'confirm' (check email) | 'confirm_group' (check email; auto-approved on confirm).
|
* @var string $successType '' | 'confirm' (check email) | 'confirm_group' (check email; auto-approved on confirm). The invited-student success is rendered by RegistrationPage::render() itself, which returns before this template for logged-in visitors.
|
||||||
* @var string $confirmResult '' | '1' (email confirmed, awaiting approval) | 'ready' (confirmed + auto-approved) | 'expired'.
|
* @var string $confirmResult '' | '1' (email confirmed, awaiting approval) | 'ready' (confirmed + auto-approved) | 'expired'.
|
||||||
* @var string $loginUrl Where the post-confirmation sign-in link points.
|
* @var string $loginUrl Where the post-confirmation sign-in link points.
|
||||||
* @var string $error
|
* @var string $error
|
||||||
@@ -57,9 +57,7 @@ $renderQuestionField = static function (Question $question): void {
|
|||||||
};
|
};
|
||||||
?>
|
?>
|
||||||
<div class="us-register-form">
|
<div class="us-register-form">
|
||||||
<?php if ($successType === 'invite') : ?>
|
<?php if ($successType === 'confirm') : ?>
|
||||||
<p class="us-success"><?php esc_html_e('Your account has been created and you are now logged in.', 'unsupervised-schedular'); ?></p>
|
|
||||||
<?php elseif ($successType === 'confirm') : ?>
|
|
||||||
<p class="us-success"><?php esc_html_e('Your account has been created. Check your email for a link to confirm your address — once you do, a studio admin will review and approve your account.', 'unsupervised-schedular'); ?></p>
|
<p class="us-success"><?php esc_html_e('Your account has been created. Check your email for a link to confirm your address — once you do, a studio admin will review and approve your account.', 'unsupervised-schedular'); ?></p>
|
||||||
<?php elseif ($successType === 'confirm_group') : ?>
|
<?php elseif ($successType === 'confirm_group') : ?>
|
||||||
<p class="us-success"><?php esc_html_e('Your account has been created. Check your email for a link to confirm your address — once you do, your account is ready to use.', 'unsupervised-schedular'); ?></p>
|
<p class="us-success"><?php esc_html_e('Your account has been created. Check your email for a link to confirm your address — once you do, your account is ready to use.', 'unsupervised-schedular'); ?></p>
|
||||||
@@ -110,7 +108,7 @@ $renderQuestionField = static function (Question $question): void {
|
|||||||
<?php foreach ($policyForms as $form) : ?>
|
<?php foreach ($policyForms as $form) : ?>
|
||||||
<div class="us-policy">
|
<div class="us-policy">
|
||||||
<h4><?php echo esc_html($form['policy']->title); ?></h4>
|
<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>
|
<label>
|
||||||
<input type="checkbox" name="accept[]" value="<?php echo esc_attr((string) $form['version']->id); ?>" required>
|
<input type="checkbox" name="accept[]" value="<?php echo esc_attr((string) $form['version']->id); ?>" required>
|
||||||
<?php
|
<?php
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ class RegistrationPageTest extends TestCase
|
|||||||
Functions\when('sanitize_email')->alias(static fn ($v) => $v);
|
Functions\when('sanitize_email')->alias(static fn ($v) => $v);
|
||||||
Functions\when('absint')->alias(static fn ($v) => (int) $v);
|
Functions\when('absint')->alias(static fn ($v) => (int) $v);
|
||||||
Functions\when('current_time')->justReturn('2024-01-01 00:00:00');
|
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);
|
$invites = Mockery::mock(InviteRepository::class);
|
||||||
$policies = Mockery::mock(PolicyRepository::class);
|
$policies = Mockery::mock(PolicyRepository::class);
|
||||||
@@ -484,6 +486,104 @@ class RegistrationPageTest extends TestCase
|
|||||||
|
|
||||||
self::assertStringContainsString('us-success', $html);
|
self::assertStringContainsString('us-success', $html);
|
||||||
self::assertStringContainsString('now logged in', $html);
|
self::assertStringContainsString('now logged in', $html);
|
||||||
|
// No page chosen: the sign-in-screen fallback is useless to someone who
|
||||||
|
// is already signed in, so no link is offered at all.
|
||||||
|
self::assertStringNotContainsString('<a href', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testInviteSuccessLinksToTheChosenPage(): void
|
||||||
|
{
|
||||||
|
$_GET = [ 'us_registered' => 'invite' ];
|
||||||
|
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
|
||||||
|
{
|
||||||
|
Functions\when('get_permalink')->justReturn(false);
|
||||||
|
|
||||||
|
self::assertNull($this->ctx['page']->continueUrl(0));
|
||||||
|
self::assertNull($this->ctx['page']->continueUrl(4));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIsRegistrationCompleteOnlyForFinishedStates(): void
|
||||||
|
{
|
||||||
|
Functions\when('sanitize_key')->alias(static fn ($v) => strtolower((string) $v));
|
||||||
|
|
||||||
|
// [ query args, logged in, finished ]
|
||||||
|
$cases = [
|
||||||
|
'invited student, now logged in' => [['us_registered' => 'invite'], true, true],
|
||||||
|
'invited student, not logged in' => [['us_registered' => 'invite'], false, false],
|
||||||
|
'email confirmed, ready' => [['us_confirmed' => 'ready'], false, true],
|
||||||
|
'email confirmed, pending review' => [['us_confirmed' => '1'], false, true],
|
||||||
|
'confirmation link expired' => [['us_confirmed' => 'expired'], false, false],
|
||||||
|
'awaiting email confirmation' => [['us_registered' => 'confirm'], false, false],
|
||||||
|
'group signup awaiting confirm' => [['us_registered' => 'confirm_group'], false, false],
|
||||||
|
'plain page view' => [[], false, false],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($cases as $label => [$get, $loggedIn, $expected]) {
|
||||||
|
$_GET = $get;
|
||||||
|
Functions\when('is_user_logged_in')->justReturn($loggedIn);
|
||||||
|
|
||||||
|
self::assertSame($expected, $this->ctx['page']->isRegistrationComplete(), $label);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testInviteOnlyMessageCanBeCustomised(): void
|
public function testInviteOnlyMessageCanBeCustomised(): void
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Unsupervised\Schedular\Tests\Unit\Auth;
|
||||||
|
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
use Unsupervised\Schedular\Auth\RoleManager;
|
||||||
|
use Unsupervised\Schedular\Auth\StudentAdminGuard;
|
||||||
|
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||||
|
|
||||||
|
class StudentAdminGuardTest extends TestCase
|
||||||
|
{
|
||||||
|
private StudentAdminGuard $guard;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->guard = new StudentAdminGuard();
|
||||||
|
Functions\when('wp_doing_ajax')->justReturn(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $held Capabilities the user is treated as holding.
|
||||||
|
*/
|
||||||
|
private function stubUser(bool $loggedIn, array $held = []): void
|
||||||
|
{
|
||||||
|
Functions\when('is_user_logged_in')->justReturn($loggedIn);
|
||||||
|
Functions\when('current_user_can')->alias(static fn (string $cap): bool => in_array($cap, $held, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBlocksStudentWithNoBackOfficeCapabilities(): void
|
||||||
|
{
|
||||||
|
// A student holds only front-end capabilities.
|
||||||
|
$this->stubUser(true, [RoleManager::CAP_BOOK_LESSON, RoleManager::CAP_VIEW_LESSONS]);
|
||||||
|
|
||||||
|
self::assertTrue($this->guard->shouldBlockAdminAccess());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAllowsInstructor(): void
|
||||||
|
{
|
||||||
|
$this->stubUser(true, [RoleManager::CAP_MANAGE_AVAILABILITY]);
|
||||||
|
|
||||||
|
self::assertFalse($this->guard->shouldBlockAdminAccess());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAllowsAdministrator(): void
|
||||||
|
{
|
||||||
|
$this->stubUser(true, ['manage_options']);
|
||||||
|
|
||||||
|
self::assertFalse($this->guard->shouldBlockAdminAccess());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDoesNotBlockLoggedOutRequests(): void
|
||||||
|
{
|
||||||
|
$this->stubUser(false);
|
||||||
|
|
||||||
|
self::assertFalse($this->guard->shouldBlockAdminAccess());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDoesNotBlockAjaxRequests(): void
|
||||||
|
{
|
||||||
|
Functions\when('wp_doing_ajax')->justReturn(true);
|
||||||
|
$this->stubUser(true, [RoleManager::CAP_BOOK_LESSON]);
|
||||||
|
|
||||||
|
self::assertFalse($this->guard->shouldBlockAdminAccess());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testHidesAdminBarForStudent(): void
|
||||||
|
{
|
||||||
|
$this->stubUser(true, [RoleManager::CAP_BOOK_LESSON]);
|
||||||
|
|
||||||
|
self::assertFalse($this->guard->hideAdminBar(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testKeepsAdminBarForInstructor(): void
|
||||||
|
{
|
||||||
|
$this->stubUser(true, [RoleManager::CAP_MANAGE_AVAILABILITY]);
|
||||||
|
|
||||||
|
self::assertTrue($this->guard->hideAdminBar(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLeavesAdminBarUntouchedForLoggedOutVisitor(): void
|
||||||
|
{
|
||||||
|
$this->stubUser(false);
|
||||||
|
|
||||||
|
self::assertFalse($this->guard->hideAdminBar(false));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ namespace Unsupervised\Schedular\Tests\Unit;
|
|||||||
|
|
||||||
use Brain\Monkey\Functions;
|
use Brain\Monkey\Functions;
|
||||||
use Unsupervised\Schedular\BlockPreview;
|
use Unsupervised\Schedular\BlockPreview;
|
||||||
|
use Unsupervised\Schedular\Booking\BookingPage;
|
||||||
|
|
||||||
class BlockPreviewTest extends TestCase
|
class BlockPreviewTest extends TestCase
|
||||||
{
|
{
|
||||||
@@ -18,6 +19,25 @@ class BlockPreviewTest extends TestCase
|
|||||||
self::assertStringContainsString('class="us-slot"', $html);
|
self::assertStringContainsString('class="us-slot"', $html);
|
||||||
self::assertStringContainsString('class="us-book-btn" disabled', $html);
|
self::assertStringContainsString('class="us-book-btn" disabled', $html);
|
||||||
self::assertStringContainsString('us-editor-note', $html);
|
self::assertStringContainsString('us-editor-note', $html);
|
||||||
|
self::assertStringContainsString('id="us-my-lessons"', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBookingOnlyPreviewLeavesOutTheUpcomingLessons(): void
|
||||||
|
{
|
||||||
|
$html = BlockPreview::booking(BookingPage::MODE_BOOKING);
|
||||||
|
|
||||||
|
self::assertStringContainsString('id="us-slot-list"', $html);
|
||||||
|
self::assertStringNotContainsString('us-my-lessons', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUpcomingOnlyPreviewLeavesOutTheCalendar(): void
|
||||||
|
{
|
||||||
|
$html = BlockPreview::booking(BookingPage::MODE_UPCOMING);
|
||||||
|
|
||||||
|
self::assertStringContainsString('id="us-my-lessons"', $html);
|
||||||
|
self::assertStringContainsString('Your upcoming lessons', $html);
|
||||||
|
self::assertStringNotContainsString('us-slot-list', $html);
|
||||||
|
self::assertStringNotContainsString('us-book-btn', $html);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testGroupClassesPreviewMirrorsTheLiveMarkup(): void
|
public function testGroupClassesPreviewMirrorsTheLiveMarkup(): void
|
||||||
@@ -29,6 +49,17 @@ class BlockPreviewTest extends TestCase
|
|||||||
self::assertStringContainsString('class="us-class"', $html);
|
self::assertStringContainsString('class="us-class"', $html);
|
||||||
self::assertStringContainsString('class="us-enrol-btn" disabled', $html);
|
self::assertStringContainsString('class="us-enrol-btn" disabled', $html);
|
||||||
self::assertStringContainsString('us-editor-note', $html);
|
self::assertStringContainsString('us-editor-note', $html);
|
||||||
|
self::assertStringContainsString('A sample class shown so the page can be styled.', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSingleClassGroupPreviewDropsTheDescriptionButKeepsScheduleAndEnrolment(): void
|
||||||
|
{
|
||||||
|
$html = BlockPreview::groupClasses(true);
|
||||||
|
|
||||||
|
self::assertStringNotContainsString('A sample class shown so the page can be styled.', $html);
|
||||||
|
self::assertStringContainsString('class="us-class-when"', $html);
|
||||||
|
self::assertStringContainsString('class="us-enrol-deadline"', $html);
|
||||||
|
self::assertStringContainsString('class="us-enrol-btn" disabled', $html);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testLoginPreviewIncludesTheRealLoginTemplate(): void
|
public function testLoginPreviewIncludesTheRealLoginTemplate(): void
|
||||||
|
|||||||
@@ -52,6 +52,12 @@ class BlockRegistrarTest extends TestCase
|
|||||||
$this->registrationPage = Mockery::mock(RegistrationPage::class);
|
$this->registrationPage = Mockery::mock(RegistrationPage::class);
|
||||||
$this->groupClassPage = Mockery::mock(GroupClassPage::class);
|
$this->groupClassPage = Mockery::mock(GroupClassPage::class);
|
||||||
|
|
||||||
|
// Most requests are not a just-finished registration; the tests that
|
||||||
|
// exercise that path override this.
|
||||||
|
$this->registrationPage->shouldReceive('isRegistrationComplete')
|
||||||
|
->andReturn(false)
|
||||||
|
->byDefault();
|
||||||
|
|
||||||
$this->registrar = new TestableBlockRegistrar(
|
$this->registrar = new TestableBlockRegistrar(
|
||||||
$this->bookingPage,
|
$this->bookingPage,
|
||||||
$this->loginPage,
|
$this->loginPage,
|
||||||
@@ -118,7 +124,7 @@ class BlockRegistrarTest extends TestCase
|
|||||||
// The link-target and auto-redirect options must be declared
|
// The link-target and auto-redirect options must be declared
|
||||||
// server-side or the block-renderer preview rejects them.
|
// server-side or the block-renderer preview rejects them.
|
||||||
self::assertSame(
|
self::assertSame(
|
||||||
['loginPageId', 'autoRedirect'],
|
['loginPageId', 'autoRedirect', 'lessonTypeId', 'showTypeFilter', 'displayMode'],
|
||||||
array_keys($registered['us-scheduler/booking']['attributes'])
|
array_keys($registered['us-scheduler/booking']['attributes'])
|
||||||
);
|
);
|
||||||
self::assertSame(
|
self::assertSame(
|
||||||
@@ -126,7 +132,7 @@ class BlockRegistrarTest extends TestCase
|
|||||||
array_keys($registered['us-scheduler/student-login']['attributes'])
|
array_keys($registered['us-scheduler/student-login']['attributes'])
|
||||||
);
|
);
|
||||||
self::assertSame(
|
self::assertSame(
|
||||||
['loginPageId', 'inviteOnlyMessage'],
|
['loginPageId', 'autoRedirect', 'inviteOnlyMessage'],
|
||||||
array_keys($registered['us-scheduler/student-register']['attributes'])
|
array_keys($registered['us-scheduler/student-register']['attributes'])
|
||||||
);
|
);
|
||||||
self::assertSame(
|
self::assertSame(
|
||||||
@@ -179,6 +185,19 @@ class BlockRegistrarTest extends TestCase
|
|||||||
self::assertStringContainsString('us-group-app', $this->registrar->renderGroupClasses());
|
self::assertStringContainsString('us-group-app', $this->registrar->renderGroupClasses());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testEditorPreviewOfAPinnedGroupClassOmitsTheDescription(): void
|
||||||
|
{
|
||||||
|
$this->registrar->preview = true;
|
||||||
|
$this->groupClassPage->shouldNotReceive('render');
|
||||||
|
|
||||||
|
$all = $this->registrar->renderGroupClasses();
|
||||||
|
$single = $this->registrar->renderGroupClasses(['offeringId' => 12]);
|
||||||
|
|
||||||
|
self::assertStringContainsString('A sample class shown so the page can be styled.', $all);
|
||||||
|
self::assertStringNotContainsString('A sample class shown so the page can be styled.', $single);
|
||||||
|
self::assertStringContainsString('us-enrol-deadline', $single);
|
||||||
|
}
|
||||||
|
|
||||||
public function testIsEditorPreviewIsFalseOutsideRestRequests(): void
|
public function testIsEditorPreviewIsFalseOutsideRestRequests(): void
|
||||||
{
|
{
|
||||||
// REST_REQUEST is undefined in the test process, so the real
|
// REST_REQUEST is undefined in the test process, so the real
|
||||||
@@ -384,6 +403,145 @@ class BlockRegistrarTest extends TestCase
|
|||||||
self::assertSame([], $this->registrar->redirects);
|
self::assertSame([], $this->registrar->redirects);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testAutoRedirectSendsAFinishedRegistrationToTheChosenPage(): void
|
||||||
|
{
|
||||||
|
$this->stubSingularRequest(
|
||||||
|
30,
|
||||||
|
[
|
||||||
|
[
|
||||||
|
'blockName' => 'us-scheduler/student-register',
|
||||||
|
'attrs' => ['autoRedirect' => true, 'loginPageId' => 4],
|
||||||
|
'innerBlocks' => [],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->registrationPage->shouldReceive('isRegistrationComplete')->andReturn(true);
|
||||||
|
$this->registrationPage->shouldReceive('continueUrl')
|
||||||
|
->once()->with(4)->andReturn('https://example.com/welcome/');
|
||||||
|
|
||||||
|
$this->registrar->maybeAutoRedirect();
|
||||||
|
|
||||||
|
self::assertSame(['https://example.com/welcome/'], $this->registrar->redirects);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAutoRedirectSendsAJustLoggedInInvitedStudentToTheChosenPage(): void
|
||||||
|
{
|
||||||
|
// The invited-student branch completes logged in, so the logged-in
|
||||||
|
// student-login branch must not get first claim on the request.
|
||||||
|
$this->stubSingularRequest(
|
||||||
|
30,
|
||||||
|
[
|
||||||
|
[
|
||||||
|
'blockName' => 'us-scheduler/student-register',
|
||||||
|
'attrs' => ['autoRedirect' => true, 'loginPageId' => 4],
|
||||||
|
'innerBlocks' => [],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->registrationPage->shouldReceive('isRegistrationComplete')->andReturn(true);
|
||||||
|
$this->registrationPage->shouldReceive('continueUrl')
|
||||||
|
->once()->with(4)->andReturn('https://example.com/welcome/');
|
||||||
|
|
||||||
|
$this->registrar->maybeAutoRedirect();
|
||||||
|
|
||||||
|
self::assertSame(['https://example.com/welcome/'], $this->registrar->redirects);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNoRedirectWhenTheRegistrationIsNotFinished(): void
|
||||||
|
{
|
||||||
|
// e.g. the "check your email" step, or a validation error — the
|
||||||
|
// message has to be read, so the block never redirects past it.
|
||||||
|
$this->stubSingularRequest(
|
||||||
|
30,
|
||||||
|
[
|
||||||
|
[
|
||||||
|
'blockName' => 'us-scheduler/student-register',
|
||||||
|
'attrs' => ['autoRedirect' => true, 'loginPageId' => 4],
|
||||||
|
'innerBlocks' => [],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->registrationPage->shouldReceive('continueUrl')->never();
|
||||||
|
|
||||||
|
$this->registrar->maybeAutoRedirect();
|
||||||
|
|
||||||
|
self::assertSame([], $this->registrar->redirects);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNoRedirectWhenTheRegisterBlockDoesNotOptIn(): void
|
||||||
|
{
|
||||||
|
$this->stubSingularRequest(
|
||||||
|
30,
|
||||||
|
[
|
||||||
|
[
|
||||||
|
'blockName' => 'us-scheduler/student-register',
|
||||||
|
'attrs' => ['loginPageId' => 4],
|
||||||
|
'innerBlocks' => [],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->registrationPage->shouldReceive('isRegistrationComplete')->andReturn(true);
|
||||||
|
$this->registrationPage->shouldReceive('continueUrl')->never();
|
||||||
|
|
||||||
|
$this->registrar->maybeAutoRedirect();
|
||||||
|
|
||||||
|
self::assertSame([], $this->registrar->redirects);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNoRedirectWhenTheRegisterBlockHasNoPageChosen(): void
|
||||||
|
{
|
||||||
|
$this->stubSingularRequest(
|
||||||
|
30,
|
||||||
|
[
|
||||||
|
[
|
||||||
|
'blockName' => 'us-scheduler/student-register',
|
||||||
|
'attrs' => ['autoRedirect' => true],
|
||||||
|
'innerBlocks' => [],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->registrationPage->shouldReceive('isRegistrationComplete')->andReturn(true);
|
||||||
|
// No page chosen: there is no login-screen fallback to redirect to,
|
||||||
|
// so the student keeps the on-page confirmation instead.
|
||||||
|
$this->registrationPage->shouldReceive('continueUrl')->once()->with(0)->andReturnNull();
|
||||||
|
|
||||||
|
$this->registrar->maybeAutoRedirect();
|
||||||
|
|
||||||
|
self::assertSame([], $this->registrar->redirects);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNoRedirectWhenTheRegisterBlockPointsAtItsOwnPage(): void
|
||||||
|
{
|
||||||
|
$this->stubSingularRequest(
|
||||||
|
4,
|
||||||
|
[
|
||||||
|
[
|
||||||
|
'blockName' => 'us-scheduler/student-register',
|
||||||
|
'attrs' => ['autoRedirect' => true, 'loginPageId' => 4],
|
||||||
|
'innerBlocks' => [],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->registrationPage->shouldReceive('isRegistrationComplete')->andReturn(true);
|
||||||
|
$this->registrationPage->shouldReceive('continueUrl')->never();
|
||||||
|
|
||||||
|
$this->registrar->maybeAutoRedirect();
|
||||||
|
|
||||||
|
self::assertSame([], $this->registrar->redirects);
|
||||||
|
}
|
||||||
|
|
||||||
public function testNoRedirectOutsideSingularFrontEndRequests(): void
|
public function testNoRedirectOutsideSingularFrontEndRequests(): void
|
||||||
{
|
{
|
||||||
Functions\when('is_admin')->justReturn(false);
|
Functions\when('is_admin')->justReturn(false);
|
||||||
|
|||||||
@@ -17,6 +17,26 @@ class BookingPageTest extends TestCase
|
|||||||
$this->page = new BookingPage();
|
$this->page = new BookingPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the page as a logged-in, approved student — the path that
|
||||||
|
* includes the template and its data attributes.
|
||||||
|
*
|
||||||
|
* @param array<int|string, mixed> $atts
|
||||||
|
*/
|
||||||
|
private function renderForStudent(array $atts): string
|
||||||
|
{
|
||||||
|
Functions\when('is_user_logged_in')->justReturn(true);
|
||||||
|
Functions\when('get_current_user_id')->justReturn(3);
|
||||||
|
Functions\when('get_user_meta')->justReturn('');
|
||||||
|
Functions\when('current_user_can')->justReturn(true);
|
||||||
|
Functions\when('wp_enqueue_style')->justReturn(null);
|
||||||
|
Functions\when('wp_enqueue_script')->justReturn(null);
|
||||||
|
Functions\when('wp_create_nonce')->justReturn('nonce123');
|
||||||
|
Functions\when('absint')->alias(static fn ($value) => abs((int) $value));
|
||||||
|
|
||||||
|
return $this->page->render($atts);
|
||||||
|
}
|
||||||
|
|
||||||
public function testLoggedOutVisitorIsLinkedToTheWordPressLoginByDefault(): void
|
public function testLoggedOutVisitorIsLinkedToTheWordPressLoginByDefault(): void
|
||||||
{
|
{
|
||||||
Functions\when('is_user_logged_in')->justReturn(false);
|
Functions\when('is_user_logged_in')->justReturn(false);
|
||||||
@@ -59,6 +79,85 @@ class BookingPageTest extends TestCase
|
|||||||
self::assertStringContainsString('href="https://example.com/login/"', $html);
|
self::assertStringContainsString('href="https://example.com/login/"', $html);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testDefaultEmbedShowsBothHalvesWithTheFilterAndNoPinnedType(): void
|
||||||
|
{
|
||||||
|
$html = $this->renderForStudent([]);
|
||||||
|
|
||||||
|
self::assertStringContainsString('id="us-booking-app"', $html);
|
||||||
|
self::assertStringContainsString('id="us-my-lessons"', $html);
|
||||||
|
self::assertStringContainsString('id="us-slot-list"', $html);
|
||||||
|
self::assertStringNotContainsString('data-lesson-type', $html);
|
||||||
|
self::assertStringNotContainsString('data-type-filter', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBlockLessonTypeAttributePinsASingleType(): void
|
||||||
|
{
|
||||||
|
self::assertStringContainsString(
|
||||||
|
'data-lesson-type="12"',
|
||||||
|
$this->renderForStudent(['lessonTypeId' => 12])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testShortcodeLessonTypeAttributePinsASingleType(): void
|
||||||
|
{
|
||||||
|
self::assertStringContainsString(
|
||||||
|
'data-lesson-type="7"',
|
||||||
|
$this->renderForStudent(['lesson_type' => '7'])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGarbageLessonTypeAttributeIsIgnored(): void
|
||||||
|
{
|
||||||
|
self::assertStringNotContainsString(
|
||||||
|
'data-lesson-type',
|
||||||
|
$this->renderForStudent(['lesson_type' => 'banana'])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFilterCanBeTurnedOffByBlockAndShortcodeAlike(): void
|
||||||
|
{
|
||||||
|
self::assertStringContainsString(
|
||||||
|
'data-type-filter="0"',
|
||||||
|
$this->renderForStudent(['showTypeFilter' => false])
|
||||||
|
);
|
||||||
|
|
||||||
|
// "no" is truthy to PHP, so the shortcode wording is matched explicitly.
|
||||||
|
self::assertStringContainsString(
|
||||||
|
'data-type-filter="0"',
|
||||||
|
$this->renderForStudent(['show_filter' => 'no'])
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertStringNotContainsString(
|
||||||
|
'data-type-filter',
|
||||||
|
$this->renderForStudent(['show_filter' => 'yes'])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBookingOnlyEmbedLeavesOutTheUpcomingLessons(): void
|
||||||
|
{
|
||||||
|
$html = $this->renderForStudent(['displayMode' => 'booking']);
|
||||||
|
|
||||||
|
self::assertStringContainsString('id="us-slot-list"', $html);
|
||||||
|
self::assertStringNotContainsString('us-my-lessons', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUpcomingOnlyEmbedLeavesOutTheBookingCalendar(): void
|
||||||
|
{
|
||||||
|
$html = $this->renderForStudent(['show' => 'upcoming']);
|
||||||
|
|
||||||
|
self::assertStringContainsString('id="us-my-lessons"', $html);
|
||||||
|
self::assertStringNotContainsString('us-slot-list', $html);
|
||||||
|
self::assertStringNotContainsString('us-booking-confirmation', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUnknownDisplayModeShowsTheWholePage(): void
|
||||||
|
{
|
||||||
|
$html = $this->renderForStudent(['displayMode' => 'sideways']);
|
||||||
|
|
||||||
|
self::assertStringContainsString('id="us-my-lessons"', $html);
|
||||||
|
self::assertStringContainsString('id="us-slot-list"', $html);
|
||||||
|
}
|
||||||
|
|
||||||
public function testLoginUrlFallsBackToWordPressLoginWhenThePageIsGone(): void
|
public function testLoginUrlFallsBackToWordPressLoginWhenThePageIsGone(): void
|
||||||
{
|
{
|
||||||
// The chosen page was deleted: get_permalink() returns false for it
|
// The chosen page was deleted: get_permalink() returns false for it
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ class OfferingControllerTest extends TestCase
|
|||||||
Functions\when('current_user_can')->justReturn(true);
|
Functions\when('current_user_can')->justReturn(true);
|
||||||
Functions\when('get_current_user_id')->justReturn(3);
|
Functions\when('get_current_user_id')->justReturn(3);
|
||||||
Functions\when('get_users')->justReturn([]);
|
Functions\when('get_users')->justReturn([]);
|
||||||
|
// Default single-account setup: admins act as instructors.
|
||||||
|
Functions\when('get_option')->justReturn('1');
|
||||||
Functions\when('check_admin_referer')->justReturn(true);
|
Functions\when('check_admin_referer')->justReturn(true);
|
||||||
Functions\when('admin_url')->justReturn('admin.php?page=us-offerings');
|
Functions\when('admin_url')->justReturn('admin.php?page=us-offerings');
|
||||||
Functions\when('add_query_arg')->alias(
|
Functions\when('add_query_arg')->alias(
|
||||||
@@ -450,6 +452,51 @@ class OfferingControllerTest extends TestCase
|
|||||||
self::assertStringNotContainsString('Edit Offering', $html);
|
self::assertStringNotContainsString('Edit Offering', $html);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testInstructorPickerIncludesAdministratorsWhenTheyActAsInstructors(): void
|
||||||
|
{
|
||||||
|
// The reported bug: a solo studio owner runs the business from a WordPress
|
||||||
|
// administrator account and teaches through the dynamic capability grant,
|
||||||
|
// so they never hold the us_instructor role. The picker must still list
|
||||||
|
// them, otherwise there is no one to assign a class to.
|
||||||
|
Functions\when('get_option')->justReturn('1');
|
||||||
|
|
||||||
|
$admin = Mockery::mock(\WP_User::class);
|
||||||
|
$admin->ID = 3;
|
||||||
|
$admin->display_name = 'Studio Owner';
|
||||||
|
|
||||||
|
$queriedRoles = [];
|
||||||
|
Functions\when('get_users')->alias(static function (array $args) use (&$queriedRoles, $admin): array {
|
||||||
|
$queriedRoles = $args['role__in'];
|
||||||
|
return [$admin];
|
||||||
|
});
|
||||||
|
$this->repository->shouldReceive('findAll')->andReturn([]);
|
||||||
|
|
||||||
|
$html = $this->render();
|
||||||
|
|
||||||
|
self::assertContains('us_instructor', $queriedRoles);
|
||||||
|
self::assertContains('administrator', $queriedRoles);
|
||||||
|
self::assertStringContainsString('Studio Owner', $html);
|
||||||
|
self::assertStringContainsString('<option value="3"', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testInstructorPickerExcludesAdministratorsWhenGrantDisabled(): void
|
||||||
|
{
|
||||||
|
// With the "admins are instructors" toggle off, an admin is not a teacher,
|
||||||
|
// so only the explicit us_instructor role is queried.
|
||||||
|
Functions\when('get_option')->justReturn('0');
|
||||||
|
|
||||||
|
$queriedRoles = null;
|
||||||
|
Functions\when('get_users')->alias(static function (array $args) use (&$queriedRoles): array {
|
||||||
|
$queriedRoles = $args['role__in'];
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
$this->repository->shouldReceive('findAll')->andReturn([]);
|
||||||
|
|
||||||
|
$this->render();
|
||||||
|
|
||||||
|
self::assertSame(['us_instructor'], $queriedRoles);
|
||||||
|
}
|
||||||
|
|
||||||
private function render(): string
|
private function render(): string
|
||||||
{
|
{
|
||||||
ob_start();
|
ob_start();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace Unsupervised\Schedular\Tests\Unit\Offering;
|
|||||||
|
|
||||||
use Brain\Monkey\Functions;
|
use Brain\Monkey\Functions;
|
||||||
use Mockery;
|
use Mockery;
|
||||||
|
use Unsupervised\Schedular\Auth\RoleManager;
|
||||||
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
|
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
|
||||||
use Unsupervised\Schedular\Offering\Offering;
|
use Unsupervised\Schedular\Offering\Offering;
|
||||||
use Unsupervised\Schedular\Offering\OfferingEndpoint;
|
use Unsupervised\Schedular\Offering\OfferingEndpoint;
|
||||||
@@ -118,4 +119,73 @@ class OfferingEndpointTest extends TestCase
|
|||||||
|
|
||||||
self::assertArrayNotHasKey('etransfer_email', $data[0]);
|
self::assertArrayNotHasKey('etransfer_email', $data[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testCanReadAllowsStudentsWhoMayBook(): void
|
||||||
|
{
|
||||||
|
Functions\when('is_user_logged_in')->justReturn(true);
|
||||||
|
Functions\when('current_user_can')->alias(
|
||||||
|
static fn (string $cap): bool => RoleManager::CAP_BOOK_LESSON === $cap
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertTrue($this->endpoint->canRead());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCanReadAllowsOfferingManagersWhoCannotBook(): void
|
||||||
|
{
|
||||||
|
Functions\when('is_user_logged_in')->justReturn(true);
|
||||||
|
Functions\when('current_user_can')->alias(
|
||||||
|
static fn (string $cap): bool => RoleManager::CAP_MANAGE_OFFERINGS === $cap
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertTrue($this->endpoint->canRead());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCanReadRejectsLoggedInUserWithNeitherCapability(): void
|
||||||
|
{
|
||||||
|
Functions\when('is_user_logged_in')->justReturn(true);
|
||||||
|
Functions\when('current_user_can')->justReturn(false);
|
||||||
|
|
||||||
|
self::assertFalse($this->endpoint->canRead());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCanReadRejectsLoggedOutVisitors(): void
|
||||||
|
{
|
||||||
|
Functions\when('is_user_logged_in')->justReturn(false);
|
||||||
|
Functions\when('current_user_can')->justReturn(true);
|
||||||
|
|
||||||
|
self::assertFalse($this->endpoint->canRead());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateRejectsTitleLongerThanColumnLimit(): void
|
||||||
|
{
|
||||||
|
Functions\when('sanitize_text_field')->returnArg();
|
||||||
|
Functions\when('sanitize_email')->returnArg();
|
||||||
|
$this->repository->shouldNotReceive('insert');
|
||||||
|
|
||||||
|
$request = new \WP_REST_Request([
|
||||||
|
'kind' => Offering::KIND_GROUP_CLASS,
|
||||||
|
'title' => str_repeat('a', Offering::MAX_TITLE_LENGTH + 1),
|
||||||
|
]);
|
||||||
|
$response = $this->endpoint->create($request);
|
||||||
|
|
||||||
|
self::assertInstanceOf(\WP_Error::class, $response);
|
||||||
|
self::assertSame(400, $response->error_data['invalid_offering']['status']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateRejectsScheduleNoteLongerThanColumnLimit(): void
|
||||||
|
{
|
||||||
|
Functions\when('sanitize_text_field')->returnArg();
|
||||||
|
Functions\when('sanitize_email')->returnArg();
|
||||||
|
$this->repository->shouldNotReceive('insert');
|
||||||
|
|
||||||
|
$request = new \WP_REST_Request([
|
||||||
|
'kind' => Offering::KIND_GROUP_CLASS,
|
||||||
|
'title' => 'Choir',
|
||||||
|
'schedule_note' => str_repeat('a', Offering::MAX_SCHEDULE_NOTE_LENGTH + 1),
|
||||||
|
]);
|
||||||
|
$response = $this->endpoint->create($request);
|
||||||
|
|
||||||
|
self::assertInstanceOf(\WP_Error::class, $response);
|
||||||
|
self::assertSame(400, $response->error_data['invalid_offering']['status']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -187,7 +187,12 @@ class ScheduledBillingRunnerTest extends TestCase
|
|||||||
$this->runner->run();
|
$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');
|
$this->now('2026-07-15 09:00:00');
|
||||||
$enrollment = new Enrollment(offeringId: 9, studentId: 5, instructorId: 3, id: 44);
|
$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);
|
$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')
|
$this->payments->shouldReceive('createForRegistration')
|
||||||
->once()
|
->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'));
|
->andReturn($this->pending(800, '2026-07-01'));
|
||||||
|
|
||||||
$this->runner->run();
|
$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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<?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\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;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
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,
|
||||||
|
$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');
|
||||||
|
|
||||||
|
$request = new \WP_REST_Request([
|
||||||
|
'title' => str_repeat('a', Policy::MAX_TITLE_LENGTH + 1),
|
||||||
|
]);
|
||||||
|
$response = $this->endpoint->create($request);
|
||||||
|
|
||||||
|
self::assertInstanceOf(\WP_Error::class, $response);
|
||||||
|
self::assertSame(400, $response->error_data['invalid_policy']['status']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateRejectsSlugLongerThanColumnLimit(): void
|
||||||
|
{
|
||||||
|
$this->service->shouldNotReceive('createPolicy');
|
||||||
|
|
||||||
|
$request = new \WP_REST_Request([
|
||||||
|
'title' => 'Cancellation',
|
||||||
|
'slug' => str_repeat('a', Policy::MAX_SLUG_LENGTH + 1),
|
||||||
|
]);
|
||||||
|
$response = $this->endpoint->create($request);
|
||||||
|
|
||||||
|
self::assertInstanceOf(\WP_Error::class, $response);
|
||||||
|
self::assertSame(400, $response->error_data['invalid_policy']['status']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,16 @@ class PolicyValueObjectsTest extends TestCase
|
|||||||
self::assertArrayHasKey('acceptance_scope', $policy->toArray());
|
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
|
public function testPolicyHandlesNullCurrentVersion(): void
|
||||||
{
|
{
|
||||||
$policy = Policy::fromRow((object) [
|
$policy = Policy::fromRow((object) [
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Unsupervised\Schedular\Tests\Unit\Registration;
|
||||||
|
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
use Mockery;
|
||||||
|
use Unsupervised\Schedular\Offering\Offering;
|
||||||
|
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||||
|
use Unsupervised\Schedular\Registration\Question;
|
||||||
|
use Unsupervised\Schedular\Registration\QuestionEndpoint;
|
||||||
|
use Unsupervised\Schedular\Registration\QuestionRepository;
|
||||||
|
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||||
|
|
||||||
|
class QuestionEndpointTest extends TestCase
|
||||||
|
{
|
||||||
|
private QuestionRepository&Mockery\MockInterface $questions;
|
||||||
|
private OfferingRepository&Mockery\MockInterface $offerings;
|
||||||
|
private QuestionEndpoint $endpoint;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
Functions\when('get_current_user_id')->justReturn(5);
|
||||||
|
Functions\when('current_user_can')->justReturn(false);
|
||||||
|
Functions\when('absint')->alias(static fn ($v): int => abs((int) $v));
|
||||||
|
Functions\when('sanitize_text_field')->returnArg();
|
||||||
|
|
||||||
|
$this->questions = Mockery::mock(QuestionRepository::class);
|
||||||
|
$this->offerings = Mockery::mock(OfferingRepository::class);
|
||||||
|
$this->endpoint = new QuestionEndpoint($this->questions, $this->offerings);
|
||||||
|
|
||||||
|
// The caller (instructor 5) owns offering 9, so the ownership gate passes
|
||||||
|
// and validation is reached.
|
||||||
|
$this->offerings->shouldReceive('findById')->with(9)->andReturn(
|
||||||
|
new Offering(instructorId: 5, kind: Offering::KIND_GROUP_CLASS, title: 'Choir', id: 9)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateRejectsLabelLongerThanColumnLimit(): void
|
||||||
|
{
|
||||||
|
// The insert must never be attempted for an over-long label — the bug was
|
||||||
|
// that it reached the DB, silently failed, and returned success anyway.
|
||||||
|
$this->questions->shouldNotReceive('insert');
|
||||||
|
|
||||||
|
$request = new \WP_REST_Request([
|
||||||
|
'offering_id' => 9,
|
||||||
|
'label' => str_repeat('a', Question::MAX_LABEL_LENGTH + 1),
|
||||||
|
]);
|
||||||
|
$response = $this->endpoint->create($request);
|
||||||
|
|
||||||
|
self::assertInstanceOf(\WP_Error::class, $response);
|
||||||
|
self::assertSame(400, $response->error_data['invalid_question']['status']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateAcceptsLabelAtColumnLimit(): void
|
||||||
|
{
|
||||||
|
$this->questions->shouldReceive('insert')->once()->andReturn(42);
|
||||||
|
|
||||||
|
$request = new \WP_REST_Request([
|
||||||
|
'offering_id' => 9,
|
||||||
|
'label' => str_repeat('a', Question::MAX_LABEL_LENGTH),
|
||||||
|
]);
|
||||||
|
$response = $this->endpoint->create($request);
|
||||||
|
|
||||||
|
self::assertInstanceOf(\WP_REST_Response::class, $response);
|
||||||
|
self::assertSame(201, $response->get_status());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,9 @@ class ShortcodeRegistrarTest extends TestCase
|
|||||||
/** @var array<string, callable> */
|
/** @var array<string, callable> */
|
||||||
private array $shortcodes = [];
|
private array $shortcodes = [];
|
||||||
|
|
||||||
|
/** @var array<string, mixed> */
|
||||||
|
private array $localized = [];
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
parent::setUp();
|
parent::setUp();
|
||||||
@@ -84,6 +87,69 @@ class ShortcodeRegistrarTest extends TestCase
|
|||||||
self::assertSame('group', $this->shortcodes['us_group_classes'](''));
|
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
|
public function testShortcodeAttributesArePassedThroughUnchanged(): void
|
||||||
{
|
{
|
||||||
$this->registrar->register();
|
$this->registrar->register();
|
||||||
|
|||||||
@@ -24,4 +24,17 @@ abstract class TestCase extends BaseTestCase
|
|||||||
Monkey\tearDown();
|
Monkey\tearDown();
|
||||||
parent::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 Name: Unsupervised Scheduler
|
||||||
* Plugin URI: https://git.unsupervised.ca/Unsupervised/unsupervised-scheduler
|
* Plugin URI: https://git.unsupervised.ca/Unsupervised/unsupervised-scheduler
|
||||||
* Description: Instructor/student lesson scheduling for WordPress.
|
* Description: Instructor/student lesson scheduling for WordPress.
|
||||||
* Version: 1.2.0
|
* Version: 1.2.3
|
||||||
* Requires at least: 6.2
|
* Requires at least: 6.2
|
||||||
* Requires PHP: 8.1
|
* Requires PHP: 8.1
|
||||||
* Author: Unsupervised
|
* Author: Unsupervised
|
||||||
@@ -21,7 +21,7 @@ if (! defined('ABSPATH')) {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
define('USC_VERSION', '1.2.0');
|
define('USC_VERSION', '1.2.3');
|
||||||
define('USC_PLUGIN_FILE', __FILE__);
|
define('USC_PLUGIN_FILE', __FILE__);
|
||||||
define('USC_PLUGIN_DIR', plugin_dir_path(__FILE__));
|
define('USC_PLUGIN_DIR', plugin_dir_path(__FILE__));
|
||||||
define('USC_PLUGIN_URL', plugin_dir_url(__FILE__));
|
define('USC_PLUGIN_URL', plugin_dir_url(__FILE__));
|
||||||
|
|||||||
Reference in New Issue
Block a user