Compare commits
14
Commits
v1.2.0
..
689ec833f3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
689ec833f3 | ||
|
|
0f30f28e92
|
||
|
|
add6605141 | ||
|
|
edcacae816
|
||
|
|
17487cde46 | ||
|
|
13d6b3e14e
|
||
|
|
d866aa7295 | ||
|
|
c4acdb7ca4
|
||
|
|
30928addf8 | ||
|
|
27793fa0aa | ||
|
|
c611268bdb | ||
|
|
721c4be1d6
|
||
|
|
3aa65bad06 | ||
|
|
f3ba09b195 |
@@ -11,6 +11,20 @@ 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.2]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- The booking calendar now has a **lesson type** filter above it, 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. 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
|
||||||
|
|||||||
@@ -117,6 +117,36 @@
|
|||||||
color: #8a6d1a;
|
color: #8a6d1a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.us-type-filter {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #eee;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.us-type-filter-heading {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
|||||||
+10
-3
@@ -151,18 +151,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 +199,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 }),
|
||||||
})
|
})
|
||||||
|
|||||||
+147
-41
@@ -81,6 +81,43 @@
|
|||||||
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.
|
||||||
|
const selectedTypeIds = new Set();
|
||||||
|
|
||||||
|
// 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())}`;
|
||||||
|
|
||||||
@@ -106,9 +143,40 @@
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "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}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lesson-type filter. Pointless with a single bookable type, so it is only
|
||||||
|
// rendered once there is a choice to make.
|
||||||
|
function filterHtml() {
|
||||||
|
if (catalog.length < 2) 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" role="group" aria-label="Filter by lesson type">
|
||||||
|
<span class="us-type-filter-heading">Lesson type</span>
|
||||||
|
${choices}
|
||||||
|
${filterActive() ? '<button type="button" id="us-type-filter-clear" class="us-type-filter-clear">Show all types</button>' : ''}
|
||||||
|
</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 +190,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,19 +219,51 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
if (!allSlots.length) {
|
const slots = visibleSlots();
|
||||||
slotList.innerHTML = '<p>No available lesson slots at this time.</p>';
|
|
||||||
|
if (!slots.length) {
|
||||||
|
slotList.innerHTML = filterHtml() + (allSlots.length
|
||||||
|
? '<p>No open times match the selected lesson types.</p>'
|
||||||
|
: '<p>No available lesson slots at this time.</p>');
|
||||||
|
wireFilterEvents();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Anchor the week view to the week of the earliest open slot (the API
|
// Anchor the week view to the week of the earliest matching slot (the
|
||||||
// returns slots ordered by start), so the first look is never empty.
|
// API returns slots ordered by start), so the first look is never empty.
|
||||||
if (view === 'week' && !weekStart) weekStart = weekStartOf(dayKey(allSlots[0].start_dt));
|
if (view === 'week' && !weekStart) weekStart = weekStartOf(dayKey(slots[0].start_dt));
|
||||||
|
|
||||||
slotList.innerHTML = toggleHtml() + (view === 'week' ? weekHtml() : listHtml());
|
slotList.innerHTML = filterHtml() + toggleHtml() + (view === 'week' ? weekHtml(slots) : listHtml(slots));
|
||||||
|
wireFilterEvents();
|
||||||
wireCalendarEvents();
|
wireCalendarEvents();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function wireFilterEvents() {
|
||||||
|
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() {
|
function wireCalendarEvents() {
|
||||||
document.getElementById('us-view-list').addEventListener('click', () => {
|
document.getElementById('us-view-list').addEventListener('click', () => {
|
||||||
view = 'list';
|
view = 'list';
|
||||||
@@ -211,20 +311,6 @@
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Active private-lesson offerings per instructor, so revisiting the
|
|
||||||
// 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)"
|
// "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 — ` : '';
|
||||||
@@ -237,13 +323,8 @@
|
|||||||
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 +339,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 +362,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
|
||||||
@@ -309,8 +402,10 @@
|
|||||||
</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');
|
||||||
@@ -467,13 +562,24 @@
|
|||||||
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.
|
||||||
|
function loadCatalog() {
|
||||||
|
if (catalog.length) return Promise.resolve(catalog);
|
||||||
|
return apiFetch('offerings?kind=private_lesson').then((list) => {
|
||||||
|
catalog = list;
|
||||||
|
return catalog;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function loadSlots() {
|
function loadSlots() {
|
||||||
clearError();
|
clearError();
|
||||||
slotList.style.display = 'block';
|
slotList.style.display = 'block';
|
||||||
confirm.style.display = 'none';
|
confirm.style.display = 'none';
|
||||||
loadMyLessons();
|
loadMyLessons();
|
||||||
apiFetch('availability')
|
Promise.all([apiFetch('availability'), loadCatalog()])
|
||||||
.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,7 +151,7 @@
|
|||||||
${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>${escHtml(Number(o.price).toFixed(2))} ${escHtml(o.currency)}</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>`
|
||||||
|
|||||||
@@ -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 your account" link an invited student sees on the spot (`?us_registered=invite`; no link at all with no page chosen, since an already-signed-in student has no use for the login screen).
|
||||||
|
- **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 lesson-type filter — see `lesson-booking.md`.
|
||||||
|
|
||||||
## REST API
|
## REST API
|
||||||
| Method | Endpoint | Permission |
|
| Method | Endpoint | Permission |
|
||||||
|
|||||||
@@ -29,8 +29,9 @@ Four blocks have sidebar (inspector) options:
|
|||||||
| `us-scheduler/booking` | `autoRedirect` (boolean) | `false` | Send logged-out visitors straight to the login page instead of showing the link. |
|
| `us-scheduler/booking` | `autoRedirect` (boolean) | `false` | Send logged-out visitors straight to the login page instead of showing the link. |
|
||||||
| `us-scheduler/student-login` | `bookingPageId` (number) | `0` | Page the "View available lessons" link points to for logged-in visitors, and the post-login redirect target. `0` = the current page. |
|
| `us-scheduler/student-login` | `bookingPageId` (number) | `0` | Page the "View available lessons" link points to for logged-in visitors, and the post-login redirect target. `0` = the current page. |
|
||||||
| `us-scheduler/student-login` | `autoRedirect` (boolean) | `false` | Send logged-in visitors straight to the booking page instead of showing the link. Does nothing until a booking page is chosen. |
|
| `us-scheduler/student-login` | `autoRedirect` (boolean) | `false` | Send logged-in visitors straight to the booking page instead of showing the link. Does nothing until a booking page is chosen. |
|
||||||
| `us-scheduler/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
|
||||||
@@ -48,6 +49,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 +88,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.
|
||||||
|
|||||||
@@ -176,7 +176,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,8 +20,8 @@ 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 **lesson-type filter** above the calendar 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.
|
||||||
@@ -31,6 +31,28 @@ Students register for a private lesson by choosing an offering, picking a time (
|
|||||||
9. Instructor sees the booking under **My Lessons** and may update status via `PATCH /bookings/{id}/status`.
|
9. Instructor sees the booking under **My Lessons** and may update status via `PATCH /bookings/{id}/status`.
|
||||||
10. The booking page also shows the student their upcoming lessons (`GET /bookings`) — each with the booked offering's name and length, when it happens, a per-lesson status badge (pending payment / confirmed), and a **Cancel** button. Only the soonest five are shown; a **Show all** control reveals the rest. `GET /bookings` includes `offering_title` and `duration_minutes` for each lesson so the list needs no extra request.
|
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.
|
||||||
|
|
||||||
|
## 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 above
|
||||||
|
the view toggle: 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 filter is 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. 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.
|
||||||
|
|
||||||
## Cancellation
|
## Cancellation
|
||||||
Students cancel their own lessons via `POST /bookings/{id}/cancel` (idempotent).
|
Students cancel their own lessons via `POST /bookings/{id}/cancel` (idempotent).
|
||||||
Cancelling marks the lesson `cancelled`, frees the availability slot for
|
Cancelling marks the lesson `cancelled`, frees the availability slot for
|
||||||
|
|||||||
@@ -107,7 +107,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 |
|
||||||
|
|||||||
@@ -66,9 +66,20 @@ class RegistrationPage {
|
|||||||
|
|
||||||
if ( is_user_logged_in() ) {
|
if ( is_user_logged_in() ) {
|
||||||
if ( self::RESULT_INVITE === $registered ) {
|
if ( self::RESULT_INVITE === $registered ) {
|
||||||
|
// An invited student is done the moment they land here logged in,
|
||||||
|
// so this is where their "continue" link belongs. The sign-in-page
|
||||||
|
// fallback is deliberately not used: pointing someone who is
|
||||||
|
// already signed in at the login screen helps nobody.
|
||||||
|
$continue = $this->continueUrl( $this->successPageId( $atts ) );
|
||||||
|
$link = null === $continue
|
||||||
|
? ''
|
||||||
|
: '<p><a href="' . esc_url( $continue ) . '">'
|
||||||
|
. esc_html__( 'Continue to your account', 'unsupervised-schedular' )
|
||||||
|
. '</a></p>';
|
||||||
|
|
||||||
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 '<p>' . esc_html__( 'You already have an account and are logged in.', 'unsupervised-schedular' ) . '</p>';
|
||||||
@@ -98,7 +109,7 @@ 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 );
|
||||||
@@ -319,21 +330,68 @@ 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 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
return wp_login_url();
|
$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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
-4
@@ -58,13 +58,29 @@ class BlockPreview {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function groupClasses(): string {
|
/**
|
||||||
|
* 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(
|
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 id="us-group-app">%s<div id="us-group-list"><div class="us-class"><h3>%s</h3><p class="us-class-when">%s</p>%s<p>25.00 CAD</p><p class="us-enrol-deadline">%s</p><button type="button" class="us-enrol-btn" disabled>%s</button></div></div></div>',
|
||||||
self::note( __( 'Editor preview — students see live group classes on the published page.', 'unsupervised-schedular' ) ),
|
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,
|
||||||
|
esc_html__( 'Enrol by Sep 6, 2026', 'unsupervised-schedular' ),
|
||||||
esc_html__( 'Enrol', 'unsupervised-schedular' )
|
esc_html__( 'Enrol', 'unsupervised-schedular' )
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+54
-5
@@ -109,6 +109,7 @@ class BlockRegistrar {
|
|||||||
'type' => 'number',
|
'type' => 'number',
|
||||||
'default' => 0,
|
'default' => 0,
|
||||||
],
|
],
|
||||||
|
'autoRedirect' => $redirectToggle,
|
||||||
'inviteOnlyMessage' => [
|
'inviteOnlyMessage' => [
|
||||||
'type' => 'string',
|
'type' => 'string',
|
||||||
'default' => '',
|
'default' => '',
|
||||||
@@ -160,16 +161,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 +187,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 +223,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
|
||||||
|
|||||||
@@ -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 ) );
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -47,7 +47,9 @@ 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 );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 ] );
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -24,12 +24,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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -484,6 +484,54 @@ 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/');
|
||||||
|
|
||||||
|
$html = $this->ctx['page']->render([ 'loginPageId' => 4 ]);
|
||||||
|
|
||||||
|
self::assertStringContainsString('now logged in', $html);
|
||||||
|
self::assertStringContainsString('href="http://home.test/welcome/"', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,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,
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<?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\PolicyVersionRepository;
|
||||||
|
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||||
|
|
||||||
|
class PolicyEndpointTest extends TestCase
|
||||||
|
{
|
||||||
|
private PolicyRepository&Mockery\MockInterface $policies;
|
||||||
|
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();
|
||||||
|
|
||||||
|
$this->policies = Mockery::mock(PolicyRepository::class);
|
||||||
|
$this->service = Mockery::mock(PolicyService::class);
|
||||||
|
$this->endpoint = new PolicyEndpoint(
|
||||||
|
$this->policies,
|
||||||
|
Mockery::mock(PolicyVersionRepository::class),
|
||||||
|
$this->service,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.2
|
||||||
* 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.2');
|
||||||
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