CI / Tests (PHP 8.1) (pull_request) Successful in 47s
CI / Tests (PHP 8.2) (pull_request) Successful in 46s
CI / No Debug Code (pull_request) Successful in 3s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m37s
CI / PHPStan (pull_request) Successful in 2m52s
CI / Coding Standards (pull_request) Successful in 3m6s
CI / Build Plugin Zip (pull_request) Skipped
Three registration fixes reported from live use:
- Accepting an invite now keeps the student signed in. The form was
processed inside render() during the_content, so wp_set_auth_cookie()
ran after headers were sent and the cookie never persisted — the new
student was bounced back to the logged-out registration page. The
submission is now handled on template_redirect (before output) with a
post/redirect/get, so the cookie sticks and the student lands logged in.
- The "registration is by invitation only" message is now customisable via
a new block attribute (inviteOnlyMessage / shortcode invite_only_message),
falling back to the default wording when blank.
- Account-registration questions save again. dbDelta does not reliably
relax a column from NOT NULL to NULL, so sites created before account-
scope questions kept us_questions.offering_id NOT NULL and rejected
account inserts ("Column 'offering_id' cannot be null"). A one-time,
self-healing migration (guarded by its own option, not the version gate)
re-applies the nullable definition on next load.
composer test, composer lint, composer cs all pass.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
233 lines
10 KiB
JavaScript
233 lines
10 KiB
JavaScript
/* global wp */
|
|
(function () {
|
|
'use strict';
|
|
|
|
const { registerBlockType } = wp.blocks;
|
|
const { createElement: el, useState, useEffect } = wp.element;
|
|
const { useBlockProps, InspectorControls } = wp.blockEditor;
|
|
const { PanelBody, SelectControl, ToggleControl, TextareaControl } = wp.components;
|
|
const { useSelect } = wp.data;
|
|
const apiFetch = wp.apiFetch;
|
|
const ServerSideRender = wp.serverSideRender;
|
|
const { __ } = wp.i18n;
|
|
|
|
/**
|
|
* Dropdown of published pages with a leading "default" choice.
|
|
* Values are page IDs; 0 means the default behaviour.
|
|
*/
|
|
function PageSelect(props) {
|
|
const pages = useSelect(
|
|
(select) => select('core').getEntityRecords('postType', 'page', {
|
|
per_page: -1,
|
|
orderby: 'title',
|
|
order: 'asc',
|
|
status: 'publish',
|
|
_fields: 'id,title',
|
|
}),
|
|
[]
|
|
);
|
|
|
|
const options = [{ label: props.defaultLabel, value: '0' }].concat(
|
|
(pages || []).map((page) => ({
|
|
label: (page.title && page.title.rendered) || __('(no title)', 'unsupervised-schedular'),
|
|
value: String(page.id),
|
|
}))
|
|
);
|
|
|
|
return el(SelectControl, {
|
|
label: props.label,
|
|
help: props.help,
|
|
value: String(props.value || 0),
|
|
options: options,
|
|
onChange: (value) => props.onChange(parseInt(value, 10) || 0),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Dropdown of active group classes fetched from the plugin's public
|
|
* offerings endpoint. Values are offering IDs; 0 means all classes.
|
|
*/
|
|
function GroupClassSelect(props) {
|
|
const [offerings, setOfferings] = useState(null);
|
|
|
|
useEffect(() => {
|
|
apiFetch({ path: '/us-scheduler/v1/offerings?kind=group_class' })
|
|
.then(setOfferings)
|
|
.catch(() => setOfferings([]));
|
|
}, []);
|
|
|
|
const options = [{ label: __('All classes', 'unsupervised-schedular'), value: '0' }].concat(
|
|
(offerings || []).map((o) => ({
|
|
label: o.title || __('(no title)', 'unsupervised-schedular'),
|
|
value: String(o.id),
|
|
}))
|
|
);
|
|
|
|
// A previously chosen class that is no longer offered (deleted or
|
|
// deactivated) keeps its stored id visible instead of silently
|
|
// pretending "All classes" is selected.
|
|
const value = String(props.value || 0);
|
|
if (offerings !== null && !options.some((opt) => opt.value === value)) {
|
|
options.push({
|
|
label: __('Unavailable class #', 'unsupervised-schedular') + value,
|
|
value: value,
|
|
});
|
|
}
|
|
|
|
return el(SelectControl, {
|
|
label: props.label,
|
|
help: props.help,
|
|
value: value,
|
|
options: options,
|
|
onChange: (newValue) => props.onChange(parseInt(newValue, 10) || 0),
|
|
});
|
|
}
|
|
|
|
const blocks = [
|
|
{
|
|
name: 'us-scheduler/booking',
|
|
title: __('Lesson Booking', 'unsupervised-schedular'),
|
|
description: __('Lets students browse availability and book lessons. Shows a styled preview in the editor.', 'unsupervised-schedular'),
|
|
icon: 'calendar-alt',
|
|
keywords: ['booking', 'lesson', 'schedule'],
|
|
shortcode: 'us_booking',
|
|
attributes: {
|
|
loginPageId: { type: 'number', default: 0 },
|
|
autoRedirect: { type: 'boolean', default: false },
|
|
},
|
|
inspector: (attributes, setAttributes) => el(
|
|
PanelBody,
|
|
{ title: __('Logged-out visitors', 'unsupervised-schedular') },
|
|
el(PageSelect, {
|
|
label: __('Login page', 'unsupervised-schedular'),
|
|
help: __('Where the log-in link sends visitors who are not logged in.', 'unsupervised-schedular'),
|
|
defaultLabel: __('WordPress login screen', 'unsupervised-schedular'),
|
|
value: attributes.loginPageId,
|
|
onChange: (loginPageId) => setAttributes({ loginPageId }),
|
|
}),
|
|
el(ToggleControl, {
|
|
label: __('Redirect automatically', 'unsupervised-schedular'),
|
|
help: __('Send logged-out visitors straight to the login page instead of showing a link.', 'unsupervised-schedular'),
|
|
checked: !!attributes.autoRedirect,
|
|
onChange: (autoRedirect) => setAttributes({ autoRedirect }),
|
|
})
|
|
),
|
|
},
|
|
{
|
|
name: 'us-scheduler/student-login',
|
|
title: __('Student Login', 'unsupervised-schedular'),
|
|
description: __('The front-end login form for students.', 'unsupervised-schedular'),
|
|
icon: 'admin-users',
|
|
keywords: ['login', 'student', 'sign in'],
|
|
shortcode: 'us_student_login',
|
|
attributes: {
|
|
bookingPageId: { type: 'number', default: 0 },
|
|
autoRedirect: { type: 'boolean', default: false },
|
|
},
|
|
inspector: (attributes, setAttributes) => el(
|
|
PanelBody,
|
|
{ title: __('Logged-in visitors', 'unsupervised-schedular') },
|
|
el(PageSelect, {
|
|
label: __('Booking page', 'unsupervised-schedular'),
|
|
help: __('Where students are sent after logging in, and where the link shown to already-logged-in visitors points.', 'unsupervised-schedular'),
|
|
defaultLabel: __('This page', 'unsupervised-schedular'),
|
|
value: attributes.bookingPageId,
|
|
onChange: (bookingPageId) => setAttributes({ bookingPageId }),
|
|
}),
|
|
el(ToggleControl, {
|
|
label: __('Redirect automatically', 'unsupervised-schedular'),
|
|
help: __('Send logged-in visitors straight to the booking page instead of showing a link. Requires a booking page to be chosen.', 'unsupervised-schedular'),
|
|
checked: !!attributes.autoRedirect,
|
|
onChange: (autoRedirect) => setAttributes({ autoRedirect }),
|
|
})
|
|
),
|
|
},
|
|
{
|
|
name: 'us-scheduler/student-register',
|
|
title: __('Student Registration', 'unsupervised-schedular'),
|
|
description: __('The invite-only student registration form.', 'unsupervised-schedular'),
|
|
icon: 'welcome-add-page',
|
|
keywords: ['register', 'student', 'invite'],
|
|
shortcode: 'us_student_register',
|
|
attributes: {
|
|
loginPageId: { type: 'number', default: 0 },
|
|
inviteOnlyMessage: { type: 'string', default: '' },
|
|
},
|
|
inspector: (attributes, setAttributes) => [
|
|
el(
|
|
PanelBody,
|
|
{ title: __('After email confirmation', 'unsupervised-schedular'), key: 'confirmation' },
|
|
el(PageSelect, {
|
|
label: __('Sign-in page', 'unsupervised-schedular'),
|
|
help: __('Where the sign-in link shown after a student confirms their email address sends them.', 'unsupervised-schedular'),
|
|
defaultLabel: __('WordPress login screen', 'unsupervised-schedular'),
|
|
value: attributes.loginPageId,
|
|
onChange: (loginPageId) => setAttributes({ loginPageId }),
|
|
})
|
|
),
|
|
el(
|
|
PanelBody,
|
|
{ title: __('Invitation-only notice', 'unsupervised-schedular'), key: 'invite-only' },
|
|
el(TextareaControl, {
|
|
label: __('Message', 'unsupervised-schedular'),
|
|
help: __('Shown when registration is invite-only and the visitor has no valid invite link. Leave blank to use the default wording.', 'unsupervised-schedular'),
|
|
value: attributes.inviteOnlyMessage,
|
|
onChange: (inviteOnlyMessage) => setAttributes({ inviteOnlyMessage }),
|
|
})
|
|
),
|
|
],
|
|
},
|
|
{
|
|
name: 'us-scheduler/group-classes',
|
|
title: __('Group Classes', 'unsupervised-schedular'),
|
|
description: __('Lets students browse and enrol in group classes. Shows a styled preview in the editor.', 'unsupervised-schedular'),
|
|
icon: 'groups',
|
|
keywords: ['group', 'class', 'enrol'],
|
|
shortcode: 'us_group_classes',
|
|
attributes: {
|
|
offeringId: { type: 'number', default: 0 },
|
|
},
|
|
inspector: (attributes, setAttributes) => el(
|
|
PanelBody,
|
|
{ title: __('Classes shown', 'unsupervised-schedular') },
|
|
el(GroupClassSelect, {
|
|
label: __('Class', 'unsupervised-schedular'),
|
|
help: __('Show only one group class, for embedding on a page dedicated to it.', 'unsupervised-schedular'),
|
|
value: attributes.offeringId,
|
|
onChange: (offeringId) => setAttributes({ offeringId }),
|
|
})
|
|
),
|
|
},
|
|
];
|
|
|
|
blocks.forEach((def) => {
|
|
registerBlockType(def.name, {
|
|
apiVersion: 3,
|
|
title: def.title,
|
|
description: def.description,
|
|
icon: def.icon,
|
|
category: 'widgets',
|
|
keywords: def.keywords,
|
|
supports: { html: false, multiple: false },
|
|
attributes: def.attributes || {},
|
|
example: {},
|
|
edit: function Edit(props) {
|
|
const inspector = def.inspector
|
|
? el(InspectorControls, {}, def.inspector(props.attributes, props.setAttributes))
|
|
: null;
|
|
|
|
return el(
|
|
'div',
|
|
useBlockProps(),
|
|
inspector,
|
|
el(ServerSideRender, { block: def.name, attributes: props.attributes })
|
|
);
|
|
},
|
|
save: () => null,
|
|
transforms: {
|
|
from: [{ type: 'shortcode', tag: def.shortcode }],
|
|
},
|
|
});
|
|
});
|
|
}());
|