PageSourceSearch

https://www.georgetowncoc.com/assets/controllers/guest_seats_controller-cf6c95ac.js

js georgetowncoc.com collected 2026-09-25 04:04:49 UTC 11,076 bytes, 299 lines download raw bytes

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.