Let parents register once and book for their children

A parent registers once and manages lessons for one or more children, who
need no login of their own. A child is a real wp_users row with the student
role but no usable login — so student_id keeps meaning "a WordPress user"
on every table, and booking, credits, policies and enrolments work unchanged.
A us_guardians link table maps guardian to child.

The signup form gains a parent/guardian tick that reveals a block per child,
with the account-signup questions asked per child rather than per guardian
— they describe the student, not the account holder. Signup policies are
recorded once per child with the guardian as the acceptor, which is the
record that actually means something. A family that half-creates is rolled
back entirely rather than leaving a guardian who cannot re-register.

The booking and enrolment forms gain a "Who is this for?" picker listing
children first, so the default selection is never the parent — booking for
the wrong child is correctable, quietly billing a parent for their kid's
lesson is not. POST /bookings and POST /enrollments take an optional
student_id honoured only for that child's guardian; anything else is a 403.
That check is the authorisation boundary of the feature.

Payments and credits gain a payer: the charge names the child it was for and
the guardian who owes it, so per-child reporting is unchanged while notices,
receipts and the payment step reach the parent. Credit is held by the payer,
so one child's cancellation can settle a sibling's charge, and the daily
billing scan sends a guardian one notice covering every child.

Closes #132

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-07-29 16:07:52 -03:00
co-authored by Claude Opus 5
parent c25260a367
commit b772e1811e
71 changed files with 4192 additions and 191 deletions
+102
View File
@@ -388,6 +388,108 @@
}
}
/*
* "Who is this for?" picker — booking and enrolment. Present only on an account
* that books for more than one person, so it is styled as a normal field rather
* than a callout.
*/
.us-student-picker select {
max-width: 100%;
}
/* Whose lesson a row in the upcoming panel is — only shown on a family account. */
.us-my-lesson-who {
font-weight: normal;
opacity: 0.75;
}
/* Parent/guardian signup: the child blocks revealed by the checkbox. */
.us-guardian {
margin: 16px 0;
padding: 12px 14px;
border: 1px solid #ddd;
border-radius: 4px;
}
.us-guardian legend {
padding: 0 6px;
font-weight: 600;
}
.us-children-intro {
margin-top: 0;
font-size: 0.9em;
opacity: 0.8;
}
/*
* Each child is a bordered group so a family of three does not read as one long
* undifferentiated column of fields.
*/
.us-child {
margin-bottom: 12px;
padding: 10px 12px;
border-left: 3px solid #ddd;
background: #fafafa;
}
.us-child > p:last-child {
margin-bottom: 0;
}
/* The guardian's manage-children screen ([us_family]). */
.us-family-list {
margin: 0 0 20px;
padding: 0;
list-style: none;
}
.us-family-child {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
align-items: baseline;
padding: 10px 0;
border-bottom: 1px solid #eee;
}
.us-family-child-name {
font-weight: 600;
}
.us-family-child-dob {
font-size: 0.9em;
opacity: 0.75;
}
/*
* The actions sit at the far end of the row. Remove is its own form (it posts),
* so it is forced inline rather than taking a block of its own.
*/
.us-family-child-actions {
display: flex;
gap: 10px;
align-items: baseline;
margin-left: auto;
}
.us-family-remove {
display: inline;
}
/* The editing row replaces the child's line, so it spans the whole width. */
.us-family-edit {
width: 100%;
}
@media (max-width: 640px) {
/* A name, a date and two actions do not fit one narrow line. */
.us-family-child-actions {
margin-left: 0;
width: 100%;
}
}
/* Shown only in block-editor previews (see BlockPreview). */
.us-editor-note {
font-size: 0.85em;
+22
View File
@@ -282,6 +282,28 @@
})
),
},
{
name: 'us-scheduler/family',
title: __('Family', 'unsupervised-schedular'),
description: __('Lets a parent or guardian add, edit and remove the children they book lessons for.', 'unsupervised-schedular'),
icon: 'groups',
keywords: ['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 }),
})
),
},
];
blocks.forEach((def) => {
+17 -1
View File
@@ -16,6 +16,11 @@
const pinnedTypeId = Number(app.dataset.lessonType) || 0;
const filterEnabled = app.dataset.typeFilter !== '0';
// Who this account may book for — children first, the account holder last,
// so a guardian's default selection is a child rather than themselves. A
// single-student account has one entry and gets no picker.
const students = window.usGuardian.parseStudents(app.dataset.students);
function apiFetch(path, options = {}) {
return fetch(restUrl + path, {
...options,
@@ -453,6 +458,7 @@
<div class="us-register">
<h3>${escHtml(dayLabel(dayKey(slot.start_dt)))} · ${escHtml(timeOf(slot.start_dt))}${escHtml(timeOf(slot.end_dt))}</h3>
<form id="us-register-form">
${window.usGuardian.selectorHtml(students, 'us-booking-student')}
${offeringFieldHtml(tied, tiedId, choices)}
<div id="us-questions"></div>
${policies.map(policyField).join('')}
@@ -553,6 +559,7 @@
body: JSON.stringify({
slot_id: slot.id,
offering_id: offeringId,
student_id: window.usGuardian.selectedId('us-booking-student'),
recurrence: weeklyEl && weeklyEl.checked ? 'weekly' : 'single',
answers,
accepted_policy_version_ids: accepted,
@@ -579,6 +586,15 @@
// How many upcoming lessons to show before the "Show all" reveal.
const INITIAL_LESSON_COUNT = 5;
// Whose lesson this is. Only shown on an account that books for more than
// one person — on a single-student account the name is on every row and says
// nothing.
function lessonWhoHtml(l) {
if (students.length < 2 || !l.student_name) return '';
return ` <span class="us-my-lesson-who">— ${escHtml(String(l.student_name))}</span>`;
}
function lessonRowHtml(l) {
const title = l.offering_title ? escHtml(String(l.offering_title)) : 'Lesson';
const duration = l.duration_minutes ? ` <span class="us-my-lesson-duration">(${escHtml(String(l.duration_minutes))} min)</span>` : '';
@@ -588,7 +604,7 @@
return `
<div class="us-my-lesson">
<div class="us-my-lesson-info">
<strong class="us-my-lesson-title">${title}${duration}</strong>
<strong class="us-my-lesson-title">${title}${duration}${lessonWhoHtml(l)}</strong>
<span class="us-my-lesson-when">${escHtml(dayLabel(dayKey(l.start_dt)))} · ${escHtml(timeOf(l.start_dt))}${escHtml(timeOf(l.end_dt))}</span>
</div>
<div class="us-my-lesson-actions">
+6
View File
@@ -17,6 +17,10 @@
// enrolment controls.
const singleOfferingId = Number(app.dataset.offering || 0);
// Who this account may enrol — children first, the account holder last, so a
// guardian's default selection is a child. One entry means no picker.
const students = window.usGuardian.parseStudents(app.dataset.students);
function apiFetch(path, options = {}) {
return fetch(restUrl + path, {
...options,
@@ -202,6 +206,7 @@
<div class="us-register">
<h3>${escHtml(offering.title)}</h3>
<form id="us-enrol-form">
${window.usGuardian.selectorHtml(students, 'us-enrol-student')}
${questions.map(questionField).join('')}
${policies.map(policyField).join('')}
${window.usPricing.summaryHtml(offering)}
@@ -239,6 +244,7 @@
method: 'POST',
body: JSON.stringify({
offering_id: offering.id,
student_id: window.usGuardian.selectedId('us-enrol-student'),
answers,
accepted_policy_version_ids: accepted,
}),
+68
View File
@@ -0,0 +1,68 @@
/**
* "Who is this for?" picker, shared by the lesson-booking and group-class
* registration forms.
*
* The list arrives from the server already ordered children-first, with the
* account holder last, and this module preserves that order: a guardian's
* default selection is their first child, never themselves. Booking for the
* wrong child is a correctable mistake; quietly enrolling the parent in a class
* meant for their kid is not.
*/
(function () {
'use strict';
function escHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/**
* Read the server-rendered student list off a `data-students` attribute.
* Anything unparseable degrades to an empty list, which renders no picker
* and books for the signed-in user — the pre-guardian behaviour.
*/
function parseStudents(raw) {
if (!raw) return [];
try {
const list = JSON.parse(raw);
return Array.isArray(list) ? list : [];
} catch (e) {
return [];
}
}
/**
* The picker's markup, or an empty string when there is nothing to choose:
* an account with only itself on the list never sees the question.
*/
function selectorHtml(students, id) {
if (!students || students.length < 2) return '';
const options = students.map((s) => {
// The account holder reads as "Myself" — their own name next to their
// children's is ambiguous about which row is the parent.
const label = s.is_self ? `Myself (${s.name})` : s.name;
return `<option value="${Number(s.id)}">${escHtml(label)}</option>`;
}).join('');
return `
<p class="us-student-picker">
<label for="${id}">Who is this for?<br>
<select id="${id}" required>${options}</select></label>
</p>`;
}
/**
* The chosen student id, or 0 when no picker was rendered — the server
* reads 0 as "the caller books for themselves".
*/
function selectedId(id) {
const el = document.getElementById(id);
return el ? Number(el.value) || 0 : 0;
}
window.usGuardian = { parseStudents, selectorHtml, selectedId };
}());
+112 -10
View File
@@ -1,23 +1,31 @@
/**
* Progressive enhancement for the two-step student registration form.
* Progressive enhancement for the student registration form.
*
* When account-signup questions are configured the form renders two panels
* (`[data-step="1"]` account details, `[data-step="2"]` the questions) inside a
* single form marked `data-steps="1"`. This script hides step two behind a
* "Next" button that only advances once step one passes native validation.
* Without JS both panels stay visible and the single submit still works.
* Two independent behaviours, both optional — without JS every panel stays
* visible and the single submit still works:
*
* 1. **Two steps.** When account-signup questions are configured the form
* renders two panels (`[data-step="1"]` account details, `[data-step="2"]`
* the questions) inside a form marked `data-steps="1"`. Step two is hidden
* behind a "Next" button that only advances once step one passes native
* validation.
* 2. **Parent/guardian.** The children section is hidden until the
* parent/guardian box is ticked, and "Add another child" clones the child
* block. Ticking the box also takes the guardian's *own* question panel out
* of play — in guardian mode the questions are asked per child, so the
* server ignores those answers and the browser must not demand them.
*/
(function () {
'use strict';
function enhance(form) {
function enhanceSteps(form) {
var step1 = form.querySelector('[data-step="1"]');
var step2 = form.querySelector('[data-step="2"]');
var next = form.querySelector('.us-reg-next');
var back = form.querySelector('.us-reg-back');
if (!step1 || !step2 || !next) {
return;
return null;
}
function show(step) {
@@ -45,13 +53,107 @@
show(1);
});
}
return {
step2: step2,
next: next,
earlySubmit: form.querySelector('.us-reg-submit-early'),
};
}
/**
* Rewrite a cloned child block's `children[0][…]` names and ids to the new
* index, and clear the values carried over from the block it was cloned from.
*/
function reindex(block, index) {
block.setAttribute('data-child-index', String(index));
var fields = block.querySelectorAll('input, select, textarea');
for (var i = 0; i < fields.length; i++) {
var field = fields[i];
if (field.name) {
field.name = field.name.replace(/^children\[\d+\]/, 'children[' + index + ']');
}
var oldId = field.id;
if (oldId) {
field.id = oldId.replace(/^us-child-\d+-/, 'us-child-' + index + '-');
var label = block.querySelector('label[for="' + oldId + '"]');
if (label) {
label.setAttribute('for', field.id);
}
}
if (field.type === 'checkbox' || field.type === 'radio') {
field.checked = false;
} else {
field.value = '';
}
}
}
function enhanceGuardian(form, steps) {
var toggle = form.querySelector('#us-is-guardian');
var children = form.querySelector('#us-children');
if (!toggle || !children) {
return;
}
var addButton = children.querySelector('.us-add-child');
var nextIndex = 1;
// The guardian's own question panel is only meaningful when they are
// registering for themselves. Disabling it (rather than hiding it) is what
// stops a `required` question the server will ignore from blocking submit.
function sync() {
children.hidden = !toggle.checked;
if (!steps) {
return;
}
var fields = steps.step2.querySelectorAll('input, select, textarea');
for (var i = 0; i < fields.length; i++) {
fields[i].disabled = toggle.checked;
}
// With the questions out of play there is no second step to advance to,
// so "Next" would be a dead end — swap it for the submit.
steps.next.hidden = toggle.checked;
if (steps.earlySubmit) {
steps.earlySubmit.hidden = !toggle.checked;
}
}
toggle.addEventListener('change', sync);
sync();
if (addButton) {
addButton.addEventListener('click', function () {
var blocks = children.querySelectorAll('.us-child');
var clone = blocks[blocks.length - 1].cloneNode(true);
reindex(clone, nextIndex);
nextIndex += 1;
children.insertBefore(clone, addButton.parentNode);
});
}
}
document.addEventListener('DOMContentLoaded', function () {
var forms = document.querySelectorAll('.us-register-form form[data-steps="1"]');
var forms = document.querySelectorAll('.us-register-form form');
for (var i = 0; i < forms.length; i++) {
enhance(forms[i]);
var steps = forms[i].getAttribute('data-steps') === '1'
? enhanceSteps(forms[i])
: null;
enhanceGuardian(forms[i], steps);
}
});
})();