1import { Controller } from "@hotwired/stimulus" 2 3// Party-size stepper + per-guest detail rows for event registration forms. 4// The stepper is the source of truth for seats: guest rows are added and 5// removed to match it, and blank rows still register (and bill) as "Guest". 6export default class extends Controller { 7 static targets = [ 8 "rows", "rowTemplate", "row", "rowsSection", 9 "count", "incrementButton", "decrementButton", 10 "removeButton", 11 "summary", "ticketSelect", "mealSelect", "addOn", 12 "pricingSelect", "billingSelect" 13 ] 14 static values = { 15 compedRegistration: { type: Boolean, default: false }, 16 maxGuests: { type: Number, default: 10 } 17 } 18 19 connect() { 20 this.nextIndex = Date.now() 21 this.removedValues = [] 22 this.sponsorshipSelectionActive = this.sponsorshipSelected() 23 this.selectedTicketValue = this.ticketSelectionValue() 24 this.regularPartySize = this.sponsorshipSelectionActive ? 1 : this.partySize() 25 // "invalid" doesn't bubble, so capture it at the form to pop open any 26 // collapsed <details> hiding the offending field â otherwise native 27 // validation silently blocks the submit. 28 this.openDetailsOnInvalid = (event) => { 29 let details = event.target.closest("details") 30 while (details) { 31 details.open = true 32 details = details.parentElement?.closest("details") 33 } 34 } 35 this.element.addEventListener("invalid", this.openDetailsOnInvalid, true) 36 this.refresh() 37 } 38 39 disconnect() { 40 this.element.removeEventListener("invalid", this.openDetailsOnInvalid, true) 41 } 42 43 increment() { 44 if (this.partySize() >= this.maxPartySize()) return 45 46 this.appendRow() 47 this.refresh() 48 } 49 50 decrement() { 51 const rows = this.rowTargets 52 if (rows.length === 0) return 53 54 this.stashAndRemove(rows[rows.length - 1]) 55 this.refresh() 56 } 57 58 removeRow(event) { 59 // Explicit removal is not stashed: restoring an un-invited guest into the 60 // next added row would silently re-register (and bill) them. 61 event.target.closest("[data-guest-seats-target~='row']")?.remove() 62 this.refresh() 63 } 64 65 revealEmail(event) { 66 const row = event.target.closest("[data-guest-seats-target~='row']") 67 if (!row) return 68 69 const wrap = row.querySelector("[data-guest-seats-email-wrap]") 70 const toggle = row.querySelector("[data-guest-seats-email-toggle]") 71 wrap?.classList.remove("hidden") 72 toggle?.classList.add("hidden") 73 wrap?.querySelector("input")?.focus() 74 } 75 76 refresh() { 77 this.syncPartySizeForSelection() 78 79 const rows = this.rowTargets 80 const size = this.partySize() 81 82 if (this.hasCountTarget) this.countTarget.textContent = String(size) 83 if (this.hasIncrementButtonTarget) this.incrementButtonTarget.disabled = size >= this.maxPartySize() 84 if (this.hasDecrementButtonTarget) this.decrementButtonTarget.disabled = size <= 1 85 if (this.hasRowsSectionTarget) this.rowsSectionTarget.classList.toggle("hidden", rows.length === 0) 86 this.removeButtonTargets.forEach((button) => button.classList.remove("hidden")) 87 88 rows.forEach((row, index) => { 89 const label = row.querySelector("[data-guest-seats-row-label]") 90 if (label) label.textContent = `Guest ${index + 1}` 91 }) 92 93 this.renderSummary(size) 94 } 95 96 partySize() { 97 return 1 + this.rowTargets.length 98 } 99 100 appendRow() { 101 const html = this.rowTemplateTarget.innerHTML.replaceAll("__INDEX__", String(this.nextIndex++)) 102 this.rowsTarget.insertAdjacentHTML("beforeend", html) 103 104 const restored = this.removedValues.pop() 105 if (restored) this.applyValues(this.rowTargets[this.rowTargets.length - 1], restored) 106 } 107 108 syncPartySizeForSelection() { 109 const sponsorship = this.sponsorshipSelected() 110 const ticketValue = this.ticketSelectionValue() 111 const selectionChanged = ticketValue !== this.selectedTicketValue 112 113 if (!this.sponsorshipSelectionActive && sponsorship) { 114 this.regularPartySize = this.partySize() 115 this.resizePartySize(this.defaultPartySize(), true) 116 } else if (this.sponsorshipSelectionActive && !sponsorship) { 117 this.resizePartySize(this.regularPartySize, false) 118 } else if (sponsorship && selectionChanged) { 119 this.resizePartySize(this.defaultPartySize(), true) 120 } 121 122 this.sponsorshipSelectionActive = sponsorship 123 this.selectedTicketValue = ticketValue 124 } 125
126 resizePartySize(targetSize, stashRemoved) { 127 const size = Math.min(Math.max(targetSize, 1), this.maxPartySize()) 128 while (this.partySize() < size) this.appendRow() 129 while (this.partySize() > size) { 130 const row = this.rowTargets[this.rowTargets.length - 1] 131 if (stashRemoved) { 132 this.stashAndRemove(row) 133 } else { 134 row?.remove() 135 } 136 } 137 } 138 139 sponsorshipSelected() { 140 if (!this.hasTicketSelectTarget) return 0 141 142 const option = this.ticketSelectTarget.selectedOptions[0] 143 return option?.dataset.sponsorship === "true" 144 } 145 146 defaultPartySize() { 147 if (!this.hasTicketSelectTarget) return 1 148 149 const option = this.ticketSelectTarget.selectedOptions[0] 150 return Math.max(Number.parseInt(option?.dataset.defaultPartySize || "1", 10) || 1, 1) 151 } 152 153 ticketSelectionValue() { 154 if (!this.hasTicketSelectTarget) return "" 155 156 return this.ticketSelectTarget.value 157 } 158 159 maxPartySize() { 160 if (!this.hasTicketSelectTarget) return this.maxGuestsValue + 1 161 162 const option = this.ticketSelectTarget.selectedOptions[0] 163 return Math.max(Number.parseInt(option?.dataset.maxPartySize || "0", 10) || this.maxGuestsValue + 1, 1) 164 } 165 166 stashAndRemove(row) { 167 const values = this.valuesFor(row) 168 if (this.hasEnteredValues(values)) this.removedValues.push(values) 169 row.remove() 170 } 171 172 applyValues(row, values) { 173 if (!row) return 174 175 const firstNameInput = row.querySelector("input[name$='[first_name]']") 176 const lastNameInput = row.querySelector("input[name$='[last_name]']") 177 const emailInput = row.querySelector("input[name$='[email]']") 178 if (firstNameInput) firstNameInput.value = values.firstName 179 if (lastNameInput) lastNameInput.value = values.lastName 180 if (emailInput) emailInput.value = values.email 181 if (values.email.length > 0) { 182 row.querySelector("[data-guest-seats-email-wrap]")?.classList.remove("hidden") 183 row.querySelector("[data-guest-seats-email-toggle]")?.classList.add("hidden") 184 } 185 186 const mealSelect = row.querySelector("select[name$='[event_meal_choice_id]']") 187 if (mealSelect) mealSelect.value = values.mealChoiceId || "" 188 189 const attendeeFields = row.querySelector("[data-event-registration-questions-target~='attendeeFields']") 190 if (attendeeFields) attendeeFields.dataset.attendeeAnswers = JSON.stringify(values.registrationAnswers || {}) 191 } 192 193 valuesFor(row) { 194 const registrationAnswers = {} 195 row.querySelectorAll("input[name*='[registration_answers]'], select[name*='[registration_answers]'], textarea[name*='[registration_answers]']").forEach((input) => { 196 const key = input.name.match(/\[registration_answers\]\[([^\]]+)\]$/)?.[1] 197 if (!key) return 198 199 if (input.type === "checkbox") { 200 registrationAnswers[key] = input.checked ? input.value : "0" 201 } else if (input.type !== "hidden" || !Object.prototype.hasOwnProperty.call(registrationAnswers, key)) { 202 registrationAnswers[key] = input.value 203 } 204 }) 205 206 return { 207 firstName: row.querySelector("input[name$='[first_name]']")?.value || "", 208 lastName: row.querySelector("input[name$='[last_name]']")?.value || "", 209 email: row.querySelector("input[name$='[email]']")?.value || "", 210 mealChoiceId: row.querySelector("select[name$='[event_meal_choice_id]']")?.value || "", 211 registrationAnswers, 212 } 213 } 214 215 hasEnteredValues(values) { 216 return values.firstName.length > 0 || 217 values.lastName.length > 0 || 218 values.email.length > 0 || 219 values.mealChoiceId.length > 0 || 220 Object.values(values.registrationAnswers).some((value) => value.toString().length > 0 && value.toString() !== "0") 221 } 222 223 renderSummary(size) { 224 if (!this.hasSummaryTarget || !this.hasTicketSelectTarget) return 225 226 const option = this.ticketSelectTarget.selectedOptions[0] 227 if (!option) { 228 this.summaryTarget.textContent = "" 229 return 230 } 231 232 const memberCents = Number.parseInt(option.dataset.memberCents || "0", 10) || 0 233 const nonMemberCents = Number.parseInt(option.dataset.nonmemberCents || "0", 10) || 0 234 const memberOnly = option.dataset.memberOnly === "true" 235 const dualPricing = option.dataset.dualPricing === "true" 236 const sponsorship = option.dataset.sponsorship === "true" 237 238 const tickets = size === 1 ? "1 ticket" : `${size} tickets` 239 if (this.compedRegistrationValue) { 240 const includedAdmissions = size === 1 ? "1 included admission" : `${size} included admissions` 241 this.summaryTarget.textContent = `${tickets} · ${includedAdmissions}` 242 return 243 } 244 245 if (sponsorship) { 246 const sponsorshipPrice = nonMemberCents === 0 ? "Free sponsorship" : `${this.formatTotal(nonMemberCents)} sponsorship` 247 this.summaryTarget.textContent = `${tickets} · ${sponsorshipPrice}` 248 return 249 } 250 251 const extras = this.extrasCents(size) 252 253 if (this.hasBillingSelectTarget && this.billingSelectTarget.value === "none") { 254 this.summaryTarget.textContent = tickets 255 return 256 } 257 258 let price 259 const pricingMode = this.hasPricingSelectTarget ? this.pricingSelectTarget.value : "auto" 260 if (pricingMode === "member" || memberOnly) { 261 price = this.formatTotal(memberCents * size + extras) 262 } else if (pricingMode === "non_member") { 263 price = this.formatTotal(nonMemberCents * size + extras) 264 } else if (dualPricing) { 265 price = `Member ${this.formatTotal(memberCents * size + extras)} / Non-member ${this.formatTotal(nonMemberCents * size + extras)}` 266 } else { 267 price = this.formatTotal(nonMemberCents * size + extras) 268 } 269 270 const suffix = this.hasBillingSelectTarget ? " before benefits" : "" 271 this.summaryTarget.textContent = `${tickets} · ${price}${suffix}` 272 } 273 274 // Each attendee meal is charged once; legacy all-party meal fields still 275 // multiply by party size. Add-ons are quantity x price. Mirrors 276 // EventRegistrationCheckoutService.total_amount_cents. 277 extrasCents(size) { 278 let extras = 0
279 this.mealSelectTargets.forEach((select) => { 280 const meal = select.selectedOptions[0] 281 const multiplier = select.name === "event_meal_choice_id" ? size : 1 282 extras += (Number.parseInt(meal?.dataset.priceCents || "0", 10) || 0) * multiplier 283 }) 284 this.addOnTargets.forEach((input) => { 285 const quantity = Math.max(Number.parseInt(input.value || "0", 10) || 0, 0) 286 const priceCents = Number.parseInt(input.dataset.priceCents || "0", 10) || 0 287 extras += quantity * priceCents 288 }) 289 return extras 290 } 291 292 formatTotal(cents) { 293 if (cents <= 0) return "Free" 294 295 const dollars = Math.floor(cents / 100) 296 const remainder = String(cents % 100).padStart(2, "0") 297 return `$${dollars}.${remainder}` 298 } 299}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.