diff --git a/CHANGELOG.md b/CHANGELOG.md index 960b654..36eea1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ each change under the current top section as you work. ## [1.2.3] +### 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 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..ed1c219 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,26 @@ 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, + occurrences: weeklyEl && weeklyEl.checked ? weeklyOccurrences(slot) : 1, + }) + : ''; + } function loadQuestions() { questions = []; @@ -475,10 +510,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 +526,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 @@