1document.addEventListener('alpine:init', () => { 2 // ... Register your other stores and components ... 3 if (window.appStores && window.appStores.modals) { 4 Alpine.store('modals', window.appStores.modals); 5 } 6 7 if (window.appStores && window.appStores.slideout) { 8 Alpine.store('slideout', window.appStores.slideout); 9 } 10 11 // Auto-open help slideout if ?help={topic} is in the URL or persisted from navigation. 12 // Optional ?anchor={id} (or location.hash) scrolls the help body to that section after open 13 // (help.php â helpBrowser scrollTo â e.g. suppliers-buy-prices). 14 const helpParams = new URLSearchParams(window.location.search); 15 const helpParam = helpParams.get('help'); 16 const helpAnchorParam = helpParams.get('anchor') 17 || (window.location.hash ? window.location.hash.replace(/^#/, '') : ''); 18 if (window.appStores && window.appStores.tutorial) { 19 Alpine.store('tutorial', window.appStores.tutorial); 20 } 21 const persistedTopic = sessionStorage.getItem('helpTopic'); 22 const autoOpenTopic = helpParam || persistedTopic; 23 24 if (persistedTopic) { 25 sessionStorage.removeItem('helpTopic'); 26 } 27 28 if (autoOpenTopic && window.appStores && window.appStores.slideout) { 29 queueMicrotask(() => { 30 let url = '/_default/admin/fetch/html/help.php?topic=' + encodeURIComponent(autoOpenTopic); 31 if (helpAnchorParam) { 32 url += '&anchor=' + encodeURIComponent(helpAnchorParam); 33 } 34 Alpine.store('slideout').open({ 35 url: url, 36 title: 'Help', 37 width: '700px' 38 }); 39 }); 40 41 // Strip ?help= / ?anchor= (and the hash when it was the anchor) so refresh doesn't re-open 42 if (helpParam || helpParams.has('anchor')) { 43 const cleanUrl = new URL(window.location); 44 cleanUrl.searchParams.delete('help'); 45 if (helpParams.has('anchor')) cleanUrl.searchParams.delete('anchor'); 46 // Only clear hash when it was the help deep-link (not some unrelated page anchor) 47 if (helpParam && !helpParams.has('anchor') && window.location.hash) { 48 cleanUrl.hash = ''; 49 } 50 window.history.replaceState(null, '', cleanUrl); 51 } 52 } 53 54 if (window.appStores && window.appStores.searchTags) { 55 Alpine.store('searchTags', window.appStores.searchTags); 56 Alpine.store('searchTags').initFromUrl('advanced-search-template'); 57 } 58 59 if (window.appStores && window.appStores.selection) { 60 Alpine.store('selection', window.appStores.selection); 61 } 62 63 if (window.appStores && window.appStores.notifications) { 64 Alpine.store('notifications', window.appStores.notifications); 65 } 66 67 // --- Register Global Components --- 68 69 // THE FIX FOR SUGGESTIONBOX: 70 // This tells Alpine: "When you see x-data='suggestionBox', 71 // use the function found at window.appComponents.suggestionBox." 72 if (window.appComponents && window.appComponents.suggestionBox) { 73 Alpine.data('suggestionBox', window.appComponents.suggestionBox); 74 } 75 76 // This correctly registers the progressBar component. 77 if (window.appComponents && window.appComponents.progressBar) { 78 Alpine.data('progressBar', window.appComponents.progressBar); 79 } 80 81 // Global bottom-right activity tray (background-job indicator). 82 if (window.appComponents && window.appComponents.activityTray) { 83 Alpine.data('activityTray', window.appComponents.activityTray); 84 } 85 86 if (window.appComponents && window.appComponents.successRedirect) { 87 Alpine.data('successRedirect', window.appComponents.successRedirect); 88 } 89 90 // Email-style modal dialogs with a TinyMCE body + template picker. 91 if (window.appComponents && window.appComponents.tinyMceModalForm) { 92 Alpine.data('tinyMceModalForm', window.appComponents.tinyMceModalForm); 93 } 94 95 if (window.appComponents && window.appComponents.advancedSearchSlideout) { 96 Alpine.data('advancedSearchSlideout', window.appComponents.advancedSearchSlideout); 97 } 98 99 if (window.appComponents && window.appComponents.searchTagsDisplay) { 100 Alpine.data('searchTagsDisplay', window.appComponents.searchTagsDisplay); 101 } 102 103 if (window.appComponents && window.appComponents.helpBrowser) { 104 Alpine.data('helpBrowser', window.appComponents.helpBrowser); 105 } 106 107 if (window.appComponents && window.appComponents.tutorialPlayer) { 108 Alpine.data('tutorialPlayer', window.appComponents.tutorialPlayer); 109 } 110 111 // Tutorial launcher (loaded into the slideout via htmx): a "copy link" per 112 // tour produces a shareable URL that auto-starts the tour for whoever opens 113 // it (?tutorial=<id> is read client-side in this file, so it survives a 114 // direct/new-tab load even though the front-controller ignores the query).
115 Alpine.data('tutorialLauncher', () => ({ 116 copyLink(id, ev) { 117 const url = location.origin + location.pathname + '?tutorial=' + encodeURIComponent(id); 118 const done = () => { 119 // The button holds a share glyph + a hidden tick; the `-copied` 120 // class swaps to the green tick (see tutorial.css), so we just 121 // toggle the class for a moment of confirmation. 122 const b = ev && ev.currentTarget; 123 if (!b) return; 124 b.classList.add('-copied'); 125 setTimeout(() => { b.classList.remove('-copied'); }, 1500); 126 }; 127 if (navigator.clipboard && window.isSecureContext) { 128 navigator.clipboard.writeText(url).then(done).catch(() => this._fallbackCopy(url, done)); 129 } else { 130 this._fallbackCopy(url, done); 131 } 132 }, 133 _fallbackCopy(url, done) { 134 try { 135 const ta = document.createElement('textarea'); 136 ta.value = url; 137 ta.style.position = 'fixed'; 138 ta.style.opacity = '0'; 139 document.body.appendChild(ta); 140 ta.focus(); ta.select(); 141 document.execCommand('copy'); 142 document.body.removeChild(ta); 143 done(); 144 } catch (e) {} 145 }, 146 })); 147 148 // Auto-start a tutorial if ?tutorial={module}/{tour} is in the URL. 149 // Lets a manager send a new starter a direct "start here" link. 150 const tutorialParam = new URLSearchParams(window.location.search).get('tutorial'); 151 if (tutorialParam && window.appStores && window.appStores.tutorial) { 152 queueMicrotask(() => { 153 Alpine.store('tutorial').start(tutorialParam); 154 }); 155 const cleanUrl = new URL(window.location); 156 cleanUrl.searchParams.delete('tutorial'); 157 window.history.replaceState(null, '', cleanUrl); 158 } 159 160 if (window.appComponents && window.appComponents.mediaPicker) { 161 Alpine.data('mediaPicker', window.appComponents.mediaPicker); 162 } 163 164 if (window.appComponents && window.appComponents.linkBrowser) { 165 Alpine.data('linkBrowser', window.appComponents.linkBrowser); 166 } 167 168 if (window.appComponents && window.appComponents.objectMap) { 169 Alpine.data('objectMap', window.appComponents.objectMap); 170 } 171 172 if (window.appComponents && window.appComponents.automationBrowse) { 173 Alpine.data('automationBrowse', window.appComponents.automationBrowse); 174 } 175 176 if (window.appComponents && window.appComponents.orderInvoiceMatch) { 177 Alpine.data('orderInvoiceMatch', window.appComponents.orderInvoiceMatch); 178 } 179 180 if (window.appComponents && window.appComponents.slideoutForm) { 181 Alpine.data('slideoutForm', window.appComponents.slideoutForm); 182 } 183 184 // Page-specific components that self-set window.appComponents (also re-bound by 185 // adminNavigate after SPA asset load). Register here for full page load when the 186 // component script ran before alpine:init. 187 if (window.appComponents && window.appComponents.fbaPlacementPage) { 188 Alpine.data('fbaPlacementPage', window.appComponents.fbaPlacementPage); 189 } 190 if (window.appComponents && window.appComponents.fbaVelocityWeights) { 191 Alpine.data('fbaVelocityWeights', window.appComponents.fbaVelocityWeights); 192 } 193 if (window.appComponents && window.appComponents.inventorySuppliers) { 194 Alpine.data('inventorySuppliers', window.appComponents.inventorySuppliers); 195 } 196 197 // Goods-in staged receive â per-line allocation staging component. 198 if (window.appComponents && window.appComponents.giLine) { 199 Alpine.data('giLine', window.appComponents.giLine); 200 } 201 202 // Live "valid in" / "Auto Sync in" countdown (job queue + FBA Price header). 203 if (window.appComponents && window.appComponents.countdown) { 204 Alpine.data('countdown', window.appComponents.countdown); 205 } 206 207 // Demand Forecast â greys the chart while its demand spine is being rebuilt. 208 if (window.appComponents && window.appComponents.forecastRebuild) { 209 Alpine.data('forecastRebuild', window.appComponents.forecastRebuild); 210 } 211 212 /** 213 * SRR "Email..." compose dialog â live read-only preview beneath the plain-text message box. 214 * The textarea stays the editable plain-text source (operators edit prose, no raw HTML); this 215 * renders exactly what the supplier will receive by mirroring the send handler's transform 216 * (srr_email_send.php): htmlspecialchars(ENT_QUOTES) -> linkify bare URLs -> nl2br. Keep the two 217 * in lock-step â if the PHP transform changes, change this too, or the preview lies. 218 */ 219 Alpine.data('srrEmailPreview', () => ({ 220 body: '', 221 init() { 222 const ta = this.$refs.body; 223 if (ta) this.body = ta.value; 224 }, 225 get html() { 226 const esc = String(this.body) 227 .replaceAll('&', '&') 228 .replaceAll('<', '<') 229 .replaceAll('>', '>') 230 .replaceAll('"', '"') 231 .replaceAll("'", '''); 232 // Lock-step with the send transform (srr_email_send.php) â a plain <a>, no 233 // target/rel, so the preview is exactly what the recipient receives. 234 const linked = esc.replace( 235 /(https?:\/\/[^\s<]+)/gi, 236 '<a href="$1">$1</a>' 237 ); 238 return linked.replace(/\n/g, '<br>'); 239 } 240 })); 241 242 /** 243 * FBA Restock â fan-out "Create Plans" preview (restock_plan_new.php GroupedView, SKU-source mode only). 244 * Groups the selection by resolved source warehouse into N swatch-headed sections; the operator can drag 245 * a SKU between sections via its "Ship From" picker (moves the live <tr>, pure client-side/transient) and 246 * flag "Make Default" to also persist that warehouse as the SKU's standing source_warehouse_idx. 247 * 248 * `groups` (idx keyed by warehouse_idx => qty) is seeded from the root element's `data-groups` attribute â 249 * a server-built JSON payload containing EVERY selected pair, independent of the template's $MAX_PREVIEW 250 * row cap. It is the sole source of truth for what gets submitted: reassign()/makePrimary() mutate it in 251 * memory, and buildGroups()/confirm() serialize it directly. The rendered <tr> rows (capped at 252 * $MAX_PREVIEW) are a visual layer only â reassign() also moves the live row when one exists, as a 253 * nice-to-have, but overflow SKUs that never got a row are still carried in `groups` and still submitted. 254 * 255 * The shell's own form-submit footer (declared in _default/_foot.htm, postUrl fixed to the single-plan 256 * restock_create_plan.php at modal-open time) is hidden by this fragment's own <style> â Create Plans below 257 * drives confirm() instead, since fan-out needs multiple requests in sequence (the "Make Default" writes, 258 * then the grouped create) that a single declarative hx-post c
258an't express. 259 */ 260 /** 261 * Demand Events page â left list / right form. Click a row to load it into the editor. 262 * Full page load / refresh always starts with a blank "New event" form (no ?edit= persistence). 263 */ 264 Alpine.data('demandEventsPage', () => ({ 265 type: 'seasonal', 266 // Channel scope: 'all' | 'whitelist' | 'blacklist'. New events are always channel-scoped; 267 // per-SKU rows from the list set invIdx (isSkuEvent) and hide this control. 268 channelApply: 'all', 269 scopeSku: '', 270 scopeSkusText: '', // multi-line / comma SKUs for whitelist|blacklist 271 editIdx: '', 272 invIdx: '', 273 editing: false, 274 title: 'New event', 275 button: 'Add Event', 276 277 get isSkuEvent() { 278 return !!(this.invIdx && String(this.invIdx).trim()); 279 }, 280 281 init() { 282 // Drop any leftover ?edit= so a refresh always shows a clean form 283 const url = new URL(window.location.href); 284 if (url.searchParams.has('edit')) { 285 url.searchParams.delete('edit'); 286 window.history.replaceState(null, '', url.pathname + (url.search || '')); 287 } 288 this.$nextTick(() => { 289 if (typeof window.initAllDatePickers === 'function') window.initAllDatePickers(); 290 }); 291 }, 292 293 applyEvent(data) { 294 if (!data || typeof data !== 'object') return; 295 const type = data.type || 'seasonal'; 296 this.type = type; 297 this.editIdx = data.idx || ''; 298 this.invIdx = data.inventory_idx || ''; 299 this.scopeSku = (data.sku || '').trim(); 300 const mode = (data.scope_mode === 'whitelist' || data.scope_mode === 'blacklist') ? data.scope_mode : 'all'; 301 this.channelApply = this.isSkuEvent ? 'all' : mode; 302 const skus = Array.isArray(data.scope_skus) ? data.scope_skus : []; 303 this.scopeSkusText = skus.join('\n'); 304 this.editing = !!this.editIdx; 305 const sku = this.scopeSku; 306 this.title = this.editing 307 ? ('Edit event' + (sku ? ' â ' + sku : ' â channel')) 308 : 'New event'; 309 this.button = this.editing ? 'Save Changes' : 'Add Event'; 310 311 const setVal = (id, v) => { 312 const el = document.getElementById(id); 313 if (el) el.value = (v === null || v === undefined) ? '' : String(v); 314 }; 315 setVal('ce-name', data.label || ''); 316 setVal('ce-pct-seasonal', type === 'seasonal' && data.pct != null ? data.pct : ''); 317 setVal('ce-pct-growth', type === 'growth' && data.pct != null ? data.pct : ''); 318 setVal('ce-pct-ramp', type === 'ramp' && data.pct != null ? data.pct : ''); 319 setVal('ce-units', type === 'deal' && data.units != null ? data.units : ''); 320 setVal('ce-from-seasonal', type === 'seasonal' ? (data.date_from || '') : ''); 321 setVal('ce-to-seasonal', type === 'seasonal' ? (data.date_to || '') : ''); 322 setVal('ce-date-deal', type === 'deal' ? (data.date_from || '') : ''); 323 // Ramped growth reuses date_from (start) + date_to (target) 324 setVal('ce-from-ramp', type === 'ramp' ? (data.date_from || '') : ''); 325 setVal('ce-to-ramp', type === 'ramp' ? (data.date_to || '') : ''); 326 327 const rep = document.getElementById('ce-repeat'); 328 if (rep) rep.checked = !!(type === 'seasonal' && data.repeat_yearly); 329 330 this.markSelectedRow(this.editIdx); 331 this.$nextTick(() => this.syncPickersFromHidden()); 332 }, 333 334 markSelectedRow(idx) { 335 this.$root.querySelectorAll('tr.rs-ev-row.-selected').forEach((r) => r.classList.remove('-selected')); 336 if (!idx) return; 337 const row = this.$root.querySelector('tr.rs-ev-row[data-idx="' + CSS.escape(idx) + '"]'); 338 if (row) row.classList.add('-selected'); 339 }, 340 341 clearEdit() { 342 this.type = 'seasonal'; 343 this.channelApply = 'all'; 344 this.scopeSku = ''; 345 this.scopeSkusText = ''; 346 this.editIdx = ''; 347 this.invIdx = ''; 348 this.editing = false;
349 this.title = 'New event'; 350 this.button = 'Add Event'; 351 ['ce-name', 'ce-pct-seasonal', 'ce-pct-growth', 'ce-pct-ramp', 'ce-units', 352 'ce-from-seasonal', 'ce-to-seasonal', 'ce-date-deal', 'ce-from-ramp', 'ce-to-ramp'].forEach((id) => { 353 const el = document.getElementById(id); 354 if (el) el.value = ''; 355 }); 356 const rep = document.getElementById('ce-repeat'); 357 if (rep) rep.checked = false; 358 this.markSelectedRow(''); 359 this.$nextTick(() => this.syncPickersFromHidden()); 360 }, 361 362 // Push hidden Y-m-d values into any already-bound flatpickr alt inputs (or re-init unbound ones). 363 syncPickersFromHidden() { 364 const bind = (controlsId) => { 365 const hidden = document.getElementById(controlsId); 366 if (!hidden) return; 367 const picker = this.$root.querySelector('.date-picker[data-controls="' + controlsId + '"]'); 368 if (!picker) return; 369 if (picker._flatpickr) { 370 if (hidden.value) picker._flatpickr.setDate(hidden.value, false); 371 else picker._flatpickr.clear(); 372 } 373 }; 374 bind('ce-from-seasonal'); 375 bind('ce-to-seasonal'); 376 bind('ce-date-deal'); 377 bind('ce-from-ramp'); 378 bind('ce-to-ramp'); 379 if (typeof window.initAllDatePickers === 'function') window.initAllDatePickers(); 380 }, 381 })); 382 383 Alpine.data('restockFanout', () => ({ 384 groupCount: 0, 385 groups: {}, // warehouse_idx => { inventory_idx: qty } â full grouping, seeded from data-groups 386 makeDefault: {}, // inventory_idx => true while its "Make Default" checkbox is checked 387 388 init() { 389 this.groups = this.parseSeed(); 390 this.recount(); 391 }, 392 393 // Parse the server-seeded full grouping off the root element's data-groups attribute (JSON: 394 // [{warehouse_idx, items:"idx:qty,idx:qty"}, â¦]) into { warehouse_idx: { idx: qty } }. 395 parseSeed() { 396 let raw = []; 397 try { raw = JSON.parse(this.$root.dataset.groups || '[]'); } catch (e) { raw = []; } 398 const groups = {}; 399 (Array.isArray(raw) ? raw : []).forEach((g) => { 400 const wh = String(g.warehouse_idx || ''); 401 if (!wh) return; 402 const pairs = {}; 403 String(g.items || '').split(',').forEach((tok) => { 404 const [idx, qty] = tok.split(':'); 405 if (idx && qty) pairs[idx] = parseInt(qty, 10) || 0; 406 }); 407 groups[wh] = pairs; 408 }); 409 return groups; 410 }, 411 412 // Find which bucket in `groups` currently holds a SKU (a row can be reassigned after the initial seed). 413 whFor(idx) { 414 for (const wh in this.groups) { 415 if (Object.prototype.hasOwnProperty.call(this.groups[wh], idx)) return wh; 416 } 417 return null; 418 }, 419 420 // Re-derive each section's live count from the in-memory `groups` state (not the capped DOM rows, so 421 // an overflow section's header count stays accurate) and toggle its -empty class. 422 recount() { 423 let n = 0; 424 this.$root.querySelectorAll('.rs-fanout-group').forEach((sec) => { 425 const items = this.groups[sec.dataset.wh] || {}; 426 const count = Object.keys(items).length; 427 const units = Object.values(items).reduce((a, b) => a + (parseInt(b, 10) || 0), 0); 428 sec.classList.toggle('-empty', count === 0); 429 const countEl = sec.querySelector('[data-sec-count]'); 430 if (countEl) countEl.textContent = count + ' ' + (count === 1 ? 'SKU' : 'SKUs') + 431 ', ' + units.toLocaleString() + ' ' + (units === 1 ? 'unit' : 'units'); 432 if (count > 0) n++; 433 }); 434 this.groupCount = n; 435 }, 436 437 // Move a SKU from its current bucket to the target warehouse in `groups` (the submitted source of 438 // truth), then best-effort move its visible <tr> too (appendChild re-parents, no clone needed) â a 439 // SKU beyond $MAX_PREVIEW has no row to move but is still reassigned in `groups`. 440 reassign(idx, warehouseIdx) { 441 if (!warehouseIdx) return; 442 const fromWh = this.whFor(idx); 443 if (fromWh === null || fromWh === warehouseIdx) return;
444 const qty = this.groups[fromWh][idx]; 445 delete this.groups[fromWh][idx]; 446 if (!this.groups[warehouseIdx]) this.groups[warehouseIdx] = {}; 447 this.groups[warehouseIdx][idx] = qty; 448 449 const row = this.$root.querySelector('tr[data-idx="' + CSS.escape(idx) + '"]'); 450 const target = this.$root.querySelector('.rs-fanout-group[data-wh="' + CSS.escape(warehouseIdx) + '"] tbody'); 451 if (row && target) target.appendChild(row); 452 453 this.recount(); 454 }, 455 456 // Track the "Make Default" flag by SKU idx (not by row/section â a flagged SKU may be reassigned 457 // afterwards, and whFor() resolves its warehouse at confirm time). 458 makePrimary(idx, on) { 459 if (on) this.makeDefault[idx] = true; 460 else delete this.makeDefault[idx]; 461 }, 462 463 // Build the groups payload from the in-memory `groups` state â [{warehouse_idx, items}], skipping any 464 // bucket left with no items (restock_create_plans.php hard-fails the whole batch on an empty group). 465 buildGroups() { 466 const groups = []; 467 Object.keys(this.groups).forEach((wh) => { 468 const items = this.groups[wh]; 469 const pairs = Object.keys(items).map((idx) => idx + ':' + items[idx]); 470 if (!pairs.length) return; 471 groups.push({ warehouse_idx: wh, items: pairs.join(',') }); 472 }); 473 return groups; 474 }, 475 476 async confirm() { 477 const groups = this.buildGroups(); 478 if (!groups.length) { 479 notify('Nothing to Create', 'No items remain in any plan.', 'error'); 480 return; 481 } 482 483 Alpine.store('modals').clearTopModalErrors(); 484 485 // "Make Default" â persist the standing source for every flagged SKU, using its CURRENT (possibly 486 // reassigned) bucket in `groups` as the warehouse to save. Fired before the create so the standing 487 // source is in place however the create itself resolves. Failures are surfaced (not swallowed) but 488 // don't block plan creation â the operator's intent was "also save this", not "only if it saves". 489 const flaggedIdx = Object.keys(this.makeDefault); 490 const failedIdx = []; 491 await Promise.all(flaggedIdx.map(async (idx) => { 492 const wh = this.whFor(idx); 493 if (!wh) return; 494 try { 495 const r = await fetch('/amazonsellerfba/admin/fetch/action/restock_set_source.php', { 496 method: 'POST', 497 headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'HX-Request': 'true' }, 498 body: new URLSearchParams({ inventory_idx: idx, source_warehouse_idx: wh }).toString(), 499 }); 500 if (!r.ok) failedIdx.push(idx); 501 } catch (e) { 502 failedIdx.push(idx); 503 } 504 })); 505 if (failedIdx.length) { 506 const n = failedIdx.length; 507 const noun = n === 1 ? 'SKU' : 'SKUs'; 508 const list = failedIdx.slice(0, 5).join(', ') + (n > 5 ? ', â¦' : ''); 509 notify('Default Source Not Saved', `Couldn't save the default source for ${n} ${noun} (${list}); the plan${groups.length === 1 ? '' : 's'} will still be created.`, 'error'); 510 } 511 512 let resp; 513 try { 514 resp = await fetch('/amazonsellerfba/admin/fetch/action/restock_create_plans.php', { 515 method: 'POST', 516 headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'HX-Request': 'true' }, 517 body: new URLSearchParams({ groups: JSON.stringify(groups) }).toString(), 518 }); 519 } catch (e) { 520 notify('Create Plans Failed', 'Could not reach the server. Check your connection and try again.', 'error'); 521 return; 522 } 523 524 const redirect = resp.headers.get('HX-Redirect'); 525 if (resp.ok && redirect) { 526 window.location.assign(redirect); 527 return; 528 } 529 530 let message = 'Something went wrong creating the plans.'; 531 try { 532 const data = await resp.json(); 533 message = (data && data.errors && data.errors.general) || message; 534 } catch (e) { /* non-JSON error body â keep the generic message */ } 535 536 const modal = Alpine.store('modals').top; 537 if (modal) { 538 modal.errors.general = message; 539 Alpine.store('modals')._renderErrors(); 540 } else { 541 notify('Create Plans Failed', message, 'error'); 542 } 543 }, 544 })); 545 546 /** 547 * Custom x-error directive. 548 * Usage: <div x-error="'field_name'"></div> 549 * It reactively shows/hides the element and sets its text content 550 * based on the error message for 'field_name' in the modal store. 551 */ 552 Alpine.directive('error', (el, { expression }, { effect, cleanup }) => { 553 // effect() automatically re-runs this code whenever any reactive 554 // data inside it (like $store.modals.top.errors) changes. 555 const watcher = effect(() => { 556 const message = Alpine.store('modals').top?.errors?.[expression]; 557 558 el.style.display = message ? 'block' : 'none'; 559 if (message) { 560 el.innerHTML = DOMPurify.sanitize(message); 561 } else { 562 el.innerHTML = ''; 563 } 564 }); 565 566 // This cleans up the watcher when the element is removed from the page. 567 cleanup(() => watcher()); 568 }); 569});
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.