Filter booking calendar slots by available lesson type
CI / Tests (PHP 8.1) (pull_request) Successful in 49s
CI / Tests (PHP 8.2) (pull_request) Successful in 51s
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 2m51s
CI / PHPStan (pull_request) Successful in 2m59s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m37s
CI / Build Plugin Zip (pull_request) Skipped
CI / Tests (PHP 8.1) (pull_request) Successful in 49s
CI / Tests (PHP 8.2) (pull_request) Successful in 51s
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 2m51s
CI / PHPStan (pull_request) Successful in 2m59s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m37s
CI / Build Plugin Zip (pull_request) Skipped
Not every open time 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. Students had no way to see that before clicking a time. The booking calendar now carries a lesson-type filter — a checkbox per active private-lesson type, fetched once from GET /offerings?kind=private_lesson. Ticking types narrows the calendar to the times bookable as one of them and re-anchors the week view on the earliest match. The registration form's Lesson type picker is narrowed the same way, and a lone remaining type is pre-selected with its intake questions loaded. Bookability is decided by offeringFitsSlot(), the client-side mirror of the rule POST /bookings enforces; the filter is a browsing aid and the server still validates every booking. No ticks means no filter, and the whole control is hidden when the studio offers fewer than two private-lesson types. Closes #117 Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -14,6 +14,7 @@ each change under the current top section as you work.
|
|||||||
## [1.2.2]
|
## [1.2.2]
|
||||||
|
|
||||||
### Added
|
### 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 **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.
|
- 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.
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
+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();
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user