1import { Controller } from "@hotwired/stimulus" 2 3export default class extends Controller { 4 static targets = [ 5 "fulfillmentType", "deliveryFields", 6 "tipPercent", "customTip", "customTipRadio", 7 "tipAmount", "serviceFee", "serviceFeeRow", "total", 8 "deliveryRow", "deliveryAmount", "deliveryLabel", 9 "discountCode", "applyButton", "discountMessage", "discountRow", "discountAmount", 10 "specialsRow", "specialsAmount", 11 "taxRow", "taxAmount", "subtotalAmount", 12 "freeItemRow", "freeItemName", "freeItemPrice", 13 "emailField", 14 "deliveryPhone", "deliveryLine1", "deliveryLine2", "deliveryCity", "deliveryState", "deliveryPostal", 15 "quoteButton", "quoteMessage", "shippingMethod" 16 ] 17 18 static values = { 19 subtotalCents: Number, 20 discountCents: Number, 21 specialsDiscountCents: Number, 22 taxCents: Number, 23 deliveryFeeCents: Number, 24 serviceFeeIncluded: Boolean, 25 serviceFeeTiers: Object, 26 signedIn: Boolean, 27 previewDiscountUrl: String, 28 emailCheckUrl: String, 29 uberDirect: Boolean, 30 deliveryQuoteUrl: String, 31 quoteRequired: Boolean, 32 shippingMode: Boolean, 33 shippingMethods: Array, 34 shippingFreeOverCents: Number, 35 freeShipping: Boolean 36 } 37 38 connect() { 39 this.emailExists = false 40 this.signinPrompted = false 41 this.quoteOk = !this.quoteRequiredValue 42 this.baseSubtotalCents = this.subtotalCentsValue 43 this.toggleDelivery() 44 this.refreshShippingFee() 45 this.recalculate() 46 } 47 48 onFulfillmentChange() { 49 this.toggleDelivery() 50 if (this.uberDirectValue && this.isDelivery()) { 51 this.quoteOk = false 52 this.setQuoteMessage("") 53 } else { 54 this.quoteOk = !this.quoteRequiredValue || !this.isDelivery() 55 } 56 this.refreshShippingFee() 57 this.recalculate() 58 } 59 60 onShippingMethodChange() { 61 this.refreshShippingFee() 62 this.recalculate() 63 } 64 65 toggleDelivery() { 66 if (!this.hasDeliveryFieldsTarget) return 67 const show = this.isDelivery() || this.shippingModeValue 68 this.deliveryFieldsTarget.classList.toggle("hidden", !show) 69 } 70 71 isDelivery() { 72 return this.hasFulfillmentTypeTarget && this.fulfillmentTypeTarget.value === "delivery" 73 } 74 75 isShipping() { 76 return this.shippingModeValue || (this.hasFulfillmentTypeTarget && this.fulfillmentTypeTarget.value === "shipping") 77 } 78 79 onTipChange(event) { 80 const radio = event.target 81 if (radio.value === "percent" && this.hasTipPercentTarget) { 82 this.tipPercentTarget.value = radio.dataset.tipPercent || "0" 83 } 84 this.recalculate() 85 } 86 87 onCustomTipInput() { 88 if (this.hasCustomTipRadioTarget && !this.customTipRadioTarget.checked) { 89 this.customTipRadioTarget.checked = true 90 } 91 this.recalculate() 92 } 93 94 selectedTipType() { 95 const checked = this.element.querySelector('input[name="checkout[tip_type]"]:checked') 96 return checked ? checked.value : "none" 97 } 98 99 tipCents(taxableSubtotal) { 100 if (this.shippingModeValue) return 0 101 102 switch (this.selectedTipType()) { 103 case "percent": { 104 const pct = this.hasTipPercentTarget ? parseFloat(this.tipPercentTarget.value) || 0 : 0 105 return Math.round((taxableSubtotal * pct) / 100) 106 } 107 case "custom": { 108 const dollars = this.hasCustomTipTarget ? parseFloat(this.customTipTarget.value) || 0 : 0 109 return Math.round(dollars * 100) 110 } 111 default: 112 return 0 113 } 114 } 115 116 serviceFeeCents(serviceFeeBase) { 117 const tiers = this.serviceFeeTiersValue || {} 118 const threshold = Number(tiers.threshold_cents) || 2000 119 const lowPercent = Number(tiers.low_percent) || 5 120 const lowFixed = Number(tiers.low_fixed_cents) || 50 121 const highPercent = Number(tiers.high_percent) || 6.5 122 123 if (serviceFeeBase < threshold) { 124 return Math.round((serviceFeeBase * lowPercent) / 100) + lowFixed 125 } 126 return Math.round((serviceFeeBase * highPercent) / 100) 127 } 128 129 selectedShippingMethodName() { 130 if (this.hasShippingMethodTarget) return this.shippingMethodTarget.value 131 const methods = this.shippingMethodsValue || [] 132 return methods[0]?.name || "" 133 } 134 135 refreshShippingFee() { 136 if (!this.isShipping()) return 137 138 if (this.freeShippingValue) { 139 this.deliveryFeeCentsValue = 0 140 return 141 } 142 143 const freeOver = Number(this.shippingFreeOverCentsValue) || 0 144 if (freeOver > 0 && this.subtotalCentsValue >= freeOver) { 145 this.deliveryFeeCentsValue = 0 146 return 147 } 148 149 const methods = this.shippingMethodsValue || [] 150 const name = this.selectedShippingMethodName() 151 const method = methods.find((m) => m.name === name) || methods[0]
152 if (!method) { 153 this.deliveryFeeCentsValue = 0 154 return 155 } 156 157 const tiers = Array.isArray(method.tiers) ? method.tiers : [] 158 const matching = tiers.filter((t) => this.subtotalCentsValue >= Number(t.min_subtotal_cents || 0)) 159 const tier = matching.length 160 ? matching.reduce((best, t) => (Number(t.min_subtotal_cents) >= Number(best.min_subtotal_cents) ? t : best)) 161 : tiers[0] 162 this.deliveryFeeCentsValue = Number(tier?.fee_cents) || 0 163 } 164 165 recalculate() { 166 const combinedDiscount = this.discountCentsValue + (this.specialsDiscountCentsValue || 0) 167 const taxableSubtotal = Math.max(this.subtotalCentsValue - combinedDiscount, 0) 168 const tip = this.tipCents(taxableSubtotal) 169 const charged = this.isDelivery() || this.isShipping() 170 const delivery = charged ? this.deliveryFeeCentsValue : 0 171 172 // Service fee excludes delivery/shipping (matches server CalculateTotalsService). 173 const serviceFeeBase = taxableSubtotal + this.taxCentsValue + tip 174 const preFee = serviceFeeBase + delivery 175 const serviceFee = this.serviceFeeCents(serviceFeeBase) 176 const total = this.serviceFeeIncludedValue ? preFee : preFee + serviceFee 177 const showServiceFee = !this.serviceFeeIncludedValue 178 179 if (this.hasSpecialsRowTarget) { 180 this.specialsRowTarget.classList.toggle("hidden", (this.specialsDiscountCentsValue || 0) <= 0) 181 if (this.hasSpecialsAmountTarget) { 182 this.specialsAmountTarget.textContent = `\u2212${this.format(this.specialsDiscountCentsValue || 0)}` 183 } 184 } 185 if (this.hasDiscountRowTarget) { 186 this.discountRowTarget.classList.toggle("hidden", this.discountCentsValue <= 0) 187 if (this.hasDiscountAmountTarget) this.discountAmountTarget.textContent = `\u2212${this.format(this.discountCentsValue)}` 188 } 189 if (this.hasSubtotalAmountTarget) { 190 this.subtotalAmountTarget.textContent = this.format(this.subtotalCentsValue) 191 } 192 if (this.hasTaxRowTarget) { 193 this.taxRowTarget.classList.toggle("hidden", this.taxCentsValue <= 0) 194 if (this.hasTaxAmountTarget) this.taxAmountTarget.textContent = this.format(this.taxCentsValue) 195 } 196 if (this.hasDeliveryRowTarget) { 197 this.deliveryRowTarget.classList.toggle("hidden", !charged && delivery <= 0) 198 if (this.hasDeliveryLabelTarget) { 199 this.deliveryLabelTarget.textContent = this.isShipping() ? "Shipping" : "Delivery" 200 } 201 if (this.hasDeliveryAmountTarget) this.deliveryAmountTarget.textContent = this.format(delivery) 202 } 203 if (this.hasTipAmountTarget) this.tipAmountTarget.textContent = this.format(tip) 204 if (this.hasServiceFeeRowTarget) { 205 this.serviceFeeRowTarget.classList.toggle("hidden", !showServiceFee) 206 } 207 if (this.hasServiceFeeTarget) this.serviceFeeTarget.textContent = this.format(serviceFee) 208 if (this.hasTotalTarget) this.totalTarget.textContent = this.format(total) 209 } 210 211 async requestDeliveryQuote(event) { 212 if (event) event.preventDefault() 213 if (this.shippingModeValue) return 214 if (!this.uberDirectValue || !this.isDelivery() || !this.deliveryQuoteUrlValue) return 215 216 const line1 = this.hasDeliveryLine1Target ? this.deliveryLine1Target.value.trim() : "" 217 const city = this.hasDeliveryCityTarget ? this.deliveryCityTarget.value.trim() : "" 218 const state = this.hasDeliveryStateTarget ? this.deliveryStateTarget.value.trim() : "" 219 const postal = this.hasDeliveryPostalTarget ? this.deliveryPostalTarget.value.trim() : "" 220 if (!line1 || !city || !state || !postal) { 221 this.quoteOk = false 222 this.setQuoteMessage("Enter a complete address to see delivery price.", false) 223 return 224 } 225 226 if (this.hasQuoteButtonTarget) { 227 this.quoteButtonTarget.disabled = true 228 this.quoteButtonTarget.classList.add("loading") 229 } 230 this.setQuoteMessage("Checking deliveryâ¦") 231 232 try { 233 const response = await fetch(this.deliveryQuoteUrlValue, { 234 method: "POST", 235 headers: { 236 "Content-Type": "application/json", 237 "Accept": "application/json", 238 "X-CSRF-Token": this.csrfToken() 239 }, 240 body: JSON.stringify({ 241 line1, 242 line2: this.hasDeliveryLine2Target ? this.deliveryLine2Target.value.trim() : "", 243 city, 244 state, 245 postal_code: postal, 246 phone: this.hasDeliveryPhoneTarget ? this.deliveryPhoneTarget.value.trim() : "" 247 }) 248 }) 249 const data = await response.json() 250 if (!response.ok || data.error) { 251 this.quoteOk = false 252 this.deliveryFeeCentsValue = 0 253 this.setQuoteMessage(data.error || "Delivery is not available to this address.", false) 254 this.recalculate() 255 return 256 } 257 258 this.quoteOk = true 259 this.deliveryFeeCentsValue = Number(data.delivery_fee_cents) || 0 260 const eta = data.duration_minutes ? ` · ~${data.duration_minutes} min` : "" 261 this.setQuoteMessage(`Delivery ${data.delivery_fee_formatted || this.format(this.deliveryFeeCentsValue)}${eta}`, true) 262 this.recalculate() 263 } catch (_error) { 264 this.quoteOk = false 265 this.setQuoteMessage("Couldn't check delivery. Try again.", false) 266 } finally { 267 if (this.hasQuoteButtonTarget) { 268 this.quoteButtonTarget.disabled = false 269 this.quoteButtonTarget.classList.remove("loading") 270 } 271 } 272 } 273 274 setQuoteMessage(message, ok = null) { 275 if (!this.hasQuoteMessageTarget) return 276 this.quoteMessageTarget.textContent = message || "" 277 this.quoteMessageTarget.classList.toggle("text-success", ok === true) 278 this.quoteMessageTarget.classList.toggle("text-error", ok === false) 279 } 280 281 async applyDiscount(event) { 282 if (event) event.preventDefault() 283 if (!this.hasDiscountCodeTarget || !this.previewDiscountUrlValue) return 284 285 const code = this.discountCodeTarget.value.trim() 286 this.setApplyLoading(true) 287 288 try { 289 const response = await fetch(this.previewDiscountUrlValue, { 290 method: "POST", 291 headers: { 292 "Content-Type": "application/json", 293 "Accept": "application/json", 294 "X-CSRF-Token": this.csrfToken() 295 }, 296 body: JSON.stringify({ 297 checkout: { 298 discount_code: code, 299 fulfillment_type: this.hasFulfillmentTypeTarget ? this.fulfillmentTypeTarget.value : "", 300 shipping_method_name: this.selectedShippingMethodName() 301 } 302 }) 303 }) 304 const data = await response.json() 305 const freeItem = data.free_item 306 this.taxCentsValue = Number(data.tax_cents) || 0 307 this.freeShippingValue = Boolean(data.free_shipping)
308 if (typeof data.delivery_fee_cents === "number") { 309 this.deliveryFeeCentsValue = data.delivery_fee_cents 310 } 311 if (freeItem && freeItem.name) { 312 this.discountCentsValue = 0 313 this.subtotalCentsValue = this.baseSubtotalCents 314 } else { 315 this.discountCentsValue = Number(data.discount_cents) || 0 316 this.subtotalCentsValue = this.baseSubtotalCents 317 } 318 this.refreshShippingFee() 319 this.showFreeItem(freeItem) 320 this.showDiscountMessage(data.valid, data.message) 321 this.recalculate() 322 } catch (_error) { 323 this.showDiscountMessage(false, "Couldn't apply the code. Please try again.") 324 } finally { 325 this.setApplyLoading(false) 326 } 327 } 328 329 setApplyLoading(loading) { 330 if (!this.hasApplyButtonTarget) return 331 this.applyButtonTarget.disabled = loading 332 this.applyButtonTarget.classList.toggle("loading", loading) 333 } 334 335 showFreeItem(item) { 336 if (!this.hasFreeItemRowTarget) return 337 338 const show = Boolean(item && item.name) 339 this.freeItemRowTarget.classList.toggle("hidden", !show) 340 if (this.hasFreeItemNameTarget) { 341 this.freeItemNameTarget.textContent = show ? item.name : "" 342 } 343 if (this.hasFreeItemPriceTarget) { 344 const cents = Number(item && item.price_cents) || 0 345 this.freeItemPriceTarget.textContent = cents > 0 ? this.format(cents) : "" 346 this.freeItemPriceTarget.classList.toggle("hidden", !show || cents <= 0) 347 } 348 } 349 350 showDiscountMessage(valid, message) { 351 if (!this.hasDiscountMessageTarget) return 352 if (!message) { 353 this.discountMessageTarget.classList.add("hidden") 354 return 355 } 356 this.discountMessageTarget.textContent = message 357 this.discountMessageTarget.classList.remove("hidden") 358 this.discountMessageTarget.classList.toggle("text-success", valid) 359 this.discountMessageTarget.classList.toggle("text-error", !valid) 360 } 361 362 async checkEmail() { 363 if (this.signedInValue || !this.hasEmailFieldTarget || !this.emailCheckUrlValue) return 364 365 const email = this.emailFieldTarget.value.trim() 366 if (!email || !email.includes("@")) { 367 this.emailExists = false 368 return 369 } 370 371 try { 372 const url = `${this.emailCheckUrlValue}?email=${encodeURIComponent(email)}` 373 const response = await fetch(url, { headers: { Accept: "application/json" } }) 374 const data = await response.json() 375 this.emailExists = Boolean(data.exists) 376 if (this.emailExists && !this.signinPrompted) this.openSigninDialog(email) 377 } catch (_error) { 378 this.emailExists = false 379 } 380 } 381 382 onSubmit(event) { 383 if (this.quoteRequiredValue && this.isDelivery() && !this.quoteOk) { 384 event.preventDefault() 385 event.stopPropagation() 386 this.setQuoteMessage("Check delivery price before paying.", false) 387 this.requestDeliveryQuote() 388 return 389 } 390 391 if (this.signedInValue || !this.emailExists || this.signinPrompted) return 392 393 event.preventDefault() 394 event.stopPropagation() 395 this.openSigninDialog(this.hasEmailFieldTarget ? this.emailFieldTarget.value.trim() : "") 396 } 397 398 openSigninDialog(email) { 399 const dialog = document.getElementById("checkout-signin-dialog") 400 if (!dialog || typeof dialog.showModal !== "function") return 401 402 this.signinPrompted = true 403 const emailInput = dialog.querySelector("#checkout-signin-email") 404 if (emailInput) emailInput.value = email 405 dialog.showModal() 406 } 407 408 csrfToken() { 409 const meta = document.querySelector('meta[name="csrf-token"]') 410 return meta ? meta.content : "" 411 } 412 413 format(cents) { 414 return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(cents / 100) 415 } 416};
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.