diff --git a/CHANGELOG.md b/CHANGELOG.md index 960b654..2931fbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,13 @@ each change under the current top section as you work. ## [1.2.3] +### Changed +- A **monthly group class is now billed its price once per month**, however many times the class meets in that month. Previously the monthly charge multiplied the price by the number of sessions in the month — a class priced at `40.00 CAD` meeting weekly was billed `160.00 CAD` on the 1st — which no studio could quote honestly on a class card. A monthly **private lesson** is unchanged: its price is a per-lesson fee and the month is still billed one fee per lesson, which is why it is quoted per lesson. Studios running a monthly group class should check the class price now reads as the monthly fee they intend to charge. + +### Added +- Every price a student sees now says **when** it is due. Lesson types in the booking form read `50.00 CAD at booking`, and group-class cards read `120.00 CAD up front`, `40.00 CAD weekly` or `40.00 CAD monthly` — the offering's billing mode, in the student's words. A monthly **private lesson** is quoted per lesson (`50.00 CAD per lesson monthly`), since its monthly charge covers every lesson booked that month; a monthly group class is quoted as the monthly figure it is. A free offering still just reads **Free**. +- Booking a lesson and enrolling in a class now take a **second confirmation that the student agrees to pay**. Above the Confirm button the form restates the price with its cadence, spells out how it is collected ("Charged on the 1st of each month, for that month's lessons"), adds the studio's HST so the figure matches the total actually billed, and requires a tick on "I agree to pay 56.50 CAD at booking." before it will submit — separate from, and in addition to, the studio policies the student accepts above it. Reserving a time weekly quotes the per-lesson fee and the most it can add up to ("up to 12 lessons, 678.00 CAD in total"), since a week another student takes first is simply not booked. Free offerings have nothing to agree to and show no price block. + ## [1.2.2] ### Added diff --git a/assets/css/frontend.css b/assets/css/frontend.css index 385f200..c22cf69 100644 --- a/assets/css/frontend.css +++ b/assets/css/frontend.css @@ -241,6 +241,47 @@ opacity: 0.4; } +/* The price and pay agreement on a booking / enrolment form. */ +.us-price { + border: 1px solid #ddd; + border-radius: 4px; + padding: 12px 16px; + margin: 16px 0; +} + +.us-price h4 { + margin: 0 0 8px; +} + +.us-price p { + margin: 0 0 4px; +} + +.us-price-amount strong { + font-size: 1.15em; +} + +.us-price-cadence { + margin-left: 4px; +} + +.us-price-tax, +.us-price-note { + font-size: 0.9em; + opacity: 0.8; +} + +.us-price-agree { + display: block; + margin-top: 12px; + font-weight: 600; +} + +/* The cadence-carrying price on a group-class card. */ +.us-class-price { + font-weight: 600; +} + @media (max-width: 640px) { .us-week-grid { grid-template-columns: 1fr; diff --git a/assets/js/booking.js b/assets/js/booking.js index 4a8fa2b..11261a9 100644 --- a/assets/js/booking.js +++ b/assets/js/booking.js @@ -360,13 +360,27 @@ `; } - // "Piano Lesson (60 min — $50.00 CAD)" / "Trial Lesson (Free)" + // "Piano Lesson (60 min — 50.00 CAD at booking)" / "Trial Lesson (Free)" function offeringLabel(o) { const duration = o.duration_minutes ? `${o.duration_minutes} min — ` : ''; - const price = Number(o.price) > 0 - ? `$${Number(o.price).toFixed(2)} ${o.currency}` - : 'Free'; - return `${o.title} (${duration}${price})`; + return `${o.title} (${duration}${window.usPricing.priceLabel(o)})`; + } + + // How many lessons a weekly reservation can claim, mirroring + // BookingEndpoint::MAX_WEEKLY_OCCURRENCES so the quoted total is never + // higher than the server will actually charge for. + const MAX_WEEKLY_OCCURRENCES = 12; + + // The open times a weekly reservation of this slot would claim: every + // still-unbooked slot of its recurring group, capped the way the server + // caps it. Some may be taken by another student first, so this is the + // upper bound on what will be booked, not a guarantee. + function weeklyOccurrences(slot) { + if (!slot.recurrence_group) return 1; + + const inGroup = allSlots.filter((s) => s.recurrence_group === slot.recurrence_group).length; + + return Math.min(Math.max(inGroup, 1), MAX_WEEKLY_OCCURRENCES); } function openRegistration(slot) { @@ -443,6 +457,7 @@
${policies.map(policyField).join('')} ${weekly} +@@ -458,6 +473,27 @@ let questions = []; const questionsBox = document.getElementById('us-questions'); + const priceBox = document.getElementById('us-price-summary'); + const weeklyEl = document.getElementById('us-weekly'); + + // What the booking will cost and the agreement to pay it, restated + // whenever the choices that decide the amount change: the lesson type + // carries the price, and a weekly reservation multiplies a per-lesson + // one-time price by every week it claims. A slot tied to a type the + // catalog no longer carries has no price to quote, so it shows nothing + // rather than a figure it cannot stand behind. + function renderPrice() { + const offering = selectedId ? catalog.find((o) => Number(o.id) === selectedId) : null; + priceBox.innerHTML = offering + ? window.usPricing.summaryHtml({ + price: offering.price, + currency: offering.currency, + billing_mode: offering.billing_mode, + kind: offering.kind, + occurrences: weeklyEl && weeklyEl.checked ? weeklyOccurrences(slot) : 1, + }) + : ''; + } function loadQuestions() { questions = []; @@ -475,10 +511,14 @@ document.getElementById('us-offering').addEventListener('change', (e) => { selectedId = Number(e.target.value) || 0; loadQuestions(); + renderPrice(); }); } + if (weeklyEl) weeklyEl.addEventListener('change', renderPrice); + loadQuestions(); + renderPrice(); document.getElementById('us-cancel').addEventListener('click', loadSlots); document.getElementById('us-register-form').addEventListener('submit', (e) => { @@ -487,6 +527,10 @@ showError('Please choose a lesson type.'); return; } + if (!window.usPricing.agreed(e.target)) { + showError(window.usPricing.AGREE_REQUIRED); + return; + } submitBooking(e.target, slot, selectedId, questions); }); } diff --git a/assets/js/group-classes.js b/assets/js/group-classes.js index e770c97..e43248a 100644 --- a/assets/js/group-classes.js +++ b/assets/js/group-classes.js @@ -152,7 +152,7 @@ ${o.instructor_name ? `
With ${escHtml(o.instructor_name)}
` : ''} ${o.schedule_note ? `${escHtml(o.schedule_note)}
` : ''} ${!singleOfferingId && o.description ? `${escHtml(o.description)}
` : ''} -${escHtml(Number(o.price).toFixed(2))} ${escHtml(o.currency)}
+${escHtml(window.usPricing.priceLabel(o))}
${!enrolledMap.has(Number(o.id)) && isEnrollmentOpen(o) && enrolmentDeadline(o) ? `Enrol by ${escHtml(formatDate(enrolmentDeadline(o)))}
` : ''} @@ -204,6 +204,7 @@