Compare commits
4
Commits
7181a80537
...
699e479805
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
699e479805
|
||
|
|
1b42d20541 | ||
|
|
ab5212282d
|
||
|
|
6e3affb1cb
|
@@ -13,6 +13,9 @@ each change under the current top section as you work.
|
||||
|
||||
## [1.3.1]
|
||||
|
||||
### Added
|
||||
- An **Account** block (`[us_account]`) showing who is signed in — their name and their email — and a **Sign out** link. Signing out returns to the login page chosen in the block, or to the page the visitor was already on when none is set, so putting it in a site header does not also move people somewhere. To a signed-out visitor it shows a **Sign in** link when a login page is chosen, and nothing at all when one is not: a panel about who is signed in has nothing to tell a stranger, and a notice they cannot act on is just clutter in a header.
|
||||
|
||||
### Security
|
||||
- Signup now checks the password properly. The form scores it as you type with the same zxcvbn meter wp-admin uses and will not submit a weak one, and the server refuses — regardless of what the browser allowed — anything shorter than 8 characters, one of the well-known leaked passwords, one built from barely any distinct characters, or one containing your own name or email address. Composition rules ("must contain a symbol") are deliberately not imposed: they mostly produce predictable substitutions. Email addresses are validated on the server on every signup path, with a clear message when one is already registered.
|
||||
|
||||
@@ -22,6 +25,7 @@ each change under the current top section as you work.
|
||||
- The interface now says **student** where it said "child" and **profile** where it said "family". The `[us_family]` page is headed **Your profile**, its form is **Add a student**, signup asks for a **Student's name**, and the wp-admin students list and student screen both label the relationship **Profile**. Two strings were reworded rather than swapped: the students list reads **Managed by _name_** (a bare "Student of _name_" would read as a teacher's pupil), and a managed account is described as a **managed student account** so it is not confused with the account holder. Internal names — database columns, request parameters, form field names, the `us_family` shortcode and the `us-scheduler/family` block — are unchanged, since they are contracts with existing installs and saved post content.
|
||||
|
||||
### Fixed
|
||||
- **Booking a lesson no longer dead-ends on the confirmation.** The confirmation used to replace the calendar entirely, leaving a student who wanted a second lesson with nothing to click and no way back short of reloading the page. It is now a dismissible notice sitting above a freshly loaded calendar — the slot just taken already gone from it, the upcoming-lessons panel already updated — so "it worked" and "book another" are the same screen. Enrolling in a group class did the same thing and is fixed the same way.
|
||||
- Upcoming lesson rows no longer render on top of each other. The row's text sits in inline elements that a theme can pull out of normal flow, which dropped the date and time onto the lesson title and the status pill onto the Cancel button; those elements are now pinned into flow alongside the rest of the panel's theme-proofing. The rows held behind **Show all** also stayed visible under the `div { display: block }` reset that many themes still carry, since `[hidden]` is only a browser default — they are now hidden for real.
|
||||
|
||||
## [1.3.0]
|
||||
|
||||
@@ -549,6 +549,72 @@
|
||||
color: #1a7d2e;
|
||||
}
|
||||
|
||||
/*
|
||||
* The account panel: who is signed in, and the way out. Sized to sit in a
|
||||
* header or sidebar, so the rules stay minimal and inherit the theme's type —
|
||||
* a block that lands in a site header should look like it belongs there.
|
||||
*/
|
||||
.us-account p {
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.us-account-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.us-account-email {
|
||||
display: block;
|
||||
font-size: 0.9em;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.us-account-actions {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/*
|
||||
* `[hidden]` is a UA-stylesheet rule, so the widespread `div { display: block }`
|
||||
* theme reset outranks it — the same trap the upcoming-lessons panel hit. An
|
||||
* author !important is the only way to win, and it has to sit before the
|
||||
* display rule it guards against.
|
||||
*/
|
||||
.us-notice[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/*
|
||||
* The "you're booked" / "you're enrolled" notice. It sits above the calendar
|
||||
* or class list rather than replacing it, so it needs to read as a banner
|
||||
* about something that just happened — not as the page's content.
|
||||
*/
|
||||
.us-notice {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px 16px;
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #b7dfc0;
|
||||
border-left-width: 4px;
|
||||
border-radius: 4px;
|
||||
background: #f2faf4;
|
||||
color: #1a5c2a;
|
||||
}
|
||||
|
||||
.us-notice p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.us-notice-dismiss {
|
||||
background: transparent;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 4px;
|
||||
padding: 4px 12px;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Shown only in block-editor previews (see BlockPreview). */
|
||||
.us-editor-note {
|
||||
font-size: 0.85em;
|
||||
|
||||
@@ -307,6 +307,28 @@
|
||||
})
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'us-scheduler/account',
|
||||
title: __('Account', 'unsupervised-schedular'),
|
||||
description: __('Shows the name and email of whoever is signed in, with a sign out link. Renders nothing for signed-out visitors unless a login page is chosen.', 'unsupervised-schedular'),
|
||||
icon: 'admin-users',
|
||||
keywords: ['account', 'sign out', 'log out', 'signed in', 'profile'],
|
||||
shortcode: 'us_account',
|
||||
attributes: {
|
||||
loginPageId: { type: 'number', default: 0 },
|
||||
},
|
||||
inspector: (attributes, setAttributes) => el(
|
||||
PanelBody,
|
||||
{ title: __('Signing in and out', 'unsupervised-schedular') },
|
||||
el(PageSelect, {
|
||||
label: __('Login page', 'unsupervised-schedular'),
|
||||
help: __('Where signing out returns to, and where signed-out visitors are offered a link to sign in. Without one, signing out returns to the current page and signed-out visitors see nothing.', 'unsupervised-schedular'),
|
||||
defaultLabel: __('Stay on the current page', 'unsupervised-schedular'),
|
||||
value: attributes.loginPageId,
|
||||
onChange: (loginPageId) => setAttributes({ loginPageId }),
|
||||
})
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
blocks.forEach((def) => {
|
||||
|
||||
+52
-10
@@ -335,7 +335,12 @@
|
||||
|
||||
slotList.querySelectorAll('.us-book-btn[data-slot-id]').forEach((btn) => {
|
||||
const slot = allSlots.find((s) => String(s.id) === btn.dataset.slotId);
|
||||
if (slot) btn.addEventListener('click', () => openRegistration(slot));
|
||||
if (slot) {
|
||||
btn.addEventListener('click', () => {
|
||||
hideConfirmation();
|
||||
openRegistration(slot);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -571,8 +576,11 @@
|
||||
? window.usPayment.collect('lesson', (res.ids || [])[0], slotList)
|
||||
: null))
|
||||
.then((result) => {
|
||||
loadMyLessons();
|
||||
showConfirmation(window.usPayment.message(result));
|
||||
const message = window.usPayment.message(result);
|
||||
|
||||
// Order matters: loadSlots() clears any standing notice, and it
|
||||
// is what puts the calendar back with the booked slot gone.
|
||||
return loadSlots().then(() => showConfirmation(message));
|
||||
})
|
||||
.catch((err) => showError(err.message));
|
||||
}
|
||||
@@ -668,10 +676,43 @@
|
||||
.catch(() => { myLessons.innerHTML = ''; });
|
||||
}
|
||||
|
||||
/**
|
||||
* Report a completed booking without taking the calendar away.
|
||||
*
|
||||
* This used to hide the slot list and leave the confirmation as the whole
|
||||
* page, which is a dead end: the student had nothing to click and no way
|
||||
* back to booking short of reloading. The notice now sits above a freshly
|
||||
* loaded calendar, so "it worked" and "you can book again" are the same
|
||||
* screen.
|
||||
*
|
||||
* Built from nodes rather than innerHTML because the message can carry a
|
||||
* studio's e-transfer address.
|
||||
*/
|
||||
function showConfirmation(message) {
|
||||
confirm.textContent = message;
|
||||
slotList.style.display = 'none';
|
||||
confirm.style.display = 'block';
|
||||
confirm.textContent = '';
|
||||
|
||||
const text = document.createElement('p');
|
||||
text.textContent = message;
|
||||
|
||||
const dismiss = document.createElement('button');
|
||||
dismiss.type = 'button';
|
||||
dismiss.className = 'us-notice-dismiss';
|
||||
dismiss.textContent = 'Dismiss';
|
||||
dismiss.addEventListener('click', hideConfirmation);
|
||||
|
||||
confirm.appendChild(text);
|
||||
confirm.appendChild(dismiss);
|
||||
|
||||
// The `hidden` attribute rather than an inline display, which would
|
||||
// outrank the stylesheet's `display: flex` and stack the notice's
|
||||
// parts instead of laying them out in a row.
|
||||
confirm.hidden = false;
|
||||
}
|
||||
|
||||
function hideConfirmation() {
|
||||
if (!confirm) return;
|
||||
confirm.hidden = true;
|
||||
confirm.textContent = '';
|
||||
}
|
||||
|
||||
// The private-lesson catalog drives both the filter and the registration
|
||||
@@ -696,16 +737,17 @@
|
||||
});
|
||||
}
|
||||
|
||||
/** Returns the load, so a caller can act once the calendar is back. */
|
||||
function loadSlots() {
|
||||
clearError();
|
||||
loadMyLessons();
|
||||
|
||||
// An upcoming-lessons-only embed has no calendar to fill.
|
||||
if (!slotList) return;
|
||||
if (!slotList) return Promise.resolve();
|
||||
|
||||
slotList.style.display = 'block';
|
||||
confirm.style.display = 'none';
|
||||
Promise.all([apiFetch('availability'), loadCatalog()])
|
||||
hideConfirmation();
|
||||
|
||||
return Promise.all([apiFetch('availability'), loadCatalog()])
|
||||
.then(([slots]) => {
|
||||
allSlots = slots;
|
||||
render();
|
||||
|
||||
@@ -173,7 +173,10 @@
|
||||
|
||||
list.querySelectorAll('.us-enrol-btn').forEach((btn) => {
|
||||
const offering = groups.find((o) => String(o.id) === btn.dataset.offeringId);
|
||||
btn.addEventListener('click', () => openEnrolment(offering));
|
||||
btn.addEventListener('click', () => {
|
||||
hideConfirmation();
|
||||
openEnrolment(offering);
|
||||
});
|
||||
});
|
||||
|
||||
list.querySelectorAll('.us-withdraw-btn').forEach((btn) => {
|
||||
@@ -254,25 +257,60 @@
|
||||
.then((res) => (res.payment
|
||||
? window.usPayment.collect('enrollment', res.id, list)
|
||||
: null))
|
||||
.then((result) => showConfirmation(window.usPayment.message(result)))
|
||||
.then((result) => {
|
||||
const message = window.usPayment.message(result);
|
||||
|
||||
// Order matters: loadClasses() clears any standing notice, and
|
||||
// it is what puts the list back showing the new enrolment.
|
||||
return loadClasses().then(() => showConfirmation(message));
|
||||
})
|
||||
.catch((err) => showError(err.message));
|
||||
}
|
||||
|
||||
/**
|
||||
* Report a completed enrolment without taking the class list away. Hiding
|
||||
* the list left the student on a dead-end screen with no way back to
|
||||
* browsing short of a reload; the notice now sits above a freshly loaded
|
||||
* list instead. Mirrors booking.js.
|
||||
*
|
||||
* Built from nodes rather than innerHTML because the message can carry a
|
||||
* studio's e-transfer address.
|
||||
*/
|
||||
function showConfirmation(message) {
|
||||
confirm.textContent = message;
|
||||
list.style.display = 'none';
|
||||
confirm.style.display = 'block';
|
||||
confirm.textContent = '';
|
||||
|
||||
const text = document.createElement('p');
|
||||
text.textContent = message;
|
||||
|
||||
const dismiss = document.createElement('button');
|
||||
dismiss.type = 'button';
|
||||
dismiss.className = 'us-notice-dismiss';
|
||||
dismiss.textContent = 'Dismiss';
|
||||
dismiss.addEventListener('click', hideConfirmation);
|
||||
|
||||
confirm.appendChild(text);
|
||||
confirm.appendChild(dismiss);
|
||||
|
||||
// The `hidden` attribute rather than an inline display, which would
|
||||
// outrank the stylesheet's `display: flex` and stack the notice's
|
||||
// parts instead of laying them out in a row.
|
||||
confirm.hidden = false;
|
||||
}
|
||||
|
||||
function hideConfirmation() {
|
||||
confirm.hidden = true;
|
||||
confirm.textContent = '';
|
||||
}
|
||||
|
||||
/** Returns the load, so a caller can act once the list is back. */
|
||||
function loadClasses() {
|
||||
clearError();
|
||||
list.style.display = 'block';
|
||||
confirm.style.display = 'none';
|
||||
hideConfirmation();
|
||||
// The student's own enrolments are fetched alongside the catalog so a
|
||||
// class they already have an active enrolment in shows its status
|
||||
// instead of offering to enrol them again (the API would reject the
|
||||
// duplicate anyway). A cancelled enrolment does not block re-enrolling.
|
||||
Promise.all([
|
||||
return Promise.all([
|
||||
apiFetch('offerings?kind=group_class'),
|
||||
apiFetch('enrollments'),
|
||||
])
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Editor Blocks
|
||||
|
||||
Gutenberg dynamic-block wrappers for the plugin's four front-end shortcodes,
|
||||
so the pages can be previewed and styled inside the block editor instead of
|
||||
appearing as grey shortcode text.
|
||||
Gutenberg dynamic-block wrappers for the plugin's front-end shortcodes, so the
|
||||
pages can be previewed and styled inside the block editor instead of appearing
|
||||
as grey shortcode text.
|
||||
|
||||
## Blocks
|
||||
|
||||
@@ -12,6 +12,8 @@ appearing as grey shortcode text.
|
||||
| `us-scheduler/student-login` | `[us_student_login]` | `Auth\LoginPage::render()` |
|
||||
| `us-scheduler/student-register` | `[us_student_register]` | `Auth\RegistrationPage::render()` |
|
||||
| `us-scheduler/group-classes` | `[us_group_classes]` | `GroupClass\GroupClassPage::render()` |
|
||||
| `us-scheduler/family` | `[us_family]` | `Guardian\FamilyPage::render()` |
|
||||
| `us-scheduler/account` | `[us_account]` | `Auth\AccountPage::render()` |
|
||||
|
||||
The shortcodes remain registered for back-compat; blocks and shortcodes share
|
||||
the same page objects (constructed once in `Plugin::boot()`), so front-end
|
||||
@@ -21,7 +23,7 @@ transform.
|
||||
|
||||
## Block options
|
||||
|
||||
Four blocks have sidebar (inspector) options:
|
||||
Most blocks have sidebar (inspector) options:
|
||||
|
||||
| Block | Attribute | Default | Effect |
|
||||
|---|---|---|---|
|
||||
@@ -34,6 +36,8 @@ Four blocks have sidebar (inspector) options:
|
||||
| `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 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/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/family` | `loginPageId` (number) | `0` | Where visitors who are not signed in are sent to log in. Shortcode equivalent: `[us_family login_page_id="…"]`. |
|
||||
| `us-scheduler/account` | `loginPageId` (number) | `0` | Where signing out returns to, and where a signed-out visitor is offered a **Sign in** link. `0` = signing out returns to the current page, and a signed-out visitor sees **nothing at all** — see below. Shortcode equivalent: `[us_account 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. 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,
|
||||
@@ -105,6 +109,10 @@ placeholder content:
|
||||
- **Login** — the real `templates/frontend/login-page.php` template (it has
|
||||
no request-state dependencies).
|
||||
- **Registration** — a disabled sample of the `.us-register-form` fields.
|
||||
- **Account** — a populated sample panel. Deliberately populated whatever the
|
||||
editor user's own state: on the published page a signed-out visitor may see
|
||||
nothing at all, and an empty box tells the person placing the block nothing
|
||||
about where it will sit.
|
||||
|
||||
Each preview starts with a `.us-editor-note` paragraph explaining what the
|
||||
published page shows instead. The note class only appears in editor previews.
|
||||
@@ -118,5 +126,22 @@ published page shows instead. The note class only appears in editor previews.
|
||||
and fallbacks.
|
||||
- `tests/Unit/Auth/LoginPageTest.php` — logged-in booking-link targets and
|
||||
fallbacks.
|
||||
- `tests/Unit/Auth/AccountPageTest.php` — what each visitor sees, the
|
||||
sign-out redirect target, and the signed-out empty render.
|
||||
- `tests/Unit/BlockPreviewTest.php` — preview markup mirrors the live CSS
|
||||
classes/ids and includes the editor note.
|
||||
|
||||
## The account block's signed-out behaviour
|
||||
|
||||
`us-scheduler/account` is the one block that can render **nothing**. It is meant
|
||||
for a header, sidebar or account page, and its whole subject is the person
|
||||
signed in — which a stranger is not. A bare "you are not signed in" in a site
|
||||
header is noise that cannot be acted on, so:
|
||||
|
||||
- **No login page chosen** → empty string for signed-out visitors.
|
||||
- **Login page chosen** → a single **Sign in** link.
|
||||
|
||||
Signed in, it shows the display name (`Auth\UserName::format()`, so a username
|
||||
is never exposed), the account email, and a **Sign out** link — deliberately
|
||||
nothing else. Signing out returns to the chosen login page, or to the current page when there
|
||||
is none, so a header sign-out does not also navigate the visitor somewhere.
|
||||
|
||||
@@ -30,7 +30,8 @@ Students register for a private lesson by choosing an offering, picking a time (
|
||||
8. `POST /bookings` creates the lesson row(s) (`status = pending`), records answers and policy acceptances, marks `us_availability.is_booked = 1`, and links the payment. A booking with nothing owed (a free offering) creates no payment and is `confirmed` immediately.
|
||||
9. On successful payment (or comp) the lesson is `confirmed` and a receipt is emailed.
|
||||
10. Instructor sees the booking under **My Lessons** and may update status via `PATCH /bookings/{id}/status`.
|
||||
11. The booking page also shows the student their upcoming lessons (`GET /bookings`) — each with the booked offering's name and length, when it happens, a per-lesson status badge (pending payment / confirmed), and a **Cancel** button. Only the soonest five are shown; a **Show all** control reveals the rest. `GET /bookings` includes `offering_title` and `duration_minutes` for each lesson so the list needs no extra request.
|
||||
11. The confirmation is a **dismissible notice above the calendar**, not a screen of its own. The calendar is reloaded first — so the slot just taken is gone and the upcoming-lessons panel is current — and the notice is shown over it. Booking again therefore needs no page reload. The notice clears when it is dismissed, when another slot's booking form is opened, and on any reload of the calendar. `group-classes.js` does the same for enrolments.
|
||||
12. 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
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Auth;
|
||||
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
/**
|
||||
* Who is signed in, and the way out.
|
||||
*
|
||||
* Meant for a header, sidebar or account page — somewhere it sits alongside
|
||||
* other content rather than being the whole of it. That shapes the two
|
||||
* decisions below.
|
||||
*/
|
||||
class AccountPage {
|
||||
|
||||
/**
|
||||
* Renders the account shortcode/block output.
|
||||
*
|
||||
* Signed out, this renders a sign-in link when a login page is configured and
|
||||
* **nothing at all** when one is not. A block whose whole job is "you are
|
||||
* signed in as X" has nothing to say to a stranger, and a bare "you are not
|
||||
* signed in" in a site header is noise with no way to act on it. The editor
|
||||
* preview shows the populated state regardless, so the block is never
|
||||
* invisible to the person placing it.
|
||||
*
|
||||
* @param array<int|string, mixed> $atts Block attributes (`loginPageId`) or
|
||||
* shortcode attributes (`login_page_id`).
|
||||
*/
|
||||
public function render( array $atts ): string {
|
||||
$loginPageId = Val::int( $atts['loginPageId'] ?? $atts['login_page_id'] ?? 0 );
|
||||
$loginUrl = $this->pageUrl( $loginPageId );
|
||||
|
||||
wp_enqueue_style( 'us-scheduler' );
|
||||
|
||||
if ( ! is_user_logged_in() ) {
|
||||
if ( null === $loginUrl ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
'<div class="us-account us-account-out"><a class="us-account-signin" href="%s">%s</a></div>',
|
||||
esc_url( $loginUrl ),
|
||||
esc_html__( 'Sign in', 'unsupervised-schedular' )
|
||||
);
|
||||
}
|
||||
|
||||
// Always a WP_User here — is_user_logged_in() above rules out the
|
||||
// id-0 placeholder wp_get_current_user() returns for a visitor.
|
||||
$user = wp_get_current_user();
|
||||
|
||||
$name = UserName::format( $user, get_current_user_id() );
|
||||
$email = $user->user_email;
|
||||
|
||||
// Back to where they were, so signing out of a header link does not also
|
||||
// navigate them somewhere. The login page is the better landing spot when
|
||||
// one is configured, since the current page may be members-only.
|
||||
$logoutUrl = wp_logout_url( $loginUrl ?? (string) get_permalink() );
|
||||
|
||||
ob_start();
|
||||
include USC_PLUGIN_DIR . 'templates/frontend/account-page.php';
|
||||
return (string) ob_get_clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* Permalink of a configured page, or null when none is chosen or the chosen
|
||||
* page has since been deleted.
|
||||
*/
|
||||
private function pageUrl( int $pageId ): ?string {
|
||||
if ( $pageId <= 0 ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$url = get_permalink( $pageId );
|
||||
|
||||
return is_string( $url ) ? $url : null;
|
||||
}
|
||||
}
|
||||
@@ -210,6 +210,24 @@ class BlockPreview {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample account panel. Shown populated whatever the editor's own login
|
||||
* state, since on the published page a signed-out visitor may see nothing at
|
||||
* all and an empty box tells the person placing the block nothing.
|
||||
*/
|
||||
public static function account(): string {
|
||||
return sprintf(
|
||||
'<div class="us-account">%s'
|
||||
. '<p class="us-account-who"><span class="us-account-name">%s</span>'
|
||||
. '<span class="us-account-email">%s</span></p>'
|
||||
. '<p class="us-account-actions"><a class="us-account-signout" href="#">%s</a></p></div>',
|
||||
self::note( __( 'Editor preview — each visitor sees their own account here.', 'unsupervised-schedular' ) ),
|
||||
esc_html__( 'Grace Hopper', 'unsupervised-schedular' ),
|
||||
esc_html__( '[email protected]', 'unsupervised-schedular' ),
|
||||
esc_html__( 'Sign out', 'unsupervised-schedular' )
|
||||
);
|
||||
}
|
||||
|
||||
private static function note( string $text ): string {
|
||||
return '<p class="us-editor-note">' . esc_html( $text ) . '</p>';
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular;
|
||||
|
||||
use Unsupervised\Schedular\Auth\AccountPage;
|
||||
use Unsupervised\Schedular\Auth\LoginPage;
|
||||
use Unsupervised\Schedular\Auth\RegistrationPage;
|
||||
use Unsupervised\Schedular\Booking\BookingPage;
|
||||
@@ -30,6 +31,7 @@ class BlockRegistrar {
|
||||
private RegistrationPage $registrationPage,
|
||||
private GroupClassPage $groupClassPage,
|
||||
private FamilyPage $familyPage,
|
||||
private AccountPage $accountPage,
|
||||
) {}
|
||||
|
||||
public function register(): void {
|
||||
@@ -148,6 +150,15 @@ class BlockRegistrar {
|
||||
],
|
||||
],
|
||||
],
|
||||
'us-scheduler/account' => [
|
||||
'render' => [ $this, 'renderAccount' ],
|
||||
'attributes' => [
|
||||
'loginPageId' => [
|
||||
'type' => 'number',
|
||||
'default' => 0,
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -195,6 +206,15 @@ class BlockRegistrar {
|
||||
return BlockPreview::groupClasses( Val::int( $attributes['offeringId'] ?? 0 ) > 0 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the account (who is signed in) block.
|
||||
*
|
||||
* @param array<string, mixed> $attributes Block attributes.
|
||||
*/
|
||||
public function renderAccount( array $attributes = [] ): string {
|
||||
return $this->isEditorPreview() ? BlockPreview::account() : $this->accountPage->render( $attributes );
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the family (manage-children) block.
|
||||
*
|
||||
|
||||
+4
-2
@@ -5,6 +5,7 @@ namespace Unsupervised\Schedular;
|
||||
|
||||
use Unsupervised\Schedular\Auth\EmailConfirmationHandler;
|
||||
use Unsupervised\Schedular\Auth\InviteRepository;
|
||||
use Unsupervised\Schedular\Auth\AccountPage;
|
||||
use Unsupervised\Schedular\Auth\LoginPage;
|
||||
use Unsupervised\Schedular\Auth\RegistrationLoginGate;
|
||||
use Unsupervised\Schedular\Auth\RegistrationMailer;
|
||||
@@ -99,6 +100,7 @@ class Plugin {
|
||||
$registrationPage = new RegistrationPage( $invites, $policies, $policyVersions, $acceptances, $settings, $registrationMailer, $questions, $answers, $groupAccess, $guardians );
|
||||
$groupClassPage = new GroupClassPage( $guardians );
|
||||
$familyPage = new FamilyPage( $guardians, $questions, $answers );
|
||||
$accountPage = new AccountPage();
|
||||
|
||||
( new ScheduledBillingRunner( $paymentService, $bookings, $enrollments, $offerings, new PaymentDueMailer(), $guardians ) )->register();
|
||||
|
||||
@@ -110,7 +112,7 @@ class Plugin {
|
||||
( 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, $guardians ) )->register();
|
||||
( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate, $enrollments, $groupAccess, $paymentService, $guardians ) )->register();
|
||||
( new ShortcodeRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage, $familyPage ) )->register();
|
||||
( new BlockRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage, $familyPage ) )->register();
|
||||
( new ShortcodeRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage, $familyPage, $accountPage ) )->register();
|
||||
( new BlockRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage, $familyPage, $accountPage ) )->register();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular;
|
||||
|
||||
use Unsupervised\Schedular\Auth\AccountPage;
|
||||
use Unsupervised\Schedular\Auth\LoginPage;
|
||||
use Unsupervised\Schedular\Auth\RegistrationPage;
|
||||
use Unsupervised\Schedular\Booking\BookingPage;
|
||||
@@ -18,6 +19,7 @@ class ShortcodeRegistrar {
|
||||
private RegistrationPage $registrationPage,
|
||||
private GroupClassPage $groupClassPage,
|
||||
private FamilyPage $familyPage,
|
||||
private AccountPage $accountPage,
|
||||
) {}
|
||||
|
||||
public function register(): void {
|
||||
@@ -26,6 +28,7 @@ class ShortcodeRegistrar {
|
||||
add_shortcode( 'us_student_register', self::shortcode( [ $this->registrationPage, 'render' ] ) );
|
||||
add_shortcode( 'us_group_classes', self::shortcode( [ $this->groupClassPage, 'render' ] ) );
|
||||
add_shortcode( 'us_family', self::shortcode( [ $this->familyPage, 'render' ] ) );
|
||||
add_shortcode( 'us_account', self::shortcode( [ $this->accountPage, 'render' ] ) );
|
||||
// Process registration submissions before output so the invite branch's
|
||||
// auth cookie is actually sent (render() runs too late, during the_content).
|
||||
add_action( 'template_redirect', [ $this->registrationPage, 'maybeHandleSubmit' ] );
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
if (! defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* @var string $name Display name of the signed-in visitor.
|
||||
* @var string $email Their account email.
|
||||
* @var string $logoutUrl Nonced sign-out URL, already carrying its redirect.
|
||||
*/
|
||||
?>
|
||||
<div class="us-account">
|
||||
<p class="us-account-who">
|
||||
<span class="us-account-name"><?php echo esc_html($name); ?></span>
|
||||
<?php if ($email !== '') : ?>
|
||||
<span class="us-account-email"><?php echo esc_html($email); ?></span>
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
|
||||
<p class="us-account-actions">
|
||||
<a class="us-account-signout" href="<?php echo esc_url($logoutUrl); ?>"><?php esc_html_e('Sign out', 'unsupervised-schedular'); ?></a>
|
||||
</p>
|
||||
</div>
|
||||
@@ -21,12 +21,17 @@ $studentsJson = wp_json_encode(array_values($students));
|
||||
<div id="us-my-lessons"></div>
|
||||
<?php endif; ?>
|
||||
<?php if ($showBooking) : ?>
|
||||
<?php
|
||||
/*
|
||||
* Above the calendar, because it reports on what the student just did and
|
||||
* the calendar below it is what they do next. Filled and shown by
|
||||
* booking.js; empty and hidden until then.
|
||||
*/
|
||||
?>
|
||||
<div id="us-booking-confirmation" class="us-notice" role="status" aria-live="polite" hidden></div>
|
||||
<div id="us-slot-list">
|
||||
<p><?php esc_html_e('Loading available slots…', 'unsupervised-schedular'); ?></p>
|
||||
</div>
|
||||
<div id="us-booking-confirmation" style="display:none;">
|
||||
<p><?php esc_html_e('Your lesson has been booked. The instructor will confirm shortly.', 'unsupervised-schedular'); ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div id="us-booking-error" style="display:none;" role="alert"></div>
|
||||
</div>
|
||||
|
||||
@@ -13,11 +13,10 @@ if (! defined('ABSPATH')) {
|
||||
$studentsJson = wp_json_encode(array_values($students));
|
||||
?>
|
||||
<div id="us-group-app" data-students="<?php echo esc_attr(is_string($studentsJson) ? $studentsJson : '[]'); ?>"<?php echo $offeringId > 0 ? ' data-offering="' . esc_attr((string) $offeringId) . '"' : ''; ?>>
|
||||
<?php /* Above the list, for the same reason as the booking page. */ ?>
|
||||
<div id="us-group-confirmation" class="us-notice" role="status" aria-live="polite" hidden></div>
|
||||
<div id="us-group-list">
|
||||
<p><?php esc_html_e('Loading group classes…', 'unsupervised-schedular'); ?></p>
|
||||
</div>
|
||||
<div id="us-group-confirmation" style="display:none;">
|
||||
<p><?php esc_html_e('You are enrolled. The studio will be in touch.', 'unsupervised-schedular'); ?></p>
|
||||
</div>
|
||||
<div id="us-group-error" style="display:none;" role="alert"></div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Tests\Unit\Auth;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use Mockery;
|
||||
use Unsupervised\Schedular\Auth\AccountPage;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
class AccountPageTest extends TestCase
|
||||
{
|
||||
private AccountPage $page;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->page = new AccountPage();
|
||||
|
||||
Functions\when('is_user_logged_in')->justReturn(true);
|
||||
Functions\when('get_current_user_id')->justReturn(5);
|
||||
Functions\when('wp_enqueue_style')->justReturn(null);
|
||||
Functions\when('get_permalink')->alias(
|
||||
static fn (int $id = 0): string => $id > 0
|
||||
? 'https://studio.test/sign-in/'
|
||||
: 'https://studio.test/current/'
|
||||
);
|
||||
Functions\when('wp_logout_url')->alias(
|
||||
static fn (string $redirect): string => 'https://studio.test/wp-login.php?action=logout&redirect_to=' . rawurlencode($redirect)
|
||||
);
|
||||
Functions\when('wp_get_current_user')->justReturn($this->user('Grace', 'Hopper', '[email protected]'));
|
||||
}
|
||||
|
||||
private function user(string $first, string $last, string $email): \WP_User
|
||||
{
|
||||
$user = Mockery::mock(\WP_User::class);
|
||||
$user->ID = 5;
|
||||
$user->first_name = $first;
|
||||
$user->last_name = $last;
|
||||
$user->nickname = '';
|
||||
$user->user_email = $email;
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
public function testShowsTheSignedInNameAndEmail(): void
|
||||
{
|
||||
$html = $this->page->render([]);
|
||||
|
||||
self::assertStringContainsString('Grace Hopper', $html);
|
||||
self::assertStringContainsString('[email protected]', $html);
|
||||
self::assertStringContainsString('Sign out', $html);
|
||||
}
|
||||
|
||||
public function testSigningOutReturnsToTheConfiguredLoginPage(): void
|
||||
{
|
||||
self::assertStringContainsString(
|
||||
rawurlencode('https://studio.test/sign-in/'),
|
||||
$this->page->render(['loginPageId' => 9])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* With no page chosen, signing out from a header link should leave the
|
||||
* visitor where they were rather than navigating them somewhere.
|
||||
*/
|
||||
public function testSigningOutReturnsToTheCurrentPageWhenNoLoginPageIsSet(): void
|
||||
{
|
||||
self::assertStringContainsString(
|
||||
rawurlencode('https://studio.test/current/'),
|
||||
$this->page->render([])
|
||||
);
|
||||
}
|
||||
|
||||
public function testTheShortcodeAttributeNameIsAccepted(): void
|
||||
{
|
||||
self::assertStringContainsString(
|
||||
rawurlencode('https://studio.test/sign-in/'),
|
||||
$this->page->render(['login_page_id' => 9])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A block whose whole job is "you are signed in as X" has nothing to say to
|
||||
* a stranger, and a bare notice in a site header cannot be acted on.
|
||||
*/
|
||||
public function testRendersNothingForASignedOutVisitorWithNoLoginPage(): void
|
||||
{
|
||||
Functions\when('is_user_logged_in')->justReturn(false);
|
||||
|
||||
self::assertSame('', $this->page->render([]));
|
||||
}
|
||||
|
||||
public function testOffersASignInLinkToASignedOutVisitorWhenAPageIsChosen(): void
|
||||
{
|
||||
Functions\when('is_user_logged_in')->justReturn(false);
|
||||
|
||||
$html = $this->page->render(['loginPageId' => 9]);
|
||||
|
||||
self::assertStringContainsString('https://studio.test/sign-in/', $html);
|
||||
self::assertStringContainsString('Sign in', $html);
|
||||
self::assertStringNotContainsString('Sign out', $html);
|
||||
}
|
||||
|
||||
/**
|
||||
* A page can be deleted after it has been chosen in the block, which
|
||||
* get_permalink() reports as false.
|
||||
*/
|
||||
public function testTreatsADeletedLoginPageAsNoneChosen(): void
|
||||
{
|
||||
Functions\when('is_user_logged_in')->justReturn(false);
|
||||
Functions\when('get_permalink')->justReturn(false);
|
||||
|
||||
self::assertSame('', $this->page->render(['loginPageId' => 9]));
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ namespace Unsupervised\Schedular\Tests\Unit;
|
||||
use Brain\Monkey\Actions;
|
||||
use Brain\Monkey\Functions;
|
||||
use Mockery;
|
||||
use Unsupervised\Schedular\Auth\AccountPage;
|
||||
use Unsupervised\Schedular\Auth\LoginPage;
|
||||
use Unsupervised\Schedular\Auth\RegistrationPage;
|
||||
use Unsupervised\Schedular\BlockRegistrar;
|
||||
@@ -43,6 +44,7 @@ class BlockRegistrarTest extends TestCase
|
||||
private RegistrationPage&Mockery\MockInterface $registrationPage;
|
||||
private GroupClassPage&Mockery\MockInterface $groupClassPage;
|
||||
private FamilyPage&Mockery\MockInterface $familyPage;
|
||||
private AccountPage&Mockery\MockInterface $accountPage;
|
||||
private TestableBlockRegistrar $registrar;
|
||||
|
||||
protected function setUp(): void
|
||||
@@ -54,6 +56,7 @@ class BlockRegistrarTest extends TestCase
|
||||
$this->registrationPage = Mockery::mock(RegistrationPage::class);
|
||||
$this->groupClassPage = Mockery::mock(GroupClassPage::class);
|
||||
$this->familyPage = Mockery::mock(FamilyPage::class);
|
||||
$this->accountPage = Mockery::mock(AccountPage::class);
|
||||
|
||||
// Most requests are not a just-finished registration; the tests that
|
||||
// exercise that path override this.
|
||||
@@ -67,6 +70,7 @@ class BlockRegistrarTest extends TestCase
|
||||
$this->registrationPage,
|
||||
$this->groupClassPage,
|
||||
$this->familyPage,
|
||||
$this->accountPage,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -116,6 +120,7 @@ class BlockRegistrarTest extends TestCase
|
||||
'us-scheduler/student-register',
|
||||
'us-scheduler/group-classes',
|
||||
'us-scheduler/family',
|
||||
'us-scheduler/account',
|
||||
],
|
||||
array_keys($registered)
|
||||
);
|
||||
@@ -213,6 +218,7 @@ class BlockRegistrarTest extends TestCase
|
||||
$this->registrationPage,
|
||||
$this->groupClassPage,
|
||||
$this->familyPage,
|
||||
$this->accountPage,
|
||||
);
|
||||
|
||||
$this->bookingPage->shouldReceive('render')->once()->with([])->andReturn('live');
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace Unsupervised\Schedular\Tests\Unit;
|
||||
use Brain\Monkey\Actions;
|
||||
use Brain\Monkey\Functions;
|
||||
use Mockery;
|
||||
use Unsupervised\Schedular\Auth\AccountPage;
|
||||
use Unsupervised\Schedular\Auth\LoginPage;
|
||||
use Unsupervised\Schedular\Auth\RegistrationPage;
|
||||
use Unsupervised\Schedular\Booking\BookingPage;
|
||||
@@ -20,6 +21,7 @@ class ShortcodeRegistrarTest extends TestCase
|
||||
private RegistrationPage&Mockery\MockInterface $registrationPage;
|
||||
private GroupClassPage&Mockery\MockInterface $groupClassPage;
|
||||
private FamilyPage&Mockery\MockInterface $familyPage;
|
||||
private AccountPage&Mockery\MockInterface $accountPage;
|
||||
private ShortcodeRegistrar $registrar;
|
||||
|
||||
/** @var array<string, callable> */
|
||||
@@ -37,6 +39,7 @@ class ShortcodeRegistrarTest extends TestCase
|
||||
$this->registrationPage = Mockery::mock(RegistrationPage::class);
|
||||
$this->groupClassPage = Mockery::mock(GroupClassPage::class);
|
||||
$this->familyPage = Mockery::mock(FamilyPage::class);
|
||||
$this->accountPage = Mockery::mock(AccountPage::class);
|
||||
|
||||
$this->registrar = new ShortcodeRegistrar(
|
||||
$this->bookingPage,
|
||||
@@ -44,6 +47,7 @@ class ShortcodeRegistrarTest extends TestCase
|
||||
$this->registrationPage,
|
||||
$this->groupClassPage,
|
||||
$this->familyPage,
|
||||
$this->accountPage,
|
||||
);
|
||||
|
||||
$shortcodes = &$this->shortcodes;
|
||||
@@ -66,7 +70,7 @@ class ShortcodeRegistrarTest extends TestCase
|
||||
$this->registrar->register();
|
||||
|
||||
self::assertSame(
|
||||
['us_booking', 'us_student_login', 'us_student_register', 'us_group_classes', 'us_family'],
|
||||
['us_booking', 'us_student_login', 'us_student_register', 'us_group_classes', 'us_family', 'us_account'],
|
||||
array_keys($this->shortcodes)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user