PageSourceSearch

https://beningtonlordship.co.uk/js/alpine-init.js?v=4

js beningtonlordship.co.uk collected 2026-09-26 07:32:38 UTC 26,657 bytes, 569 lines download raw bytes

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('&', '&amp;')
228                .replaceAll('<', '&lt;')
229                .replaceAll('>', '&gt;')
230                .replaceAll('"', '&quot;')
231                .replaceAll("'", '&#039;');
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.