[us_account], or the Account block: the signed-in visitor's name, their email, a Sign out link, and — only when the account books for someone besides itself — the students it books for. A parent's first question on seeing "signed in as Grace" is whether this is the account their children's lessons are on. Two decisions worth naming. Signed out with no login page chosen, the block renders nothing. Its whole subject is the person signed in, which a stranger is not, and a bare "you are not signed in" in a site header is noise with no way to act on it. With a login page chosen it offers a Sign in link instead. The editor preview is populated regardless, so the block is never an invisible box to the person placing it. Signing out returns to the chosen login page, or to the current page when there is none. A block meant for a header should not also navigate someone somewhere when they use it; the login page wins when configured, because the page they were on may well be members-only. The name comes from UserName::format(), so the block never exposes a username the way display_name can. Also brings docs/features/editor-blocks.md back in step: it still described "four shortcodes" and had never listed the family block. Closes #142 Co-Authored-By: Claude Opus 5 <[email protected]>
364 lines
18 KiB
JavaScript
364 lines
18 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),
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Dropdown of active private-lesson types fetched from the plugin's public
|
||
* offerings endpoint. Values are offering IDs; 0 means every type.
|
||
*/
|
||
function LessonTypeSelect(props) {
|
||
const [offerings, setOfferings] = useState(null);
|
||
|
||
useEffect(() => {
|
||
apiFetch({ path: '/us-scheduler/v1/offerings?kind=private_lesson' })
|
||
.then(setOfferings)
|
||
.catch(() => setOfferings([]));
|
||
}, []);
|
||
|
||
const options = [{ label: __('All lesson types', 'unsupervised-schedular'), value: '0' }].concat(
|
||
(offerings || []).map((o) => ({
|
||
label: o.duration_minutes
|
||
? `${o.title} (${o.duration_minutes} min)`
|
||
: (o.title || __('(no title)', 'unsupervised-schedular')),
|
||
value: String(o.id),
|
||
}))
|
||
);
|
||
|
||
// A previously chosen type that is no longer offered keeps its stored
|
||
// id visible instead of silently pretending "All lesson types" is set.
|
||
const value = String(props.value || 0);
|
||
if (offerings !== null && !options.some((opt) => opt.value === value)) {
|
||
options.push({
|
||
label: __('Unavailable lesson type #', 'unsupervised-schedular') + value,
|
||
value: value,
|
||
});
|
||
}
|
||
|
||
return el(SelectControl, {
|
||
label: props.label,
|
||
help: props.help,
|
||
value: value,
|
||
options: options,
|
||
onChange: (newValue) => props.onChange(parseInt(newValue, 10) || 0),
|
||
});
|
||
}
|
||
|
||
const blocks = [
|
||
{
|
||
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 },
|
||
lessonTypeId: { type: 'number', default: 0 },
|
||
showTypeFilter: { type: 'boolean', default: true },
|
||
displayMode: { type: 'string', default: 'both' },
|
||
},
|
||
inspector: (attributes, setAttributes) => [
|
||
el(
|
||
PanelBody,
|
||
{ title: __('What to show', 'unsupervised-schedular'), key: 'display' },
|
||
el(SelectControl, {
|
||
label: __('Sections', 'unsupervised-schedular'),
|
||
help: __('Split the page in two: a booking calendar here, the student’s upcoming lessons somewhere else.', 'unsupervised-schedular'),
|
||
value: attributes.displayMode || 'both',
|
||
options: [
|
||
{ label: __('Booking and upcoming lessons', 'unsupervised-schedular'), value: 'both' },
|
||
{ label: __('Booking only', 'unsupervised-schedular'), value: 'booking' },
|
||
{ label: __('Upcoming lessons only', 'unsupervised-schedular'), value: 'upcoming' },
|
||
],
|
||
onChange: (displayMode) => setAttributes({ displayMode }),
|
||
})
|
||
),
|
||
el(
|
||
PanelBody,
|
||
{ title: __('Lesson types', 'unsupervised-schedular'), key: 'lesson-types' },
|
||
el(LessonTypeSelect, {
|
||
label: __('Lesson type', 'unsupervised-schedular'),
|
||
help: __('Show only the times bookable as one lesson type, for embedding on a page dedicated to it. That type is then the only one students can book here.', 'unsupervised-schedular'),
|
||
value: attributes.lessonTypeId,
|
||
onChange: (lessonTypeId) => setAttributes({ lessonTypeId }),
|
||
}),
|
||
el(ToggleControl, {
|
||
label: __('Show the lesson-type filter', 'unsupervised-schedular'),
|
||
help: __('Offer students the “Show Only” button that narrows the calendar to chosen lesson types. Not used when a single lesson type is set above.', 'unsupervised-schedular'),
|
||
checked: attributes.showTypeFilter !== false,
|
||
onChange: (showTypeFilter) => setAttributes({ showTypeFilter }),
|
||
})
|
||
),
|
||
el(
|
||
PanelBody,
|
||
{ title: __('Logged-out visitors', 'unsupervised-schedular'), key: 'logged-out' },
|
||
el(PageSelect, {
|
||
label: __('Login page', 'unsupervised-schedular'),
|
||
help: __('Where the log-in link sends visitors who are not logged in.', 'unsupervised-schedular'),
|
||
defaultLabel: __('WordPress login screen', 'unsupervised-schedular'),
|
||
value: attributes.loginPageId,
|
||
onChange: (loginPageId) => setAttributes({ loginPageId }),
|
||
}),
|
||
el(ToggleControl, {
|
||
label: __('Redirect automatically', 'unsupervised-schedular'),
|
||
help: __('Send logged-out visitors straight to the login page instead of showing a link.', 'unsupervised-schedular'),
|
||
checked: !!attributes.autoRedirect,
|
||
onChange: (autoRedirect) => setAttributes({ autoRedirect }),
|
||
})
|
||
),
|
||
],
|
||
},
|
||
{
|
||
name: 'us-scheduler/student-login',
|
||
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 },
|
||
autoRedirect: { type: 'boolean', default: false },
|
||
inviteOnlyMessage: { type: 'string', default: '' },
|
||
},
|
||
inspector: (attributes, setAttributes) => [
|
||
el(
|
||
PanelBody,
|
||
{ title: __('After registration', 'unsupervised-schedular'), key: 'confirmation' },
|
||
el(PageSelect, {
|
||
label: __('Sign-in page', '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'),
|
||
value: attributes.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(
|
||
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. That class’s description is left out — the card shows just the schedule, price and enrolment controls.', 'unsupervised-schedular'),
|
||
value: attributes.offeringId,
|
||
onChange: (offeringId) => setAttributes({ offeringId }),
|
||
})
|
||
),
|
||
},
|
||
{
|
||
name: 'us-scheduler/family',
|
||
title: __('Profile', 'unsupervised-schedular'),
|
||
description: __('Lets a parent or guardian add, edit and remove the students they book lessons for.', 'unsupervised-schedular'),
|
||
icon: 'groups',
|
||
// 'family' and 'children' are kept as search terms only — they are
|
||
// never displayed, and the block answered to them before it was
|
||
// renamed, so anyone reaching for the old word still finds it.
|
||
keywords: ['profile', 'students', 'family', 'children', 'guardian', 'parent'],
|
||
shortcode: 'us_family',
|
||
attributes: {
|
||
loginPageId: { type: 'number', default: 0 },
|
||
},
|
||
inspector: (attributes, setAttributes) => el(
|
||
PanelBody,
|
||
{ title: __('Logged-out visitors', 'unsupervised-schedular') },
|
||
el(PageSelect, {
|
||
label: __('Login page', 'unsupervised-schedular'),
|
||
help: __('Where visitors who are not signed in are sent to log in.', 'unsupervised-schedular'),
|
||
defaultLabel: __('WordPress login screen', 'unsupervised-schedular'),
|
||
value: attributes.loginPageId,
|
||
onChange: (loginPageId) => setAttributes({ loginPageId }),
|
||
})
|
||
),
|
||
},
|
||
{
|
||
name: 'us-scheduler/account',
|
||
title: __('Account', 'unsupervised-schedular'),
|
||
description: __('Shows who is signed in, who they book for, and 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) => {
|
||
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 }],
|
||
},
|
||
});
|
||
});
|
||
}());
|