PageSourceSearch

https://finnalby.com/js/alpine-components.z2zi0lw7wt.js

js finnalby.com collected 2026-10-04 22:46:06 UTC 413,450 bytes, 10,593 lines download raw bytes

1// ========================================
2// FINNALBY ALPINE.JS COMPONENTS
3// ========================================
4
5// ========================================================================
6// Page data and page libraries (read this before adding a <script> to a component)
7// ========================================================================
8//
9// Components never emit executable <script> tags. Enhanced navigation (Phase 8) patches the DOM
10// in place and does not run scripts it patches in, so an inline `window.x = …` keeps the previous
11// page's value and a patched-in <script src> never loads (both verified; see the migration doc).
12//
13// - Server data arrives as <script type="application/json" id="…"> blocks (Shared/PageConfig.razor).
14//   The globals below are getters that parse the current block when read, so every component reads
15//   its own page's data, whether the page came from a full load or an enhanced navigation.
16// - Libraries a page needs (Turnstile, Chart.js, PostHog) load on demand through loadScriptOnce:
17//   a <script> created from JavaScript does run.
18
19function defineConfigGlobal(name, elementId) {
20    let raw = null;
21    let value;
22    Object.defineProperty(window, name, {
23        configurable: true,
24        get() {
25            const element = document.getElementById(elementId);
26            if (!element) return undefined;
27            const text = element.textContent;
28            if (text !== raw) {
29                raw = text;
30                try {
31                    value = JSON.parse(text);
32                } catch (error) {
33                    console.error(`#${elementId} is not valid JSON.`, error);
34                    value = undefined;
35                }
36            }
37            return value;
38        },
39    });
40}
41
42defineConfigGlobal('pageConfig', 'page-config');
43defineConfigGlobal('initialMessages', 'initial-messages');
44defineConfigGlobal('productDetailConfig', 'product-detail-config');
45defineConfigGlobal('businessDetailConfig', 'business-detail-config');
46defineConfigGlobal('cartConfig', 'cart-config');
47defineConfigGlobal('contactConfig', 'contact-config');
48defineConfigGlobal('newsletterConfig', 'newsletter-config');
49
50const loadedScripts = new Map();
51
52/** Loads a classic script once per document; resolves when it has run. A failed load can be retried. */
53function loadScriptOnce(src, attributes = {}) {
54    if (!loadedScripts.has(src)) {
55        const promise = new Promise((resolve, reject) => {
56            const script = document.createElement('script');
57            script.src = src;
58            script.async = true;
59            Object.entries(attributes).forEach(([key, value]) => script.setAttribute(key, value));
60            script.onload = () => resolve();
61            script.onerror = () => {
62                loadedScripts.delete(src);
63                script.remove();
64                reject(new Error(`Failed to load ${src}`));
65            };
66            document.head.appendChild(script);
67        });
68        loadedScripts.set(src, promise);
69    }
70    return loadedScripts.get(src);
71}
72
73const TURNSTILE_SCRIPT = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
74
75/** Removes a rendered Turnstile widget; components call it from destroy(). */
76function removeTurnstileWidget(widgetId) {
77    if (widgetId === null || widgetId === undefined || !window.turnstile) return;
78    try {
79        window.turnstile.remove(widgetId);
80    } catch {
81        // Already gone with its container.
82    }
83}
84
85const CHART_JS_SCRIPT = 'https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js';
86
87// PostHog runs on pages whose layout (MainLayout, FunnelLayout) carries
88// <meta name="posthog-script" content="…">; the admin shell has none. Checked at startup and after
89// every enhanced navigation, since the first page may be an admin one. PostHog records the
90// pageview of the page it loads on; later pages arrive by enhanced navigation, which it cannot see,
91// so each one records its own (a full load used to, query-only changes included).
92function loadPostHogIfRequested({ afterNavigation = false } = {}) {
93    const marker = document.querySelector('meta[name="posthog-script"]');
94    if (!marker?.content) return;
95
96    const alreadyRequested = loadedScripts.has(marker.content);
97    loadScriptOnce(marker.content).catch((error) => console.warn(error));
98    if (afterNavigation && alreadyRequested) {
99        window.posthog?.capture?.('$pageview');
100    }
101}
102
103// ========================================================================
104// Enhanced navigation (Phase 8; see docs/blazor-ssr-migration.md)
105// ========================================================================
106//
107// Blazor fetches each page and patches it into the current DOM, reusing elements by tag name.
108// Alpine cannot follow that: a reused x-data root keeps the previous page's component, and Alpine's
109// MutationObserver re-evaluates changed attributes in the old scope. So when a navigation starts,
110// every outermost element that carries an Alpine attribute (or an Alpine scope, like x-for clones)
111// gets a data-permanent value the new markup cannot match. Blazor never merges elements whose
112// data-permanent values differ, so it removes those subtrees and inserts the new page's instead,
113// and Alpine's observer destroys the old components and initialises the new ones exactly as on a
114// full load. Everything else, real data-permanent containers included, is still patched in place.
115//
116// Consequences for components:
117// - A component lives for one page. Anything it registers outside its own element (window or
118//   document listeners, timers, polling, observers, third-party widgets) must be released in
119//   destroy() or with listenWhileMounted(); a full page load used to hide leaks.
120// - <body>'s x-data is the one Alpine scope that survives; it is reset to its initial values.
121// - Navigate from script with navigateTo(), not location.href, to stay on enhanced navigation.
122
123const ALPINE_REPLACE_MARK = 'alpine-replace';
124
125function hasAlpineAttribute(element) {
126    for (const attribute of element.attributes) {
127        if (attribute.name.startsWith('x-')) return true;
128    }
129    return false;
130}
131
132function collectAlpineSubtrees(parent, found) {
133    for (const child of parent.children) {
134        // Blazor keeps a data-permanent element and everything inside it untouched.
135        if (child.hasAttribute('data-permanent')) continue;
136        if (child._x_dataStack || hasAlpineAttribute(child)) {
137            found.push(child);
138        } else {
139            collectAlpineSubtrees(child, found);
140        }
141    }
142    return found;
143}
144
145function markAlpineSubtreesForReplacement() {
146    const body = document.body;
147    const subtrees = collectAlpineSubtrees(body, []);
148    const mark = () => subtrees.forEach((element) => element.setAttribute('data-permanent', ALPINE_REPLACE_MARK));
149    window.Alpine ? Alpine.mutateDom(mark) : mark();
150
151    const bodyData = body._x_dataStack?.[0];
152    const bodyExpression = body.getAttribute('x-data');
153    if (window.Alpine && bodyData && bodyExpression) {
154        Object.assign(bodyData, Alpine.evaluate(body, bodyExpression));
155    }
156    if (window.Alpine?.store('adminShell')) {
157        Alpine.store('adminShell').mobileMenuOpen = false;
158    }
159}
160
161/** Runs dispose when the component leaves the page. Call from init(). */
162function onUnmount(component, dispose) {
163    Alpine.onElRemoved(component.$root, dispose);
164}
165
166/** Adds a listener for as long as the component is on the page. Call from init(). */
167function listenWhileMounted(component, target, type, handler, options) {
168    target.addEventListener(type, handler, options);
169    onUnmount(component, () => target.removeEventListener(type, handler, options));
170}
171
172let keepScrollOnNextNavigation = false;
173
174/**
175 * Navigates within the site the way a link click does (enhanced navigation). keepScroll holds the
176 * scroll position, for refreshing the current page after a change; otherwise a new page starts at
177 * the top, as a full load did.
178 */
179function navigateTo(url, { replace = false, keepScroll = false } = {}) {
180    const target = new URL(url, window.location.href);
181    if (target.origin !== window.location.origin || !window.Blazor?.navigateTo) {
182        replace ? window.location.replace(target.href) : window.location.assign(target.href);
183        return;
184    }
185    keepScrollOnNextNavigation = keepScroll;
186    Blazor.navigateTo(target.href, { replaceHistoryEntry: replace });
187}
188
189/** Re-renders the current page in place (an enhanced navigation to the same URL, scroll kept). */
190function refreshPage() {
191    navigateTo(window.location.href, { replace: true, keepScroll: true });
192}
193
194// Scroll. Blazor resets the scroll position only for links to a different path, and restores
195// nothing on back/forward (the browser would restore it before the old page is even replaced). So
196// the browser's restoration is off while a document is open, and every page start is decided here:
197// back/forward returns to where that page was left, a new URL starts at the top, and keepScroll or
198// a #fragment (Blazor scrolls to it) leave it alone. At pagehide the browser takes over again, so a
199// reload or a return from another site restores natively.
200const scrollPositions = new Map();
201let currentPageUrl = window.location.href;
202let navigationInProgress = false;
203let pendingNavigation = null;
204let traversingHistory = false;
205
206function useManualScrollRestoration() {
207    if ('scrollRestoration' in history) history.scrollRestoration = 'manual';
208}
209
210useManualScrollRestoration();
211window.addEventListener('pagehide', () => {
212    if ('scrollRestoration' in history) history.scrollRestoration = 'auto';
213});
214window.addEventListener('pageshow', (event) => {
215    if (event.persisted) useManualScrollRestoration();
216});
217// Blazor's popstate listener starts the navigation, and it may run before or after this one.
218window.addEventListener('popstate', () => {
219    if (pendingNavigation) {
220        pendingNavigation.traversal = true;
221    } else {
222        traversingHistory = true;
223        queueMicrotask(() => { traversingHistory = false; });
224    }
225});
226
227// Keyed by the address as it is when the user scrolls, since pages rewrite their own query string
228// with replaceState (product tabs, admin filters).
229let scrollFrame = 0;
230window.addEventListener('scroll', () => {
231    if (navigationInProgress || scrollFrame) return;
232    scrollFrame = requestAnimationFrame(() => {
233        scrollFrame = 0;
234        if (navigationInProgress) return;
235        currentPageUrl = window.location.href;
236        scrollPositions.set(currentPageUrl, window.scrollY);
237    });
238}, { passive: true });
239
240function applyNavigationScroll({ restoreTo, keepScroll }) {
241    if (restoreTo !== undefined) {
242        window.scrollTo(0, restoreTo);
243    } else if (!keepScroll && !window.location.hash) {
244        window.scrollTo(0, 0);
245    }
246}
247
248// Stylesheets. A page's own <link rel="stylesheet"> is patched into <head> with the page, and
249// a stylesheet added after the first paint does not block rendering, so the new page would paint
250// unstyled until it arrives. The body stays hidden until the page's stylesheets have loaded (at
251// most a second), which is how a full load behaved.
252const loadedStylesheets = new WeakMap();
253const STYLESHEET_WAIT_MS = 1000;
254
255document.querySelectorAll('link[rel="stylesheet"]').forEach((link) => {
256    if (link.sheet) loadedStylesheets.set(link, link.href);
257});
258document.addEventListener('load', (event) => {
259    if (event.target instanceof HTMLLinkElement && event.target.rel === 'stylesheet') {
260        loadedStylesheets.set(event.target, event.target.href);
261    }
262}, true);
263
264function waitForStylesheets() {
265    const pending = [...document.querySelectorAll('link[rel="stylesheet"]')]
266        .filter((link) => loadedStylesheets.get(link) !== link.href);
267    if (pending.length === 0) return Promise.resolve();
268
269    const root = document.documentElement;
270    root.setAttribute('data-styles-pending', '');
271    const loads = pending.map((link) => new Promise((resolve) => {
272        link.addEventListener('load', resolve, { once: true });
273        link.addEventListener('error', resolve, { once: true });
274    }));
275    const timeout = new Promise((resolve) => setTimeout(resolve, STYLESHEET_WAIT_MS));
276    return Promise.race([Promise.all(loads), timeout]).then(() => root.removeAttribute('data-styles-pending'));
277}
278
279// Third-party nodes. Blazor makes <head> and <body> match the new page's markup, so it deletes
280// whatever a library appended to them: Stripe Connect's data-layer iframe (Stripe then stops
281// working), PostHog's scripts and survey popups, library <style> and <link> tags. A node a library
282// adds to <body> moves into #third-party-root, a data-permanent container at the end of <body>
283// (App.razor) that Blazor leaves alone; moveBefore keeps an iframe's state where the browser has
284// it. One added to <head> is put back after each patch.
285const serverRenderedNodes = new WeakSet();
286const thirdPartyHeadNodes = new Set();
287
288function rememberServerRenderedNodes() {
289    for (const node of [...document.head.childNodes, ...document.body.childNodes]) {
290        if (!thirdPartyHeadNodes.has(node)) serverRenderedNodes.add(node);
291    }
292}
293
294function adoptThirdPartyNodes(records) {
295    const root = document.getElementById('third-party-root');
296    for (const record of records) {
297        for (const node of record.addedNodes) {
298            if (node.nodeType !== Node.ELEMENT_NODE || serverRenderedNodes.has(node)) continue;
299            if (node.parentNode === document.head) {
300                thirdPartyHeadNodes.add(node);
301            } else if (node.parentNode === document.body && root && node !== root) {
302                // Blazor's streaming updates arrive as <blazor-ssr> and remove themselves.
303                if (node.localName === 'blazor-ssr') continue;
304                root.moveBefore ? root.moveBefore(node, null) : root.appendChild(node);
305            }
306        }
307    }
308}
309
310const thirdPartyObserver = new MutationObserver(adoptThirdPartyNodes);
311rememberServerRenderedNodes();
312thirdPartyObserver.observe(document.head, { childList: true });
313thirdPartyObserver.observe(document.body, { childList: true });
314loadPostHogIfRequested();
315
316// Runs synchronously after each patch, so the records it drops are the patch's own insertions
317// (anything a library added earlier was adopted when the observer last ran).
318function afterDocumentPatched() {
319    thirdPartyObserver.takeRecords();
320    for (const node of thirdPartyHeadNodes) {
321        if (!node.isConnected) document.head.appendChild(node);
322    }
323    thirdPartyObserver.takeRecords();
324    rememberServerRenderedNodes();
325}
326
327window.Blazor?.addEventListener?.('enhancednavigationstart', () => {
328    const leavingScrollY = window.scrollY;
329    if (!navigationInProgress) scrollPositions.set(currentPageUrl, leavingScrollY);
330    navigationInProgress = true;
331    // For a link to another path Blazor scrolls to the top as soon as the link is clicked, so the
332    // old page would jump while the new one loads. Put it back; the new page is scrolled on arrival.
333    queueMicrotask(() => {
334        if (navigationInProgress && window.scrollY !== leavingScrollY) window.scrollTo(0, leavingScrollY);
335    });
336    pendingNavigation = { traversal: traversingHistory, keepScroll: keepScrollOnNextNavigation };
337    traversingHistory = false;
338    keepScrollOnNextNavigation = false;
339    markAlpineSubtreesForReplacement();
340});
341
342// Fires after the page is patched in and again after each streamed update. Only the first one
343// after a navigation starts is the new page.
344window.Blazor?.addEventListener?.('enhancedload', () => {
345    afterDocumentPatched();
346
347    const navigation = pendingNavigation;
348    pendingNavigation = null;
349    if (!navigation) return;
350
351    navigationInProgress = false;
352    currentPageUrl = window.location.href;
353    const restoreTo = navigation.traversal ? (scrollPositions.get(currentPageUrl) ?? 0) : undefined;
354    applyNavigationScroll({ restoreTo, keepScroll: navigation.keepScroll });
355    waitForStylesheets().then(() => {
356        if (restoreTo !== undefined) window.scrollTo(0, restoreTo);
357    });
358    loadPostHogIfRequested({ afterNavigation: true });
359});
360
361// ========================================================================
362// Admin form helpers (formerly admin-forms.js, loaded per page)
363// ========================================================================
364
365/**
366 * Shared Administrative Form Helpers & Validation
367 */
368
369window.AdminFormUtils = {
370    /**
371     * Slugify a string (standard URL-safe format)
372     * @param {string} text 
373     * @returns {string}
374     */
375    slugify(text) {
376        if (!text) return '';
377        return text.toString().toLowerCase()
378            .trim()
379            .replace(/\s+/g, '-')
380            .replace(/[^\w\-]+/g, '')
381            .replace(/\-\-+/g, '-');
382    },
383
384    /**
385     * Standard validation rules
386     */
387    rules: {
388        required: (value, fieldName = 'Field') => {
389            if (value === null || value === undefined || (typeof value === 'string' && value.trim() === '')) {
390                return `${fieldName} is required.`;
391            }
392            return null;
393        },
394        minLength: (value, min, fieldName = 'Field') => {
395            if (value && value.toString().length < min) {
396                return `${fieldName} must be at least ${min} characters.`;
397            }
398            return null;
399        },
400        maxLength: (value, max, fieldName = 'Field') => {
401            if (value && value.toString().length > max) {
402                return `${fieldName} cannot exceed ${max} characters.`;
403            }
404            return null;
405        },
406        httpsUrl: (value, fieldName = 'URL') => {
407            if (!value) return null;
408            try {
409                const url = new URL(value);
410                if (url.protocol !== 'https:') return `${fieldName} must use HTTPS.`;
411            } catch (e) {
412                return `Please enter a valid ${fieldName}.`;
413            }
414            return null;
415        },
416        email: (value) => {
417            if (!value) return null;
418            const re = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
419            if (!re.test(String(value).toLowerCase())) return 'Please enter a valid email address.';
420            return null;
421        },
422        noKeyword: (value, keyword, fieldName = 'Field') => {
423            if (value && value.toLowerCase().includes(keyword.toLowerCase())) {
424                return `${fieldName} cannot contain "${keyword}".`;
425            }
426            return null;
427        },
428        noSlug: (value, forbiddenSlug, fieldName = 'Field') => {
429            if (value && window.AdminFormUtils.slugify(value) === forbiddenSlug) {
430                return `This ${fieldName} is not allowed.`;
431            }
432            return null;
433        }
434    },
435
436    /**
437     * Map server-side validation error response to local errors object
438     * @param {object} serverErrors - The errors object from response.json()
439     * @returns {object} - Local errors object with camelCase keys
440     */
441    mapServerErrors(serverErrors) {
442        const localErrors = {};
443        if (!serverErrors) return localErrors;
444        
445        Object.keys(serverErrors).forEach(key => {
446            // Convert TitleCase (C#) to camelCase (JS) if necessary
447            const localKey = key.charAt(0).toLowerCase() + key.slice(1);
448            localErrors[localKey] = Array.isArray(serverErrors[key]) ? serverErrors[key][0] : serverErrors[key];
449        });
450        
451        return localErrors;
452    }
453};
454
455/**
456 * Search Autocomplete Component
457 * Provides real-time product search with debounced API calls
458 */
459function searchAutocomplete() {
460    return {
461        query: '',
462        results: [],
463        loading: false,
464        showDropdown: false,
465
466        async search() {
467            if (this.query.length < 2) {
468                this.results = [];
469                this.showDropdown = false;
470                return;
471            }
472
473            this.loading = true;
474            try {
475                const response = await fetch(`/api/products/search?searchTerm=${encodeURIComponent(this.query)}&pageSize=10`);
476                if (response.ok) {
477                    const data = await response.json();
478                    this.results = data.products || [];
479                    this.showDropdown = this.results.length > 0;
480                } else {
481                    this.results = [];
482                    this.showDropdown = false;
483                }
484            } catch (error) {
485                console.error('Search error:', error);
486                this.results = [];
487                this.showDropdown = false;
488            } finally {
489                this.loading = false;
490            }
491        },
492
493        goToSearch() {
494            if (this.query.trim()) {
495                navigateTo(`/products?searchTerm=${encodeURIComponent(this.query)}`);
496            }
497        },
498
499        closeDropdown() {
500            this.showDropdown = false;
501        },
502
503        selectResult(product) {
504            navigateTo(`/products/detail/${product.businessId}/${product.slug}`);
505        }
506    };
507}
508
509/**
510 * Cart Badge Component
511 * Displays and updates the shopping cart item count
512 */
513function cartBadge() {
514    return {
515        itemCount: 0,
516
517        async init() {
518            // Listen for cart updates from other parts of the app
519            listenWhileMounted(this, window, 'cart-updated', () => this.fetchCount());
520            await this.fetchCount();
521        },
522
523        async fetchCount() {
524            try {
525                const response = await fetch('/api/shopping-cart/count');
526                if (response.ok) {
527                    const data = await response.json();
528                    this.itemCount = data.itemCount || 0;
529                }
530            } catch (error) {
531                console.error('Cart count error:', error);
532            }
533        }
534    };
535}
536
537/**
538 * Newsletter Subscription Component
539 * Handles email newsletter signups
540 */
541function newsletter() {
542    const config = window.newsletterConfig || {};
543
544    const getNewIdempotencyKey = () => {
545        if (window.crypto?.randomUUID) return window.crypto.randomUUID();
546        return `newsletter-${Date.now()}-${Math.random().toString(16).slice(2)}`;
547    };
548
549    return {
550        email: '',
551        loading: false,
552        message: '',
553        isError: false,
554        isVerified: false,
555        hasFocusedEmail: false,
556        isTurnstileLoading: false,
557        verificationError: false,
558        turnstileToken: '',
559        turnstileWidgetId: null,
560        turnstileRetryTimer: null,
561        turnstileRetryTimeout: null,
562        turnstileRetryCount: 0,
563        idempotencyKey: '',
564        hasAttemptedSubmit: false,
565        cloudflareSiteKey: config.cloudflareSiteKey || '',
566        antiforgeryToken: config.antiforgeryToken || '',
567        antiforgeryHeaderName: config.antiforgeryHeaderName || '',
568
569        init() {
570            this.idempotencyKey = getNewIdempotencyKey();
571        },
572
573        destroy() {
574            clearTimeout(this.turnstileRetryTimer);
575            clearTimeout(this.turnstileRetryTimeout);
576            removeTurnstileWidget(this.turnstileWidgetId);
577        },
578
579        handleEmailFocus() {
580            if (this.hasFocusedEmail) return;
581            this.hasFocusedEmail = true;
582            this.ensureTurnstileScript();
583            this.setupTurnstile();
584        },
585
586        ensureTurnstileScript() {
587            if (!this.cloudflareSiteKey || window.turnstile) {
588                return;
589            }
590
591            // setupTurnstile polls for window.turnstile and reports a load failure itself.
592            loadScriptOnce(TURNSTILE_SCRIPT).catch(() => {});
593        },
594
595        setupTurnstile() {
596            if (!this.cloudflareSiteKey) {
597                return;
598            }
599
600            const container = this.$refs?.turnstileContainer;
601            if (!container) return;
602
603            if (!window.turnstile) {
604                this.isTurnstileLoading = true;
605                this.turnstileRetryCount += 1;
606                if (!this.turnstileRetryTimeout) {
607                    this.turnstileRetryTimeout = setTimeout(() => {
608                        if (!window.turnstile && this.hasAttemptedSubmit) {
609                            this.isError = true;
610                            this.message = 'Verification is unavailable right now.';
611                            this.verificationError = true;
612                        }
613                        this.turnstileRetryTimeout = null;
614                    }, 7000);
615                }
616                if (this.turnstileRetryCount < 60) {
617                    this.turnstileRetryTimer = setTimeout(() => this.setupTurnstile(), 300);
618                }
619                return;
620            }
621
622            if (this.turnstileRetryTimer) {
623                clearTimeout(this.turnstileRetryTimer);
624                this.turnstileRetryTimer = null;
625            }
626            if (this.turnstileRetryTimeout) {
627                clearTimeout(this.turnstileRetryTimeout);
628                this.turnstileRetryTimeout = null;
629            }
630            this.turnstileRetryCount = 0;
631            this.isTurnstileLoading = false;
632
633            if (this.turnstileWidgetId !== null) {
634                window.turnstile.reset(this.turnstileWidgetId);
635                return;
636            }
637
638            this.turnstileWidgetId = window.turnstile.render(container, {
639                sitekey: this.cloudflareSiteKey,
640                callback: (token) => {
641                    this.turnstileToken = token;
642                    this.isVerified = true;
643                    if (this.isError && this.verificationError) {
644                        this.isError = false;
645                        this.message = '';
646                    }
647                    this.verificationError = false;
648                },
649                'expired-callback': () => {
650                    this.turnstileToken = '';
651                    this.isVerified = false;
652                },
653                'error-callback': () => {
654                    this.turnstileToken = '';
655                    this.isVerified = false;
656                    this.isError = true;
657                    this.message = 'Verification failed. Please try again.';
658                    this.verificationError = true;
659                }
660            });
661        },
662
663        resetTurnstile() {
664            if (this.turnstileRetryTimer) {
665                clearTimeout(this.turnstileRetryTimer);
666                this.turnstileRetryTimer = null;
667            }
668            if (this.turnstileRetryTimeout) {
669                clearTimeout(this.turnstileRetryTimeout);
670                this.turnstileRetryTimeout = null;
671            }
672            this.turnstileRetryCount = 0;
673            if (window.turnstile && this.turnstileWidgetId !== null) {
674                window.turnstile.reset(this.turnstileWidgetId);
675            }
676            this.turnstileToken = '';
677            this.isVerified = false;
678            this.isTurnstileLoading = false;
679            this.verificationError = false;
680        },
681
682        getApiHeaders() {
683            const headers = { 'Content-Type': 'application/json' };
684            if (this.antiforgeryToken && this.antiforgeryHeaderName) {
685                headers[this.antiforgeryHeaderName] = this.antiforgeryToken;
686            }
687            return headers;
688        },
689
690        async subscribe() {
691            if (this.loading) return;
692
693            const email = this.email.trim();
694            this.hasAttemptedSubmit = true;
695            this.handleEmailFocus();
696
697            if (!email) {
698                this.isError = true;
699                this.message = 'Please enter your email address.';
700                this.verificationError = false;
701                return;
702            }
703
704            if (!this.cloudflareSiteKey) {
705                this.isError = true;
706                this.message = 'Verification is unavailable right now.';
707                this.verificationError = true;
708                return;
709            }
710
711            if (!window.turnstile || (this.turnstileWidgetId === null && !this.turnstileToken)) {
712                this.isError = false;
713                this.message = 'Loading verification...';
714                this.verificationError = false;
715                return;
716            }
717
718            if (!this.turnstileToken) {
719                this.isError = true;
720                this.message = 'Please complete the verification.';
721                this.verificationError = true;
722                return;
723            }
724
725            this.loading = true;
726            this.message = '';
727
728            try {
729                const response = await fetch('/api/marketing/subscribe-email', {
730                    method: 'POST',
731                    headers: this.getApiHeaders(),
732                    body: JSON.stringify({
733                        email: email,
734                        cloudflareTurnstileToken: this.turnstileToken,
735                        idempotencyKey: this.idempotencyKey
736                    })
737                });
738
739                const data = await response.json().catch(() => ({}));
740
741                if (!response.ok) {
742                    this.isError = true;
743                    const errorMessages = data.errors ? Object.values(data.errors).flat() : [];
744                    this.message = errorMessages[0] || data.message || 'Error subscribing';
745                    const verificationMessage = (data.message || '').toLowerCase();
746                    const hasVerificationError = Boolean(data.errors?.Turnstile?.length)
747                        || verificationMessage.includes('verification')
748                        || verificationMessage.includes('turnstile');
749                    if (hasVerificationError) {
750                        this.resetTurnstile();
751                    }
752                } else {
753                    this.isError = false;
754                    this.message = data.message || 'Subscribed successfully!';
755                    this.email = '';
756                    this.idempotencyKey = getNewIdempotencyKey();
757                    this.resetTurnstile();
758                }
759            } catch (error) {
760                this.isError = true;
761                this.message = 'Error subscribing. Please try again.';
762            } finally {
763                this.loading = false;
764            }
765        }
766    };
767}
768
769/**
770 * Account Dropdown Component
771 * Manages the account dropdown menu state
772 */
773function accountDropdown() {
774    return {
775        open: false,
776
777        toggle() {
778            this.open = !this.open;
779        },
780
781        close() {
782            this.open = false;
783        }
784    };
785}
786
787const DESKTOP_BREAKPOINT = 1024;
788const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])';
789const FOCUS_WITHOUT_SCROLL = { preventScroll: true };
790
791/**
792 * Product Listing Component
793 * Shared behaviour for the products page and the market page: filter drawer and sort dropdown.
794 * Both pages render the same components, so their behaviour lives here rather than being copied
795 * per page. Paging is plain links (ProductListingPagination.razor): client-appended cards would
796 * not survive enhanced navigation, so the old mobile infinite scroll is gone.
797 */
798function productListingPage() {
799    return {
800        // UI State
801        filterDrawerOpen: false,
802        sortDropdownOpen: false,
803
804        // Responsive state
805        isDesktop: window.innerWidth >= DESKTOP_BREAKPOINT,
806
807        // Element focused before the drawer opened, restored when it closes.
808        previouslyFocused: null,
809        lockedScrollY: 0,
810
811        initProductListing() {
812            listenWhileMounted(this, window, 'resize', () => {
813                this.isDesktop = window.innerWidth >= DESKTOP_BREAKPOINT;
814            });
815
816            // Close filter drawer when switching to desktop
817            this.$watch('isDesktop', (value) => {
818                if (value && this.filterDrawerOpen) {
819                    this.closeFilterDrawer();
820                }
821            });
822
823            // Pin the body instead of overflow:hidden so iOS cannot throw away scrollY.
824            this.$watch('filterDrawerOpen', (value) => {
825                if (value) {
826                    this.lockBodyScroll();
827                } else {
828                    this.unlockBodyScroll();
829                }
830            });
831        },
832
833        openFilterDrawer() {
834            this.previouslyFocused = document.activeElement;
835            this.filterDrawerOpen = true;
836
837            this.$nextTick(() => {
838                this.$refs.drawerClose?.focus(FOCUS_WITHOUT_SCROLL);
839            });
840        },
841
842        closeFilterDrawer() {
843            if (!this.filterDrawerOpen) return;
844
845            this.filterDrawerOpen = false;
846
847            const target = this.previouslyFocused || this.$refs.filterToggle;
848            this.previouslyFocused = null;
849            this.$nextTick(() => {
850                target?.focus?.(FOCUS_WITHOUT_SCROLL);
851            });
852        },
853
854        lockBodyScroll() {
855            this.lockedScrollY = window.scrollY;
856            document.body.style.top = `-${this.lockedScrollY}px`;
857            document.body.classList.add('listing-scroll-lock');
858        },
859
860        unlockBodyScroll() {
861            if (!document.body.classList.contains('listing-scroll-lock')) return;
862
863            document.body.classList.remove('listing-scroll-lock');
864            document.body.style.top = '';
865            window.scrollTo(0, this.lockedScrollY);
866        },
867
868        // No Alpine focus plugin is loaded, so the modal focus trap is handled here.
869        trapDrawerFocus(event) {
870            if (event.key !== 'Tab') return;
871
872            const focusable = Array.from(event.currentTarget.querySelectorAll(FOCUSABLE_SELECTOR)).filter(
873                (element) => element.offsetParent !== null
874            );
875
876            if (!focusable.length) return;
877
878            const first = focusable[0];
879            const last = focusable[focusable.length - 1];
880
881            if (event.shiftKey && document.activeElement === first) {
882                event.preventDefault();
883                last.focus(FOCUS_WITHOUT_SCROLL);
884            } else if (!event.shiftKey && document.activeElement === last) {
885                event.preventDefault();
886                first.focus(FOCUS_WITHOUT_SCROLL);
887            }
888        }
889    };
890}
891
892/**
893 * Products Page Component
894 * Manages the products listing page with filters and sorting
895 */
896function productsPage() {
897    const listing = productListingPage();
898
899    return {
900        ...listing,
901
902        init() {
903            this.initProductListing();
904        }
905    };
906}
907
908/**
909 * Product Card Component
910 * Handles per-card interactions like wishlist toggling
911 */
912function productCard(initialData) {
913    return {
914        id: initialData.id,
915        isWishlisted: initialData.isWishlisted,
916        toggling: false,
917
918        async toggleWishlist(isAuthenticated) {
919            if (!isAuthenticated) {
920                navigateTo(`/account/login?returnUrl=${encodeURIComponent(window.location.pathname)}`);
921                return;
922            }
923
924            if (this.toggling) return;
925            this.toggling = true;
926
927            const wasWishlisted = this.isWishlisted;
928            const url = wasWishlisted 
929                ? `/api/wishlists/products/${this.id}`
930                : '/api/wishlists/products';
931
932            try {
933                const response = await fetch(url, {
934                    method: wasWishlisted ? 'DELETE' : 'POST',
935                    headers: { 
936                        'Content-Type': 'application/json',
937                        'X-Requested-With': 'XMLHttpRequest'
938                    },
939                    body: JSON.stringify({ productId: this.id })
940                });
941
942                if (response.ok) {
943                    this.isWishlisted = !wasWishlisted;
944                    // Dispatch global event for other components to sync if needed
945                    window.dispatchEvent(new CustomEvent('wishlist-updated', { 
946                        detail: { productId: this.id, isWishlisted: this.isWishlisted }
947                    }));
948                } else {
949                    console.error('Failed to update wishlist');
950                }
951            } catch (error) {
952                console.error('Wishlist error:', error);
953            } finally {
954                this.toggling = false;
955            }
956        }
957    };
958}
959
960// ========================================================================
961// Product detail page (formerly product-detail.js)
962// ========================================================================
963
964function lockBodyScroll() {
965    const previousOverflow = document.body.style.overflow;
966    const previousPaddingRight = document.body.style.paddingRight;
967    const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
968
969    document.body.style.overflow = 'hidden';
970    if (scrollbarWidth > 0) {
971        document.body.style.paddingRight = `${scrollbarWidth}px`;
972    }
973
974    return { previousOverflow, previousPaddingRight };
975}
976
977function unlockBodyScroll(previous) {
978    document.body.style.overflow = previous?.previousOverflow || '';
979    document.body.style.paddingRight = previous?.previousPaddingRight || '';
980}
981
982// Approximate height of the native <video> control bar on mobile browsers.
983const VIDEO_CONTROL_STRIP_HEIGHT = 56;
984
985function consumeHorizontalSwipe(startX, startY, endX, endY, minDistance = 50) {
986    const deltaX = endX - startX;
987    const deltaY = endY - startY;
988    if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > minDistance) {
989        return deltaX > 0 ? 'prev' : 'next';
990    }
991    return null;
992}
993
994/**
995 * Product Detail Component
996 * Main component for the product detail page, managing variations, cart, wishlist, and UI state
997 */
998function productDetail(config) {
999    return {
1000        // Config passed from server
1001        product: config.product,
1002        businessId: config.businessId,
1003        reviewSummary: config.reviewSummary || { reviewCount: 0, averageRating: 0, rating1Count: 0, rating2Count: 0, rating3Count: 0, rating4Count: 0, rating5Count: 0 },
1004        isAuthenticated: config.isAuthenticated,
1005        loginUrl: config.loginUrl || '/account/login',
1006        antiforgeryToken: config.antiforgeryToken,
1007        antiforgeryHeaderName: config.antiforgeryHeaderName,
1008
1009        // Variation state
1010        selectedVariationIds: config.initialVariationIds || [],
1011
1012        // UI state
1013        quantity: 1,
1014        activeTab: config.activeTab || 'additional-information',
1015        isDescriptionExpanded: false,
1016        needsShowMore: false,
1017
1018        // Loading states
1019        addingToCart: false,
1020        togglingWishlist: false,
1021
1022        // Modal states
1023        registryModalOpen: false,
1024
1025        // Computed: minimum date for date needed field
1026        get minDateNeeded() {
1027            if (!this.product.requiresDateNeeded) return '';
1028            const minDate = new Date();
1029            minDate.setDate(minDate.getDate() + (this.product.minimumLeadTimeDays || 0));
1030            return minDate.toISOString().split('T')[0];
1031        },
1032
1033        // Normalize any id (Guid/number) to a comparable string
1034        idStr(id) {
1035            return id === null || id === undefined ? '' : (id.toString ? id.toString() : String(id));
1036        },
1037
1038        // Convenience: the product's variations (never null)
1039        get variations() {
1040            return Array.isArray(this.product.variations) ? this.product.variations : [];
1041        },
1042
1043        // Convenience: the product's variation options (never null)
1044        get variationOptions() {
1045            return Array.isArray(this.product.variationOptions) ? this.product.variationOptions : [];
1046        },
1047
1048        // Whether variations carry structured option-value ids (vs. legacy name-only)
1049        get hasStructuredVariations() {
1050            return this.variations.some(v => Array.isArray(v.optionValueIds) && v.optionValueIds.length > 0);
1051        },
1052
1053        // Whether every option has a chosen value
1054        get isSelectionComplete() {
1055            const options = this.variationOptions;
1056            if (options.length === 0) return true;
1057            return options.every((_, idx) => !!this.idStr(this.selectedVariationIds[idx]));
1058        },
1059
1060        // Computed: find the selected variant based on selected variation IDs
1061        get selectedVariant() {
1062            const variations = this.variations;
1063            if (variations.length === 0) return null;
1064
1065            const selected = (this.selectedVariationIds || [])
1066                .map(id => this.idStr(id))
1067                .filter(Boolean);
1068
1069            // Structured (id-based) matching — deterministic, order-independent
1070            if (this.hasStructuredVariations) {
1071                if (!this.isSelectionComplete) return null;
1072                const selectedKey = [...selected].sort().join('|');
1073                const match = variations.find(v => {
1074                    const ids = (v.optionValueIds || []).map(id => this.idStr(id));
1075                    return ids.length === selected.length && [...ids].sort().join('|') === selectedKey;
1076                });
1077                if (match) return match;
1078            }
1079
1080            // Fallback for legacy variations stored only as " / "-joined names
1081            const selectedNames = selected
1082                .map(id => this.findVariationOptionValueById(id)?.name?.trim()?.toLowerCase())
1083                .filter(Boolean);
1084            if (selectedNames.length === 0) return null;
1085
1086            return variations.find(variant => {
1087                const variantParts = (variant.name || '').split(' / ').map(p => p.trim().toLowerCase());
1088                return selectedNames.length === variantParts.length &&
1089                    selectedNames.every(name => variantParts.includes(name));
1090            }) || null;
1091        },
1092
1093        // Computed: variation image URL (derived from selected option values)
1094        get selectedVariationImageUrl() {
1095            const selectedIds = this.selectedVariant?.optionValueIds?.length
1096                ? this.selectedVariant.optionValueIds
1097                : this.selectedVariationIds;
1098
1099            const ids = (selectedIds || []).map((id) => id?.toString?.() ?? String(id));
1100            for (const id of ids) {
1101                const value = this.findVariationOptionValueById(id);
1102                if (value?.imageUrl) return value.imageUrl;
1103            }
1104
1105            return null;
1106        },
1107
1108        // Computed: get the price to display (variant price or product price)
1109        get selectedVariantPrice() {
1110            return this.selectedVariant?.price ?? this.product.price;
1111        },
1112
1113        // Computed: apply sale discount if applicable
1114        get displayPrice() {
1115            if (this.product.isCurrentlyOnSale) {
1116                return this.selectedVariantPrice * (1 - this.product.discountPercentage / 100);
1117            }
1118            return this.selectedVariantPrice;
1119        },
1120
1121        // Computed: available quantity
1122        get selectedVariantQuantity() {
1123            return this.selectedVariant?.quantity ?? this.product.maxQuantity ?? 999;
1124        },
1125
1126        // Computed: SKU display
1127        get selectedVariantSku() {
1128            return this.selectedVariant?.sku || this.product.sku || 'N/A';
1129        },
1130
1131        // Computed: variant inactive flag
1132        get selectedVariantInactive() {
1133            return this.selectedVariant?.active === false;
1134        },
1135
1136        // Computed: out of stock check
1137        get isOutOfStock() {
1138            if (this.product.continueSellingWhenOutOfStock) return false;
1139            return this.selectedVariantQuantity < 1;
1140        },
1141
1142        // Computed: product has options that still need to be chosen
1143        get needsVariationSelection() {
1144            return this.variationOptions.length > 0 && !this.isSelectionComplete;
1145        },
1146
1147        init() {
1148            // If there's exactly one purchasable variation, preselect it so the
1149            // shopper sees the right price/SKU/stock without any interaction.
1150            this.autoSelectSoleVariation();
1151
1152            // Check if description needs "Show more" button
1153            this.$nextTick(() => {
1154                this.updateDescriptionOverflow();
1155                setTimeout(() => this.updateDescriptionOverflow(), 100);
1156            });
1157
1158            if (document.fonts && document.fonts.ready) {
1159                document.fonts.ready.then(() => this.updateDescriptionOverflow());
1160            }
1161            if (document.readyState !== 'complete') {
1162                listenWhileMounted(this, window, 'load', () => this.updateDescriptionOverflow(), { once: true });
1163            }
1164            listenWhileMounted(this, window, 'resize', () => this.updateDescriptionOverflow());
1165
1166            // Mark product as viewed
1167            this.markAsViewed();
1168
1169            listenWhileMounted(this, window, 'close-registry-modal', () => {
1170                this.closeRegistryModal();
1171            });
1172
1173            // Ensure the gallery reflects the initially selected variation.
1174            this.$nextTick(() => this.syncGalleryToSelectedVariation());
1175        },
1176
1177        // Preselect the only variation when a product has exactly one
1178        autoSelectSoleVariation() {
1179            if (!this.hasStructuredVariations) return;
1180            if (this.variations.length !== 1) return;
1181            const alreadyChosen = (this.selectedVariationIds || []).some(id => !!this.idStr(id));
1182            if (alreadyChosen) return;
1183
1184            const sole = this.variations[0];
1185            const options = this.variationOptions;
1186            const next = options.map(opt => {
1187                const optionValueIds = new Set((sole.optionValueIds || []).map(id => this.idStr(id)));
1188                const match = (opt.values || []).find(v => optionValueIds.has(this.idStr(v.id)));
1189                return match ? this.idStr(match.id) : '';
1190            });
1191            this.selectedVariationIds = next;
1192        },
1193
1194        // Find a variation option value by its ID (string/Guid)
1195        findVariationOptionValueById(valueId) {
1196            if (!valueId || !this.product?.variationOptions) return null;
1197            const idStr = valueId.toString();
1198            for (const option of this.product.variationOptions) {
1199                const match = option?.values?.find(v => v?.id?.toString?.() === idStr);
1200                if (match) return match;
1201            }
1202            return null;
1203        },
1204
1205        // Dispatch a gallery update if the current selection has an associated image
1206        syncGalleryToSelectedVariation() {
1207            const imageUrl = this.selectedVariationImageUrl;
1208            if (!imageUrl) return;
1209            window.dispatchEvent(new CustomEvent('variation-image-selected', {
1210                detail: { imageUrl }
1211            }));
1212        },
1213
1214        // Format price for display
1215        formatPrice(amount) {
1216            return new Intl.NumberFormat('en-US', {
1217                style: 'currency',
1218                currency: 'USD'
1219            }).format(amount);
1220        },
1221
1222        // Index of an option within variationOptions by its id
1223        optionIndexById(optionId) {
1224            return this.variationOptions.findIndex(o => this.idStr(o.id) === this.idStr(optionId));
1225        },
1226
1227        // The chosen value id for a given option (or '')
1228        selectedValueIdFor(optionId) {
1229            const idx = this.optionIndexById(optionId);
1230            return idx === -1 ? '' : this.idStr(this.selectedVariationIds[idx]);
1231        },
1232
1233        // Is a specific option value currently chosen?
1234        isValueSelected(optionId, valueId) {
1235            return this.selectedValueIdFor(optionId) === this.idStr(valueId);
1236        },
1237
1238        // Display name of the chosen value for an option (for the inline label)
1239        selectedValueName(optionId) {
1240            const valueId = this.selectedValueIdFor(optionId);
1241            if (!valueId) return '';
1242            return this.findVariationOptionValueById(valueId)?.name || '';
1243        },
1244
1245        // Active variations that include a given option value
1246        activeVariationsWithValue(valueId) {
1247            const vId = this.idStr(valueId);
1248            return this.variations.filter(v =>
1249                v.active !== false &&
1250                (v.optionValueIds || []).map(id => this.idStr(id)).includes(vId)
1251            );
1252        },
1253
1254        // Whether a variation can be purchased (in stock or backorder allowed)
1255        isVariationPurchasable(variation) {
1256            if (!variation) return false;
1257            return this.product.continueSellingWhenOutOfStock || (variation.quantity ?? 0) > 0;
1258        },
1259
1260        // Map a variation's option-value ids to an array aligned to option order
1261        variationByOption(variation) {
1262            const ids = (variation.optionValueIds || []).map(id => this.idStr(id));
1263            return this.variationOptions.map(opt => {
1264                const optValueIds = new Set((opt.values || []).map(val => this.idStr(val.id)));
1265                return ids.find(id => optValueIds.has(id)) || '';
1266            });
1267        },
1268
1269        // Availability of an option value, independent of sibling selections so the
1270        // matrix is always navigable (selecting snaps siblings to a valid combo).
1271        // Returns 'available' | 'oos' (exists but sold out) | 'unavailable' (no variation at all).
1272        optionValueState(optionId, valueId) {
1273            if (!this.hasStructuredVariations) return 'available';
1274
1275            const matches = this.activeVariationsWithValue(valueId);
1276            if (matches.length === 0) return 'unavailable';
1277
1278            return matches.some(v => this.isVariationPurchasable(v)) ? 'available' : 'oos';
1279        },
1280
1281        // Apply a value selection by snapping every option to a concrete, valid variation.
1282        // Preserves as many of the shopper's other choices as possible, preferring in-stock.
1283        applyValueSelection(optionIndex, valueId) {
1284            const vId = this.idStr(valueId);
1285
1286            // Legacy (name-only) variations: keep simple positional assignment.
1287            if (!this.hasStructuredVariations) {
1288                const newIds = [...this.selectedVariationIds];
1289                newIds[optionIndex] = vId;
1290                this.selectedVariationIds = newIds;
1291                this.clampQuantityToStock();
1292                this.syncGalleryToSelectedVariation();
1293                return;
1294            }
1295
1296            const candidates = this.activeVariationsWithValue(vId);
1297            if (candidates.length === 0) return; // truly unavailable; ignore
1298
1299            const current = (this.selectedVariationIds || []).map(id => this.idStr(id));
1300
1301            let best = null;
1302            let bestScore = -Infinity;
1303            candidates.forEach(v => {
1304                const byOption = this.variationByOption(v);
1305                let overlap = 0;
1306                byOption.forEach((id, idx) => {
1307                    if (idx !== optionIndex && id && id === current[idx]) overlap++;
1308                });
1309                // Preserving the shopper's other choices dominates; break ties by stock.
1310                const score = overlap * 2 + (this.isVariationPurchasable(v) ? 1 : 0);
1311                if (score > bestScore) {
1312                    bestScore = score;
1313                    best = byOption;
1314                }
1315            });
1316
1317            if (best) {
1318                best[optionIndex] = vId;
1319                this.selectedVariationIds = best;
1320            }
1321
1322            this.clampQuantityToStock();
1323            this.syncGalleryToSelectedVariation();
1324        },
1325
1326        // Select an option value (chip/swatch selector)
1327        selectValue(optionId, valueId) {
1328            if (this.optionValueState(optionId, valueId) === 'unavailable') return;
1329            const optionIndex = this.optionIndexById(optionId);
1330            if (optionIndex === -1) return;
1331            this.applyValueSelection(optionIndex, valueId);
1332        },
1333
1334        // Handle variation option change (native <select> fallback for large option sets)
1335        handleVariationChange(optionId, valueId) {
1336            const optionIndex = this.optionIndexById(optionId);
1337            if (optionIndex === -1) return;
1338            this.applyValueSelection(optionIndex, valueId);
1339        },
1340
1341        // Keep the chosen quantity within the selected variant's available stock
1342        clampQuantityToStock() {
1343            const max = this.selectedVariantQuantity;
1344            if (Number.isFinite(max) && this.quantity > max) {
1345                this.quantity = Math.max(1, max);
1346            }
1347        },
1348
1349        // Check if a specific variation value should be disabled (no variation uses it)
1350        isVariationDisabled(optionId, valueId) {
1351            return this.optionValueState(optionId, valueId) === 'unavailable';
1352        },
1353
1354        // Quantity controls
1355        incrementQuantity() {
1356            if (this.quantity < this.selectedVariantQuantity) {
1357                this.quantity++;
1358            }
1359        },
1360
1361        decrementQuantity() {
1362            if (this.quantity > 1) {
1363                this.quantity--;
1364            }
1365        },
1366
1367        // Description expand/collapse
1368        toggleDescription() {
1369            this.isDescriptionExpanded = !this.isDescriptionExpanded;
1370
1371            // Re-check overflow when collapsing (wait for transition)
1372            if (!this.isDescriptionExpanded) {
1373                setTimeout(() => this.updateDescriptionOverflow(), 350);
1374            }
1375        },
1376
1377        updateDescriptionOverflow() {
1378            const descEl = this.$refs.description;
1379            if (!descEl || this.isDescriptionExpanded) return;
1380            this.needsShowMore = descEl.scrollHeight > descEl.clientHeight;
1381        },
1382
1383        // Tab switching with URL update
1384        switchTab(tabId) {
1385            this.activeTab = tabId;
1386
1387            const url = new URL(window.location.href);
1388            if (tabId === 'additional-information') {
1389                url.searchParams.delete('tab');
1390            } else {
1391                url.searchParams.set('tab', tabId);
1392            }
1393
1394            window.history.replaceState({}, '', url.toString());
1395        },
1396
1397        // Scroll to reviews tab
1398        scrollToReviews() {
1399            this.switchTab('reviews');
1400            this.$nextTick(() => {
1401                const tabsSection = document.querySelector('[data-tabs-section]');
1402                if (tabsSection) {
1403                    tabsSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
1404                }
1405            });
1406        },
1407
1408        // Add to cart
1409        async addToCart() {
1410            if (!this.isAuthenticated) {
1411                navigateTo(this.loginUrl + '?returnUrl=' + encodeURIComponent(window.location.pathname));
1412                return;
1413            }
1414
1415            if (this.needsVariationSelection) {
1416                this.showToast('error', 'Please select all options first.');
1417                return;
1418            }
1419
1420            if (this.isOutOfStock || this.selectedVariantInactive || this.addingToCart) {
1421                return;
1422            }
1423
1424            this.addingToCart = true;
1425
1426            try {
1427                const form = this.$refs.productForm;
1428                const formData = new FormData(form);
1429
1430                const payload = {
1431                    productId: this.product.id,
1432                    quantity: this.quantity,
1433                    personalization: formData.get('personalization') || null,
1434                    dateNeeded: formData.get('dateNeeded') || null,
1435                    variationId: this.selectedVariant?.id || null
1436                };
1437
1438                const response = await fetch('/api/shopping-cart/items', {
1439                    method: 'POST',
1440                    headers: this.getApiHeaders(),
1441                    body: JSON.stringify(payload)
1442                });
1443
1444                const data = await response.json();
1445
1446                if (!response.ok) {
1447                    const errorMsg = data.errors
1448                        ? Object.values(data.errors).flat()[0]
1449                        : data.message || 'Failed to add to cart';
1450                    this.showToast('error', errorMsg);
1451                    return;
1452                }
1453
1454                // Notify cart badge to update
1455                window.dispatchEvent(new CustomEvent('cart-updated'));
1456
1457                // Show success toast
1458                this.showToast('success', 'Added to cart', {
1459                    label: 'View Cart',
1460                    href: '/products/cart'
1461                });
1462
1463            } catch (error) {
1464                console.error('Add to cart error:', error);
1465                this.showToast('error', 'Failed to add to cart. Please try again.');
1466            } finally {
1467                this.addingToCart = false;
1468            }
1469        },
1470
1471        // Toggle wishlist
1472        async toggleWishlist() {
1473            if (!this.isAuthenticated) {
1474                navigateTo(this.loginUrl + '?returnUrl=' + encodeURIComponent(window.location.pathname));
1475                return;
1476            }
1477
1478            if (this.togglingWishlist) return;
1479
1480            this.togglingWishlist = true;
1481            const wasWishlisted = this.product.isWishlisted;
1482
1483            try {
1484                const url = wasWishlisted
1485                    ? `/api/wishlists/products/${this.product.id}`
1486                    : '/api/wishlists/products';
1487
1488                const response = await fetch(url, {
1489                    method: wasWishlisted ? 'DELETE' : 'POST',
1490                    headers: this.getApiHeaders(),
1491                    body: JSON.stringify({ productId: this.product.id })
1492                });
1493
1494                if (!response.ok) {
1495                    const data = await response.json();
1496                    this.showToast('error', data.message || 'Failed to update wishlist');
1497                    return;
1498                }
1499
1500                // Toggle the local state
1501                this.product.isWishlisted = !wasWishlisted;
1502
1503                if (wasWishlisted) {
1504                    this.showToast('success', 'Removed from wishlist');
1505                } else {
1506                    this.showToast('success', 'Added to wishlist', {
1507                        label: 'View Wishlist',
1508                        href: '/account/wishlist'
1509                    });
1510                }
1511
1512            } catch (error) {
1513                console.error('Wishlist toggle error:', error);
1514                this.showToast('error', 'Failed to update wishlist. Please try again.');
1515            } finally {
1516                this.togglingWishlist = false;
1517            }
1518        },
1519
1520        // Modal controls
1521        openRegistryModal() {
1522            if (!this.isAuthenticated) {
1523                navigateTo(this.loginUrl + '?returnUrl=' + encodeURIComponent(window.location.pathname));
1524                return;
1525            }
1526            this.registryModalOpen = true;
1527            document.body.style.overflow = 'hidden';
1528        },
1529
1530        closeRegistryModal() {
1531            this.registryModalOpen = false;
1532            document.body.style.overflow = '';
1533        },
1534
1535        openContactSellerModal() {
1536            if (!this.isAuthenticated) {
1537                navigateTo(this.loginUrl + '?returnUrl=' + encodeURIComponent(window.location.pathname));
1538                return;
1539            }
1540
1541            window.dispatchEvent(new CustomEvent('open-contact-seller'));
1542        },
1543
1544        // Send message to seller - this is handled by the modal's own Alpine component
1545        // This method is kept for compatibility but the actual implementation is in the modal
1546
1547        // Mark product as viewed (for recommendations)
1548        async markAsViewed() {
1549            const storageKey = `finnalby:viewed-product:${this.product.id}`;
1550            const dedupeWindowMs = 30 * 60 * 1000;
1551            const lastViewedAt = parseInt(sessionStorage.getItem(storageKey), 10);
1552            if (!Number.isNaN(lastViewedAt) && Date.now() - lastViewedAt < dedupeWindowMs) return;
1553
1554            try {
1555                const response = await fetch(`/api/products/${this.product.id}/mark-as-viewed`, {
1556                    method: 'POST',
1557                    headers: this.getApiHeaders(),
1558                    body: JSON.stringify({
1559                        viewContext: 'product_detail',
1560                        referrerType: document.referrer ? 'internal' : 'direct'
1561                    })
1562                });
1563                if (response.ok) sessionStorage.setItem(storageKey, Date.now().toString());
1564            } catch (error) {
1565                // Silent fail - not critical
1566                console.debug('Failed to mark product as viewed:', error);
1567            }
1568        },
1569
1570        // Helper to show toast notifications
1571        showToast(type, message, action = null) {
1572            window.dispatchEvent(new CustomEvent('show-toast', {
1573                detail: { type, message, action }
1574            }));
1575        },
1576
1577        // Helper to get API headers including antiforgery token
1578        getApiHeaders() {
1579            const headers = { 'Content-Type': 'application/json' };
1580            if (this.antiforgeryToken && this.antiforgeryHeaderName) {
1581                headers[this.antiforgeryHeaderName] = this.antiforgeryToken;
1582            }
1583            return headers;
1584        }
1585    };
1586}
1587
1588
1589/**
1590 * Product Gallery Component
1591 * Handles image carousel, thumbnails, and lightbox functionality
1592 */
1593function productGallery(product) {
1594    return {
1595        mediaItems: [],
1596        activeIndex: 0,
1597        // Persisted mute preference for product gallery videos (default: muted)
1598        videoMuteStorageKey: 'pdp.videoMuted',
1599        // Touch swipe tracking
1600        touchStartX: 0,
1601        touchStartY: 0,
1602        ignoreSwipe: false,
1603        didSwipe: false,
1604
1605        get activeItem() {
1606            return this.mediaItems[this.activeIndex] || null;
1607        },
1608        get activeItemIsImage() {
1609            return this.activeItem?.mediaType === 'image';
1610        },
1611
1612        getVideoMutedPreference() {
1613            try {
1614                const stored = localStorage.getItem(this.videoMuteStorageKey);
1615                if (stored === null) return true; // default muted
1616                return stored === 'true';
1617            } catch {
1618                return true;
1619            }
1620        },
1621
1622        setVideoMutedPreference(muted) {
1623            try {
1624                localStorage.setItem(this.videoMuteStorageKey, String(Boolean(muted)));
1625            } catch {
1626                // private mode / quota — ignore
1627            }
1628        },
1629
1630        applyVideoMutePreference(videoEl) {
1631            if (!(videoEl instanceof HTMLVideoElement)) return;
1632            videoEl.muted = this.getVideoMutedPreference();
1633        },
1634
1635        onVideoVolumeChange(event) {
1636            const video = event?.target;
1637            if (!(video instanceof HTMLVideoElement)) return;
1638            // Native mute button toggles .muted; persist that preference
1639            this.setVideoMutedPreference(video.muted);
1640        },
1641
1642        normalizeImageItem(image) {
1643            return {
1644                key: `image-${image?.id ?? crypto.randomUUID()}`,
1645                id: image?.id,
1646                mediaType: 'image',
1647                imageUrl: image?.imageUrl,
1648                caption: image?.caption || '',
1649                thumbnailUrl: image?.imageUrl || '',
1650            };
1651        },
1652
1653        normalizeVideoItem(video, fallbackPosterUrl = '') {
1654            return {
1655                key: `video-${video?.id ?? crypto.randomUUID()}`,
1656                id: video?.id,
1657                mediaType: 'video',
1658                videoUrl: video?.videoUrl,
1659                posterImageUrl: video?.posterImageUrl || fallbackPosterUrl || '',
1660                caption: 'Product video',
1661                thumbnailUrl: video?.posterImageUrl || fallbackPosterUrl || '',
1662            };
1663        },
1664
1665        buildMediaItems(productData) {
1666            const productImages = Array.isArray(productData?.productImages) ? productData.productImages : [];
1667            const imageItems = productImages.map((image) => this.normalizeImageItem(image));
1668            const fallbackPosterUrl = imageItems[0]?.imageUrl || '';
1669            const videoItem = productData?.productVideo
1670                ? this.normalizeVideoItem(productData.productVideo, fallbackPosterUrl)
1671                : null;
1672
1673            if (!videoItem) {
1674                return imageItems;
1675            }
1676
1677            if (imageItems.length === 0) {
1678                return [videoItem];
1679            }
1680
1681            return [imageItems[0], videoItem, ...imageItems.slice(1)];
1682        },
1683
1684        normalizeImageUrl(url) {
1685            if (!url) return '';
1686            let normalized = String(url).trim();
1687            if (normalized.startsWith('//')) {
1688                normalized = `${window.location.protocol}${normalized}`;
1689            }
1690
1691            try {
1692                normalized = new URL(normalized, window.location.origin).href;
1693            } catch {
1694                // Best-effort; keep original if URL parsing fails
1695            }
1696
1697            // Drop hash for comparison
1698            normalized = normalized.split('#')[0];
1699            return normalized;
1700        },
1701
1702        stripQuery(url) {
1703            if (!url) return '';
1704            return String(url).split('?')[0];
1705        },
1706
1707        init() {
1708            const productData = product || window.productDetailConfig?.product || {};
1709            this.mediaItems = this.buildMediaItems(productData);
1710            
1711            // Ensure we have a valid activeIndex
1712            if (this.mediaItems.length > 0) {
1713                this.activeIndex = 0;
1714            }
1715
1716            this.$watch('activeIndex', () => this.scrollActiveThumbnailIntoView());
1717            
1718            // Listen for variation image selection
1719            listenWhileMounted(this, window, 'variation-image-selected', (e) => {
1720                const imageUrl = e.detail?.imageUrl;
1721                if (imageUrl && this.mediaItems.length > 0) {
1722                    const target = this.normalizeImageUrl(imageUrl);
1723                    const targetNoQuery = this.stripQuery(target);
1724
1725                    const index = this.mediaItems.findIndex((item) => {
1726                        if (item?.mediaType !== 'image') return false;
1727                        const candidate = this.normalizeImageUrl(item?.imageUrl);
1728                        if (!candidate) return false;
1729
1730                        if (candidate === target) return true;
1731
1732                        const candidateNoQuery = this.stripQuery(candidate);
1733                        return candidateNoQuery === targetNoQuery;
1734                    });
1735
1736                    if (index !== -1) {
1737                        this.slideTo(index);
1738                        return;
1739                    }
1740
1741                    // Fallback: if the variation image isn't already in the gallery, inject it as the first image.
1742                    // This ensures the main image still reflects the user's selection.
1743                    this.mediaItems = [
1744                        {
1745                            key: `variation-${Date.now()}`,
1746                            id: `variation-${Date.now()}`,
1747                            mediaType: 'image',
1748                            imageUrl: imageUrl,
1749                            thumbnailUrl: imageUrl,
1750                            caption: 'Selected variation'
1751                        },
1752                        ...this.mediaItems
1753                    ];
1754                    this.activeIndex = 0;
1755                }
1756            });
1757        },
1758
1759        slideTo(index) {
1760            if (index >= 0 && index < this.mediaItems.length) {
1761                this.activeIndex = index;
1762            }
1763        },
1764
1765        nextImage() {
1766            this.activeIndex = (this.activeIndex + 1) % this.mediaItems.length;
1767        },
1768
1769        prevImage() {
1770            this.activeIndex = (this.activeIndex - 1 + this.mediaItems.length) % this.mediaItems.length;
1771        },
1772
1773        scrollActiveThumbnailIntoView() {
1774            this.$nextTick(() => {
1775                const thumb = this.$root?.querySelector?.('.pdp-gallery-thumbnail.active');
1776                const strip = thumb?.closest('.pdp-gallery-thumbnails');
1777                if (!thumb || !strip) return;
1778
1779                // Scroll the strip only: scrollIntoView also scrolled the page, 75px down on
1780                // every product page load.
1781                const thumbRect = thumb.getBoundingClientRect();
1782                const stripRect = strip.getBoundingClientRect();
1783                const offset = thumbRect.left < stripRect.left
1784                    ? thumbRect.left - stripRect.left
1785                    : Math.max(0, thumbRect.right - stripRect.right);
1786                if (offset !== 0) strip.scrollBy({ left: offset, behavior: 'smooth' });
1787            });
1788        },
1789
1790        shouldIgnoreGallerySwipe(event) {
1791            const target = event?.target;
1792            if (!(target instanceof Element)) return false;
1793            if (target.closest('button')) return true;
1794
1795            // A video fills the whole slide, so swiping over its body must still
1796            // navigate. Only leave the native control strip along the bottom
1797            // alone, where a horizontal drag scrubs the timeline instead.
1798            const video = target.closest('video');
1799            if (!video) return false;
1800            const touch = event.changedTouches?.[0];
1801            if (!touch) return true;
1802            const rect = video.getBoundingClientRect();
1803            return touch.clientY >= rect.bottom - VIDEO_CONTROL_STRIP_HEIGHT;
1804        },
1805
1806        handleTouchStart(event) {
1807            this.didSwipe = false;
1808            this.ignoreSwipe = this.mediaItems.length <= 1 || this.shouldIgnoreGallerySwipe(event);
1809            if (this.ignoreSwipe) return;
1810
1811            const touch = event.changedTouches?.[0];
1812            if (!touch) return;
1813            this.touchStartX = touch.screenX;
1814            this.touchStartY = touch.screenY;
1815        },
1816
1817        handleTouchEnd(event) {
1818            if (this.ignoreSwipe) return;
1819
1820            const touch = event.changedTouches?.[0];
1821            if (!touch) return;
1822
1823            const direction = consumeHorizontalSwipe(
1824                this.touchStartX,
1825                this.touchStartY,
1826                touch.screenX,
1827                touch.screenY
1828            );
1829            if (!direction) return;
1830
1831            this.didSwipe = true;
1832            if (direction === 'prev') {
1833                this.prevImage();
1834            } else {
1835                this.nextImage();
1836            }
1837        },
1838
1839        onImageClick(index) {
1840            if (this.didSwipe) {
1841                this.didSwipe = false;
1842                return;
1843            }
1844            this.openLightbox(index);
1845        },
1846
1847        openLightbox(index = null) {
1848            if (index !== null) {
1849                this.activeIndex = index;
1850            }
1851
1852            const activeItem = this.mediaItems[this.activeIndex];
1853            if (!activeItem || activeItem.mediaType !== 'image') {
1854                return;
1855            }
1856
1857            const images = this.mediaItems.filter((item) => item.mediaType === 'image');
1858            const activeImageIndex =
1859                this.mediaItems.slice(0, this.activeIndex + 1).filter((item) => item.mediaType === 'image').length - 1;
1860
1861            // Dispatch event to global lightbox component
1862            window.dispatchEvent(new CustomEvent('pdp-lightbox-open', {
1863                detail: { images, activeIndex: Math.max(activeImageIndex, 0) }
1864            }));
1865        }
1866    };
1867}
1868
1869
1870/**
1871 * PDP Lightbox Component
1872 * Global lightbox that floats above all stacking contexts
1873 */
1874function pdpLightbox() {
1875    return {
1876        open: false,
1877        images: [],
1878        activeIndex: 0,
1879        previousBodyScrollState: null,
1880        
1881        // Touch swipe tracking
1882        touchStartX: 0,
1883        touchStartY: 0,
1884        
1885        prevImage() {
1886            this.activeIndex = (this.activeIndex - 1 + this.images.length) % this.images.length;
1887        },
1888        nextImage() {
1889            this.activeIndex = (this.activeIndex + 1) % this.images.length;
1890        },
1891        init() {
1892            listenWhileMounted(this, window, 'pdp-lightbox-open', (e) => {
1893                this.images = e.detail?.images || [];
1894                this.activeIndex = e.detail?.activeIndex || 0;
1895                this.open = true;
1896                this.previousBodyScrollState = lockBodyScroll();
1897            });
1898        },
1899        closeLightbox() {
1900            this.open = false;
1901            unlockBodyScroll(this.previousBodyScrollState);
1902            this.previousBodyScrollState = null;
1903        },
1904        
1905        // Touch event handlers for swipe
1906        handleTouchStart(e) {
1907            const touch = e.changedTouches?.[0];
1908            if (!touch) return;
1909            this.touchStartX = touch.screenX;
1910            this.touchStartY = touch.screenY;
1911        },
1912        
1913        handleTouchEnd(e) {
1914            const touch = e.changedTouches?.[0];
1915            if (!touch) return;
1916
1917            const direction = consumeHorizontalSwipe(
1918                this.touchStartX,
1919                this.touchStartY,
1920                touch.screenX,
1921                touch.screenY
1922            );
1923            if (direction === 'prev') {
1924                this.prevImage();
1925            } else if (direction === 'next') {
1926                this.nextImage();
1927            }
1928        }
1929    };
1930}
1931
1932/**
1933 * Contact Seller Modal Component
1934 * Sends a pre-order message to the seller with Cloudflare Turnstile validation.
1935 */
1936function contactSellerModal(config) {
1937    return {
1938        open: false,
1939        productId: config?.product?.id,
1940        businessId: config?.businessId,
1941        productName: config?.product?.name || '',
1942        sellerName: config?.sellerName || config?.product?.marketName || 'Seller',
1943        isAuthenticated: config?.isAuthenticated,
1944        loginUrl: config?.loginUrl || '/account/login',
1945        cloudflareSiteKey: config?.cloudflareSiteKey || '',
1946        antiforgeryToken: config?.antiforgeryToken,
1947        antiforgeryHeaderName: config?.antiforgeryHeaderName,
1948        subject: '',
1949        message: '',
1950        isSubmitting: false,
1951        errorMessage: '',
1952        isVerified: false,
1953        turnstileToken: '',
1954        turnstileWidgetId: null,
1955        turnstileRetryTimer: null,
1956        idempotencyKey: '',
1957        previousBodyScrollState: null,
1958
1959        init() {
1960            this.resetForm();
1961        },
1962
1963        destroy() {
1964            clearTimeout(this.turnstileRetryTimer);
1965            removeTurnstileWidget(this.turnstileWidgetId);
1966        },
1967
1968        resetForm() {
1969            this.subject = this.productName ? `Question about ${this.productName}` : 'Question about this product';
1970            this.message = '';
1971            this.errorMessage = '';
1972            this.isVerified = false;
1973            this.turnstileToken = '';
1974            this.idempotencyKey = this.generateIdempotencyKey();
1975        },
1976
1977        openModal() {
1978            if (!this.isAuthenticated) {
1979                navigateTo(this.loginUrl + '?returnUrl=' + encodeURIComponent(window.location.pathname));
1980                return;
1981            }
1982
1983            this.open = true;
1984            this.resetForm();
1985            this.previousBodyScrollState = lockBodyScroll();
1986            this.$nextTick(() => this.ensureTurnstile());
1987        },
1988
1989        closeModal() {
1990            this.open = false;
1991            this.errorMessage = '';
1992            unlockBodyScroll(this.previousBodyScrollState);
1993            this.previousBodyScrollState = null;
1994            this.resetTurnstile();
1995        },
1996
1997        ensureTurnstile() {
1998            if (!this.open) return;
1999            if (!this.cloudflareSiteKey) {
2000                this.errorMessage = 'Verification is temporarily unavailable. Please try again later.';
2001                return;
2002            }
2003
2004            const container = this.$refs.turnstileContainer;
2005            if (!container) return;
2006
2007            if (!window.turnstile) {
2008                loadScriptOnce(TURNSTILE_SCRIPT).catch(() => {});
2009                this.turnstileRetryTimer = setTimeout(() => this.ensureTurnstile(), 300);
2010                return;
2011            }
2012
2013            if (this.turnstileWidgetId !== null) {
2014                window.turnstile.reset(this.turnstileWidgetId);
2015                return;
2016            }
2017
2018            this.turnstileWidgetId = window.turnstile.render(container, {
2019                sitekey: this.cloudflareSiteKey,
2020                callback: (token) => {
2021                    this.turnstileToken = token;
2022                    this.isVerified = true;
2023                },
2024                'expired-callback': () => {
2025                    this.turnstileToken = '';
2026                    this.isVerified = false;
2027                },
2028                'error-callback': () => {
2029                    this.turnstileToken = '';
2030                    this.isVerified = false;
2031                    this.errorMessage = 'Verification failed. Please try again.';
2032                }
2033            });
2034        },
2035
2036        resetTurnstile() {
2037            if (this.turnstileRetryTimer) {
2038                clearTimeout(this.turnstileRetryTimer);
2039                this.turnstileRetryTimer = null;
2040            }
2041            if (window.turnstile && this.turnstileWidgetId !== null) {
2042                window.turnstile.reset(this.turnstileWidgetId);
2043            }
2044            this.turnstileToken = '';
2045            this.isVerified = false;
2046        },
2047
2048        generateIdempotencyKey() {
2049            if (window.crypto && window.crypto.randomUUID) {
2050                return window.crypto.randomUUID();
2051            }
2052
2053            return `msg-${Date.now()}-${Math.random().toString(16).slice(2)}`;
2054        },
2055
2056        async sendMessage() {
2057            if (this.isSubmitting) return;
2058            this.errorMessage = '';
2059
2060            const subject = (this.subject || '').trim();
2061            const message = (this.message || '').trim();
2062
2063            if (!subject) {
2064                this.errorMessage = 'Subject is required.';
2065                return;
2066            }
2067
2068            if (!message) {
2069                this.errorMessage = 'Message is required.';
2070                return;
2071            }
2072
2073            if (!this.turnstileToken) {
2074                this.errorMessage = 'Please complete the verification.';
2075                return;
2076            }
2077
2078            this.isSubmitting = true;
2079
2080            try {
2081                const response = await fetch('/api/conversations/send-to-seller', {
2082                    method: 'POST',
2083                    headers: this.getApiHeaders(),
2084                    body: JSON.stringify({
2085                        cloudflareTurnstileToken: this.turnstileToken,
2086                        idempotencyKey: this.idempotencyKey,
2087                        businessId: this.businessId,
2088                        productId: this.productId,
2089                        subject,
2090                        message
2091                    })
2092                });
2093
2094                if (response.status === 401 || response.status === 403) {
2095                    navigateTo(this.loginUrl + '?returnUrl=' + encodeURIComponent(window.location.pathname));
2096                    return;
2097                }
2098
2099                const data = await response.json().catch(() => ({}));
2100
2101                if (!response.ok) {
2102                    this.errorMessage = data.message || 'Failed to send message. Please try again.';
2103                    this.showToast('error', this.errorMessage);
2104                    return;
2105                }
2106
2107                this.showToast('success', data.message || 'Message sent to the seller.');
2108                this.closeModal();
2109            } catch (error) {
2110                console.error('Send message to seller error:', error);
2111                this.errorMessage = 'Failed to send message. Please try again.';
2112                this.showToast('error', this.errorMessage);
2113            } finally {
2114                this.isSubmitting = false;
2115                this.idempotencyKey = this.generateIdempotencyKey();
2116                this.resetTurnstile();
2117            }
2118        },
2119
2120        showToast(type, message, action = null) {
2121            window.dispatchEvent(new CustomEvent('show-toast', {
2122                detail: { type, message, action }
2123            }));
2124        },
2125
2126        getApiHeaders() {
2127            const headers = { 'Content-Type': 'application/json' };
2128            if (this.antiforgeryToken && this.antiforgeryHeaderName) {
2129                headers[this.antiforgeryHeaderName] = this.antiforgeryToken;
2130            }
2131            return headers;
2132        }
2133    };
2134}
2135
2136
2137/**
2138 * Product Reviews Component
2139 * Handles reviews listing, pagination, voting, and reporting
2140 */
2141function productReviews(productId, initialSummary) {
2142    return {
2143        productId: productId,
2144        summary: initialSummary || { reviewCount: 0, averageRating: 0, rating1Count: 0, rating2Count: 0, rating3Count: 0, rating4Count: 0, rating5Count: 0 },
2145        reviews: [],
2146        loading: false,
2147        page: 1,
2148        pageSize: 10,
2149        totalPages: 1,
2150        sortBy: 'recent',
2151        ratingFilter: null,
2152        pendingVoteId: null,
2153        pendingReportId: null,
2154        reportedReviewIds: new Set(),
2155
2156        init() {
2157            this.loadReviews();
2158        },
2159
2160        async loadReviews() {
2161            this.loading = true;
2162
2163            try {
2164                const params = new URLSearchParams({
2165                    page: this.page.toString(),
2166                    pageSize: this.pageSize.toString(),
2167                    sortBy: this.sortBy
2168                });
2169
2170                if (this.ratingFilter) {
2171                    params.set('ratingFilter', this.ratingFilter.toString());
2172                }
2173
2174                const response = await fetch(`/api/products/${this.productId}/reviews?${params}`);
2175
2176                if (!response.ok) {
2177                    throw new Error('Failed to load reviews');
2178                }
2179
2180                const data = await response.json();
2181                const result = data.result ?? data;
2182
2183                this.reviews = result.reviews ?? [];
2184                this.totalPages = result.totalPages ?? 1;
2185
2186                if (result.summary) {
2187                    this.summary = result.summary;
2188                }
2189
2190            } catch (error) {
2191                console.error('Load reviews error:', error);
2192                this.reviews = [];
2193            } finally {
2194                this.loading = false;
2195            }
2196        },
2197
2198        changeSort(sortBy) {
2199            this.sortBy = sortBy;
2200            this.page = 1;
2201            this.loadReviews();
2202        },
2203
2204        changeRatingFilter(rating) {
2205            // Toggle filter off if clicking the same rating
2206            if (this.ratingFilter === rating) {
2207                this.ratingFilter = null;
2208            } else {
2209                this.ratingFilter = rating;
2210            }
2211            this.page = 1;
2212            this.loadReviews();
2213        },
2214
2215        changePage(newPage) {
2216            if (newPage >= 1 && newPage <= this.totalPages) {
2217                this.page = newPage;
2218                this.loadReviews();
2219            }
2220        },
2221
2222        async vote(reviewId, isHelpful) {
2223            if (this.pendingVoteId) return;
2224
2225            this.pendingVoteId = reviewId;
2226
2227            try {
2228                // Get token from global config if available
2229                const token = window.productDetailConfig?.antiforgeryToken;
2230                const header = window.productDetailConfig?.antiforgeryHeaderName;
2231                const headers = { 'Content-Type': 'application/json' };
2232                if (token && header) headers[header] = token;
2233
2234                const response = await fetch(`/api/reviews/${reviewId}/vote`, {
2235                    method: 'POST',
2236                    headers: headers,
2237                    body: JSON.stringify({ isHelpful })
2238                });
2239
2240                const data = await response.json().catch(() => ({}));
2241
2242                if (!response.ok) {
2243                    window.dispatchEvent(new CustomEvent('show-toast', {
2244                        detail: { type: 'error', message: data.message || 'Failed to vote' }
2245                    }));
2246                    return;
2247                }
2248
2249                // Update the local review count
2250                const review = this.reviews.find(r => r.id === reviewId);
2251                if (review) {
2252                    review.helpfulCount = data.helpfulCount ?? review.helpfulCount;
2253                    review.notHelpfulCount = data.notHelpfulCount ?? review.notHelpfulCount;
2254                }
2255
2256            } catch (error) {
2257                console.error('Vote error:', error);
2258                window.dispatchEvent(new CustomEvent('show-toast', {
2259                    detail: { type: 'error', message: 'Failed to submit vote' }
2260                }));
2261            } finally {
2262                this.pendingVoteId = null;
2263            }
2264        },
2265
2266        async report(reviewId) {
2267            if (this.pendingReportId) return;
2268
2269            this.pendingReportId = reviewId;
2270
2271            try {
2272                // Get token from global config if available
2273                const token = window.productDetailConfig?.antiforgeryToken;
2274                const header = window.productDetailConfig?.antiforgeryHeaderName;
2275                const headers = { 'Content-Type': 'application/json' };
2276                if (token && header) headers[header] = token;
2277
2278                const response = await fetch(`/api/reviews/${reviewId}/report`, {
2279                    method: 'POST',
2280                    headers: headers,
2281                    body: JSON.stringify({ reason: 'Inappropriate content' })
2282                });
2283
2284                if (!response.ok) {
2285                    const data = await response.json();
2286                    window.dispatchEvent(new CustomEvent('show-toast', {
2287                        detail: { type: 'error', message: data.message || 'Failed to report review' }
2288                    }));
2289                    return;
2290                }
2291
2292                this.reportedReviewIds.add(reviewId);
2293
2294                window.dispatchEvent(new CustomEvent('show-toast', {
2295                    detail: { type: 'success', message: 'Review reported. Thank y
2295ou for your feedback.' }
2296                }));
2297
2298            } catch (error) {
2299                console.error('Report error:', error);
2300                window.dispatchEvent(new CustomEvent('show-toast', {
2301                    detail: { type: 'error', message: 'Failed to report review' }
2302                }));
2303            } finally {
2304                this.pendingReportId = null;
2305            }
2306        }
2307    };
2308}
2309
2310
2311/**
2312 * Registry Modal Component
2313 * Handles adding products to gift registries
2314 */
2315function registryModal(productId, productName) {
2316    return {
2317        productId: productId,
2318        productName: productName,
2319        registries: [],
2320        loading: true,
2321        submitting: false,
2322        error: '',
2323        selectedRegistryId: '',
2324        desiredQuantity: 1,
2325        notes: '',
2326
2327        init() {
2328            this.loadRegistries();
2329        },
2330
2331        async loadRegistries() {
2332            this.loading = true;
2333            this.error = '';
2334
2335            try {
2336                const response = await fetch('/api/gift-registries');
2337
2338                if (!response.ok) {
2339                    throw new Error('Failed to load registries');
2340                }
2341
2342                const data = await response.json();
2343                this.registries = data.registries || [];
2344
2345            } catch (error) {
2346                console.error('Load registries error:', error);
2347                this.error = 'Failed to load registries. Please try again.';
2348            } finally {
2349                this.loading = false;
2350            }
2351        },
2352
2353        async addToRegistry() {
2354            if (!this.selectedRegistryId || this.submitting) return;
2355
2356            this.submitting = true;
2357            this.error = '';
2358
2359            try {
2360                // Get token from global config if available
2361                const token = window.productDetailConfig?.antiforgeryToken;
2362                const header = window.productDetailConfig?.antiforgeryHeaderName;
2363                const headers = { 'Content-Type': 'application/json' };
2364                if (token && header) headers[header] = token;
2365
2366                const response = await fetch(`/api/gift-registries/${this.selectedRegistryId}/items`, {
2367                    method: 'POST',
2368                    headers: headers,
2369                    body: JSON.stringify({
2370                        productId: this.productId,
2371                        desiredQuantity: this.desiredQuantity,
2372                        notes: this.notes || null
2373                    })
2374                });
2375
2376                if (!response.ok) {
2377                    const data = await response.json();
2378                    this.error = data.message || 'Failed to add to registry';
2379                    return;
2380                }
2381
2382                // Close modal and show success
2383                this.$dispatch('close-registry-modal');
2384                window.dispatchEvent(new CustomEvent('show-toast', {
2385                    detail: {
2386                        type: 'success',
2387                        message: `Added "${this.productName}" to registry`,
2388                        action: {
2389                            label: 'View Registry',
2390                            href: `/account/registries/${this.selectedRegistryId}`
2391                        }
2392                    }
2393                }));
2394
2395            } catch (error) {
2396                console.error('Add to registry error:', error);
2397                this.error = 'Failed to add to registry. Please try again.';
2398            } finally {
2399                this.submitting = false;
2400            }
2401        }
2402    };
2403}
2404
2405
2406/**
2407 * Toast Manager Component
2408 * Handles displaying toast notifications
2409 */
2410function toastManager() {
2411    return {
2412        toasts: [],
2413        nextId: 1,
2414
2415        showToast(detail) {
2416            const toast = {
2417                id: this.nextId++,
2418                type: detail.type || 'info',
2419                message: detail.message,
2420                action: detail.action || null,
2421                visible: true
2422            };
2423
2424            this.toasts.push(toast);
2425
2426            // Auto-remove after 5 seconds
2427            setTimeout(() => {
2428                this.removeToast(toast.id);
2429            }, 5000);
2430        },
2431
2432        removeToast(id) {
2433            const index = this.toasts.findIndex(t => t.id === id);
2434            if (index !== -1) {
2435                this.toasts[index].visible = false;
2436                // Remove from array after animation
2437                setTimeout(() => {
2438                    this.toasts = this.toasts.filter(t => t.id !== id);
2439                }, 200);
2440            }
2441        }
2442    };
2443}
2444
2445/**
2446 * Related Products Section Component
2447 * Loads intelligent recommendations with fallback to related products.
2448 */
2449function relatedProductsSection(config) {
2450    return {
2451        productId: config.productId,
2452        businessId: config.businessId,
2453        productSlug: config.productSlug,
2454        items: [],
2455        title: 'You might also like',
2456        loading: true,
2457        isVisible: true,
2458
2459        init() {
2460            this.loadRecommendations();
2461        },
2462
2463        async loadRecommendations() {
2464            this.loading = true;
2465            this.isVisible = true;
2466
2467            try {
2468                const response = await fetch(`/api/recommendations/product/${this.productId}`, {
2469                    credentials: 'include'
2470                });
2471
2472                if (response.ok) {
2473                    const data = await response.json();
2474                    const recommendations = data.recommendations || [];
2475                    if (recommendations.length > 0) {
2476                        this.title = 'You might also like';
2477                        this.items = recommendations.map((item) => this.normalizeRecommendation(item));
2478                        this.loading = false;
2479                        return;
2480                    }
2481                }
2482            } catch (error) {
2483                console.debug('Recommendation fetch failed, falling back to related products:', error);
2484            }
2485
2486            await this.loadRelatedProducts();
2487        },
2488
2489        async loadRelatedProducts() {
2490            try {
2491                const response = await fetch(`/api/products/${this.productId}/related`);
2492                if (!response.ok) {
2493                    throw new Error('Failed to load related products');
2494                }
2495
2496                const data = await response.json();
2497                const products = data.products || [];
2498                this.title = 'More from this seller';
2499                this.items = products.map((item) => this.normalizeRelatedProduct(item));
2500                this.isVisible = this.items.length > 0;
2501            } catch (error) {
2502                console.error('Related products fetch failed:', error);
2503                this.isVisible = false;
2504            } finally {
2505                this.loading = false;
2506            }
2507        },
2508
2509        normalizeRecommendation(item) {
2510            return {
2511                id: item.id,
2512                name: item.name,
2513                slug: item.slug,
2514                businessId: item.businessId,
2515                businessName: item.businessName,
2516                imageUrl: item.image?.imageUrl || null,
2517                imageCaption: item.image?.caption || null,
2518                price: item.price,
2519                msrpPrice: item.msrpPrice,
2520                msrpOnly: item.msrpOnly,
2521                isCurrentlyOnSale: item.isCurrentlyOnSale,
2522                salePrice: item.salePrice,
2523                discountPercentage: item.discountPercentage,
2524                isHandmade: item.isHandmade,
2525                domesticShippingCost: item.domesticShippingCost
2526            };
2527        },
2528
2529        normalizeRelatedProduct(item) {
2530            return {
2531                id: item.id,
2532                name: item.name,
2533                slug: item.slug,
2534                businessId: item.businessId,
2535                businessName: item.businessName,
2536                imageUrl: item.image?.imageUrl || null,
2537                imageCaption: item.image?.caption || null,
2538                price: item.price,
2539                msrpPrice: item.msrpPrice,
2540                msrpOnly: item.msrpOnly,
2541                isCurrentlyOnSale: item.isCurrentlyOnSale,
2542                salePrice: item.salePrice,
2543                discountPercentage: item.discountPercentage,
2544                isHandmade: item.isHandmade,
2545                domesticShippingCost: item.domesticShippingCost
2546            };
2547        },
2548
2549        formatPrice(amount) {
2550            return new Intl.NumberFormat('en-US', {
2551                style: 'currency',
2552                currency: 'USD'
2553            }).format(amount);
2554        }
2555    };
2556}
2557
2558// ========================================================================
2559// Order messenger (formerly messenger.js)
2560// ========================================================================
2561
2562function messenger() {
2563    return {
2564        messages: [],
2565        newMessage: '',
2566        selectedImages: [], // { file, preview, caption }
2567        isSending: false,
2568        error: null,
2569        currentUserId: window.pageConfig?.currentUserId ?? null,
2570        
2571        // Polling state
2572        _pollingState: 'disconnected', // 'connected', 'connecting', 'disconnected'
2573        _pollTimeoutId: null,
2574        _lastActivityTime: Date.now(),
2575        _consecutiveErrors: 0,
2576        _maxConsecutiveErrors: 5,
2577        _isPolling: false,
2578        
2579        // Polling intervals (in ms)
2580        _activeInterval: 2000,    // 2 seconds when active
2581        _idleInterval: 8000,      // 8 seconds when idle
2582        _idleThreshold: 30000,    // Consider idle after 30 seconds of inactivity
2583        _errorBackoffBase: 2000,  // Base for exponential backoff on errors
2584        _maxErrorBackoff: 30000,  // Max backoff time
2585        
2586        get connectionState() {
2587            return this._pollingState;
2588        },
2589        
2590        get connectionStatusText() {
2591            switch (this._pollingState) {
2592                case 'connected': return 'Connected';
2593                case 'connecting': return 'Connecting...';
2594                case 'disconnected': return 'Disconnected';
2595                default: return 'Unknown';
2596            }
2597        },
2598
2599        get canSend() {
2600            return (this.newMessage.trim().length > 0 || this.selectedImages.length > 0) && this._pollingState === 'connected';
2601        },
2602
2603        get lastMessageId() {
2604            if (this.messages.length === 0) return null;
2605            return Math.max(...this.messages.map(m => m.id));
2606        },
2607
2608        get isUserActive() {
2609            return (Date.now() - this._lastActivityTime) < this._idleThreshold;
2610        },
2611
2612        get currentPollInterval() {
2613            if (this._consecutiveErrors > 0) {
2614                // Exponential backoff on errors
2615                const backoff = Math.min(
2616                    this._errorBackoffBase * Math.pow(2, this._consecutiveErrors - 1),
2617                    this._maxErrorBackoff
2618                );
2619                return backoff;
2620            }
2621            return this.isUserActive ? this._activeInterval : this._idleInterval;
2622        },
2623
2624        init() {
2625            this.messages = window.initialMessages || [];
2626            
2627            // Start polling
2628            this.startPolling();
2629            
2630            this.$nextTick(() => this.scrollToBottom());
2631            
2632            // Watch messages to scroll
2633            this.$watch('messages', () => {
2634                this.$nextTick(() => this.scrollToBottom());
2635            });
2636
2637            // Track user activity
2638            this._boundTrackActivity = () => this.trackActivity();
2639            document.addEventListener('mousemove', this._boundTrackActivity);
2640            document.addEventListener('keydown', this._boundTrackActivity);
2641            document.addEventListener('click', this._boundTrackActivity);
2642            document.addEventListener('touchstart', this._boundTrackActivity);
2643
2644            // Handle visibility changes
2645            this._boundHandleVisibility = () => this.handleVisibilityChange();
2646            document.addEventListener('visibilitychange', this._boundHandleVisibility);
2647
2648            // Cleanup on page unload
2649            this._boundCleanup = () => this.cleanup();
2650            window.addEventListener('beforeunload', this._boundCleanup);
2651        },
2652
2653        destroy() {
2654            this.cleanup();
2655        },
2656
2657        cleanup() {
2658            this.stopPolling();
2659            
2660            if (this._boundTrackActivity) {
2661                document.removeEventListener('mousemove', this._boundTrackActivity);
2662                document.removeEventListener('keydown', this._boundTrackActivity);
2663                document.removeEventListener('click', this._boundTrackActivity);
2664                document.removeEventListener('touchstart', this._boundTrackActivity);
2665                this._boundTrackActivity = null;
2666            }
2667            
2668            if (this._boundHandleVisibility) {
2669                document.removeEventListener('visibilitychange', this._boundHandleVisibility);
2670                this._boundHandleVisibility = null;
2671            }
2672            
2673            if (this._boundCleanup) {
2674                window.removeEventListener('beforeunload', this._boundCleanup);
2675                this._boundCleanup = null;
2676            }
2677        },
2678
2679        trackActivity() {
2680            this._lastActivityTime = Date.now();
2681        },
2682
2683        handleVisibilityChange() {
2684            if (document.visibilityState === 'visible') {
2685                // Resume polling when tab becomes visible
2686                this.trackActivity();
2687                if (this._pollingState === 'disconnected' || !this._isPolling) {
2688                    this.startPolling();
2689                }
2690            } else {
2691                // Pause polling when tab is hidden
2692                this.stopPolling();
2693            }
2694        },
2695
2696        startPolling() {
2697            if (this._isPolling) return;
2698            
2699            this._isPolling = true;
2700            this._pollingState = 'connecting';
2701            this._consecutiveErrors = 0;
2702            this.error = null;
2703            
2704            this.poll();
2705        },
2706
2707        stopPolling() {
2708            this._isPolling = false;
2709            if (this._pollTimeoutId) {
2710                clearTimeout(this._pollTimeoutId);
2711                this._pollTimeoutId = null;
2712            }
2713        },
2714
2715        async poll() {
2716            if (!this._isPolling || document.visibilityState === 'hidden') {
2717                this._pollingState = 'disconnected';
2718                return;
2719            }
2720
2721            try {
2722                const sinceId = this.lastMessageId;
2723                const url = sinceId 
2724                    ? `${window.pageConfig.pollUrl}?sinceId=${sinceId}`
2725                    : window.pageConfig.pollUrl;
2726
2727                const response = await fetch(url, {
2728                    method: 'GET',
2729                    headers: {
2730                        'Accept': 'application/json'
2731                    },
2732                    credentials: 'same-origin'
2733                });
2734
2735                if (!response.ok) {
2736                    // Handle auth errors separately - redirect to login
2737                    if (response.status === 401 || response.status === 403) {
2738                        this._isPolling = false;
2739                        this._pollingState = 'disconnected';
2740                        this.error = 'Session expired. Please refresh the page.';
2741                        return;
2742                    }
2743                    throw new Error(`HTTP ${response.status}`);
2744                }
2745
2746                const data = await response.json();
2747                
2748                // Success - reset error state
2749                this._consecutiveErrors = 0;
2750                this._pollingState = 'connected';
2751                this.error = null;
2752
2753                // Add new messages (avoiding duplicates)
2754                if (data.messages && data.messages.length > 0) {
2755                    for (const message of data.messages) {
2756                        const exists = this.messages.some(m => m.id === message.id);
2757                        if (!exists) {
2758                            this.messages.push(message);
2759                        }
2760                    }
2761                }
2762
2763            } catch (err) {
2764                console.error('Polling error:', err);
2765                this._consecutiveErrors++;
2766                
2767                if (this._consecutiveErrors >= this._maxConsecutiveErrors) {
2768                    this._pollingState = 'disconnected';
2769                    this.error = 'Connection lost. Click reconnect to try again.';
2770                    this._isPolling = false;
2771                    return;
2772                } else {
2773                    this._pollingState = 'connecting';
2774                }
2775            }
2776
2777            // Schedule next poll
2778            if (this._isPolling) {
2779                this._pollTimeoutId = setTimeout(() => this.poll(), this.currentPollInterval);
2780            }
2781        },
2782
2783        async manualReconnect() {
2784            if (this._pollingState === 'connected' && this._isPolling) return;
2785            
2786            this._consecutiveErrors = 0;
2787            this.error = null;
2788            this.trackActivity();
2789            this.startPolling();
2790        },
2791
2792        handleFileSelect(event) {
2793            const files = Array.from(event.target.files);
2794            if (files.length + this.selectedImages.length > 5) {
2795                this.error = 'You can only upload up to 5 images.';
2796                return;
2797            }
2798            
2799            this.error = null;
2800            this.trackActivity();
2801            
2802            files.forEach(file => {
2803                if (file.size > 10 * 1024 * 1024) {
2804                    this.error = `File ${file.name} is too large (max 10MB).`;
2805                    return;
2806                }
2807                
2808                const reader = new FileReader();
2809                reader.onload = (e) => {
2810                    this.selectedImages.push({
2811                        file: file,
2812                        preview: e.target.result,
2813                        caption: ''
2814                    });
2815                };
2816                reader.readAsDataURL(file);
2817            });
2818            
2819            event.target.value = ''; // Reset input
2820        },
2821
2822        removeImage(index) {
2823            this.selectedImages.splice(index, 1);
2824        },
2825
2826        async sendMessage() {
2827            if (!this.canSend) return;
2828
2829            this.isSending = true;
2830            this.error = null;
2831            this.trackActivity();
2832
2833            try {
2834                const formData = new FormData();
2835                
2836                formData.append('ConversationId', window.pageConfig.conversationId);
2837
2838                if (this.newMessage.trim()) {
2839                    formData.append('Content', this.newMessage.trim());
2840                }
2841
2842                for (const img of this.selectedImages) {
2843                    if (img.file) {
2844                        formData.append('Images', img.file, img.file.name);
2845                    }
2846                }
2847
2848                for (const img of this.selectedImages) {
2849                    formData.append('ImageCaptions', img.caption || '');
2850                }
2851
2852                const headers = {};
2853                if (window.pageConfig?.antiforgeryToken && window.pageConfig?.antiforgeryHeaderName) {
2854                    headers[window.pageConfig.antiforgeryHeaderName] = window.pageConfig.antiforgeryToken;
2855                }
2856
2857                const response = await fetch(`/api/conversations/${window.pageConfig.conversationId}/messages`, {
2858                    method: 'POST',
2859                    headers: headers,
2860                    body: formData
2861                });
2862
2863                if (!response.ok) {
2864                    const errorText = await response.text();
2865                    console.error('Server error:', response.status, errorText);
2866                    throw new Error('Failed to send message');
2867                }
2868
2869                this.newMessage = '';
2870                this.selectedImages = [];
2871                
2872                // Immediately poll for new messages after sending
2873                // This ensures the sent message appears quickly
2874                setTimeout(() => this.poll(), 100);
2875                
2876            } catch (err) {
2877                console.error('Error sending message:', err);
2878                this.error = 'Failed to send message. Please try again.';
2879            } finally {
2880                this.isSending = false;
2881            }
2882        },
2883
2884        scrollToBottom() {
2885            const list = this.$refs.messagesList;
2886            if (list) {
2887                list.scrollTop = list.scrollHeight;
2888            }
2889        },
2890
2891        formatTime(dateString) {
2892            const date = new Date(dateString);
2893            const now = new Date();
2894            const seconds = Math.floor((now - date) / 1000);
2895            
2896            if (seconds < 5) return 'Just now';
2897            if (seconds < 60) return seconds + 's ago';
2898            
2899            let interval = seconds / 31536000;
2900            if (interval > 1) return Math.floor(interval) + "y ago";
2901            interval = seconds / 2592000;
2902            if (interval > 1) return Math.floor(interval) + "mo ago";
2903            interval = seconds / 86400;
2904            if (interval > 1) return Math.floor(interval) + "d ago";
2905            interval = seconds / 3600;
2906            if (interval > 1) return Math.floor(interval) + "h ago";
2907            interval = seconds / 60;
2908            if (interval > 1) return Math.floor(interval) + "m ago";
2909            return Math.floor(seconds) + "s ago";
2910        }
2911    };
2912}
2913
2914// ========================================================================
2915// Admin dropshipping search (formerly admin-dropshipping.js)
2916// ========================================================================
2917
2918const DROPSHIPPING_PLACEHOLDER_IMAGE = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='300' viewBox='0 0 300 300'%3E%3Crect fill='%23f0ebe3' width='300' height='300'/%3E%3Ctext x='50%25' y='50%25' dominant-baseline='middle' text-anchor='middle' fill='%23999' font-family='sans-serif' font-size='14'%3ENo Image%3C/text%3E%3C/svg%3E";
2919
2920function adminDropshippingPage() {
2921    return {
2922        searchTerm: window.pageConfig?.searchTerm ?? '',
2923        sort: window.pageConfig?.sort ?? 'default',
2924        page: 1,
2925        products: [],
2926        loading: false,
2927        hasMore: true,
2928        error: null,
2929        observer: null,
2930        defaultSearchTerm: window.pageConfig?.defaultSearchTerm ?? '',
2931
2932        init() {
2933            if (this.searchTerm) {
2934                this.search();
2935            } else if (this.defaultSearchTerm) {
2936                this.loadDefaultResults();
2937            }
2938            this.setupInfiniteScroll();
2939        },
2940
2941        handleSearchInput(value) {
2942            this.searchTerm = value;
2943            this.page = 1;
2944            this.products = [];
2945            this.hasMore = true;
2946            this.error = null;
2947
2948            if (value.trim().length > 0) {
2949                this.search();
2950            } else if (this.defaultSearchTerm) {
2951                this.loadDefaultResults();
2952            }
2953            this.updateUrl();
2954        },
2955
2956        handleSortChange(newSort) {
2957            this.sort = newSort;
2958            this.page = 1;
2959            this.products = [];
2960            this.hasMore = true;
2961            this.error = null;
2962
2963            if (this.searchTerm.trim().length > 0) {
2964                this.search();
2965            } else if (this.defaultSearchTerm) {
2966                this.loadDefaultResults();
2967            }
2968            this.updateUrl();
2969        },
2970
2971        browseCategory(searchTerm) {
2972            this.searchTerm = searchTerm;
2973            this.page = 1;
2974            this.products = [];
2975            this.hasMore = true;
2976            this.error = null;
2977            this.search();
2978            this.updateUrl();
2979
2980            const input = document.querySelector('.dropshipping-search-input');
2981            if (input) {
2982                input.value = searchTerm;
2983            }
2984        },
2985
2986        async loadDefaultResults() {
2987            if (this.loading) return;
2988
2989            this.loading = true;
2990            this.error = null;
2991
2992            try {
2993                const response = await fetch('/api/admin/dropshipping/products/search', {
2994                    method: 'POST',
2995                    headers: this.getApiHeaders(),
2996                    body: JSON.stringify({
2997                        searchTerm: this.defaultSearchTerm,
2998                        sort: this.sort,
2999                        page: this.page
3000                    })
3001                });
3002
3003                if (!response.ok) {
3004                    const errorData = await response.json().catch(() => ({}));
3005                    throw new Error(errorData.message || 'Failed to load products');
3006                }
3007
3008                const data = await response.json();
3009                const newProducts = data.products ?? [];
3010
3011                if (this.page === 1) {
3012                    this.products = newProducts;
3013                } else {
3014                    // Filter out duplicates based on itemId
3015                    const existingIds = new Set(this.products.map(p => p.itemId));
3016                    const filteredNewProducts = newProducts.filter(p => !existingIds.has(p.itemId));
3017                    this.products = [...this.products, ...filteredNewProducts];
3018                }
3019
3020                this.hasMore = newProducts.length >= 20;
3021            } catch (err) {
3022                console.error('Load default results error:', err);
3023                this.error = err.message || 'Unable to load products. Please try again.';
3024            } finally {
3025                this.loading = false;
3026            }
3027        },
3028
3029        async search() {
3030            if (this.loading || !this.searchTerm.trim()) return;
3031
3032            this.loading = true;
3033            this.error = null;
3034
3035            try {
3036                const response = await fetch('/api/admin/dropshipping/products/search', {
3037                    method: 'POST',
3038                    headers: this.getApiHeaders(),
3039                    body: JSON.stringify({
3040                        searchTerm: this.searchTerm,
3041                        sort: this.sort,
3042                        page: this.page
3043                    })
3044                });
3045
3046                if (!response.ok) {
3047                    const errorData = await response.json().catch(() => ({}));
3048                    throw new Error(errorData.message || 'Search failed');
3049                }
3050
3051                const data = await response.json();
3052                const newProducts = data.products ?? [];
3053
3054                if (this.page === 1) {
3055                    this.products = newProducts;
3056                } else {
3057                    // Filter out duplicates based on itemId
3058                    const existingIds = new Set(this.products.map(p => p.itemId));
3059                    const filteredNewProducts = newProducts.filter(p => !existingIds.has(p.itemId));
3060                    this.products = [...this.products, ...filteredNewProducts];
3061                }
3062
3063                this.hasMore = newProducts.length >= 20;
3064            } catch (err) {
3065                console.error('Search error:', err);
3066                this.error = err.message || 'Unable to search products. Please try again.';
3067            } finally {
3068                this.loading = false;
3069            }
3070        },
3071
3072        loadMore() {
3073            if (this.loading || !this.hasMore) return;
3074
3075            this.page++;
3076            if (this.searchTerm) {
3077                this.search();
3078            } else {
3079                this.loadDefaultResults();
3080            }
3081        },
3082
3083        setupInfiniteScroll() {
3084            this.$nextTick(() => {
3085                const sentinel = document.querySelector('[data-infinite-scroll-sentinel]');
3086                if (!sentinel) return;
3087
3088                this.observer = new IntersectionObserver(
3089                    (entries) => {
3090                        if (entries[0].isIntersecting && !this.loading && this.hasMore && this.products.length > 0) {
3091                            this.loadMore();
3092                        }
3093                    },
3094                    { rootMargin: '200px' }
3095                );
3096
3097                this.observer.observe(sentinel);
3098            });
3099        },
3100
3101        updateUrl() {
3102            const url = new URL(window.location.href);
3103
3104            if (this.searchTerm) {
3105                url.searchParams.set('q', this.searchTerm);
3106            } else {
3107                url.searchParams.delete('q');
3108            }
3109
3110            if (this.sort !== 'default') {
3111                url.searchParams.set('sort', this.sort);
3112            } else {
3113                url.searchParams.delete('sort');
3114            }
3115
3116            window.history.replaceState({}, '', url.toString());
3117        },
3118
3119        getApiHeaders() {
3120            const headers = { 'Content-Type': 'application/json' };
3121            const token = window.pageConfig?.antiforgeryToken;
3122            const headerName = window.pageConfig?.antiforgeryHeaderName;
3123            if (token && headerName) {
3124                headers[headerName] = token;
3125            }
3126            return headers;
3127        },
3128
3129        getProductImage(product) {
3130            const image = product.image;
3131            if (!image) return DROPSHIPPING_PLACEHOLDER_IMAGE;
3132            return image.startsWith('//') ? `https:${image}` : image;
3133        },
3134
3135        getPromotionPrice(product) {
3136            return product.sku?.def?.promotionPrice ?? product.sku?.def?.price ?? 0;
3137        },
3138
3139        getOriginalPrice(product) {
3140            return product.sku?.def?.price ?? 0;
3141        },
3142
3143        hasDiscount(product) {
3144            const promo = product.sku?.def?.promotionPrice;
3145            const original = product.sku?.def?.price;
3146            return promo && original && promo < original;
3147        },
3148
3149        formatPrice(price) {
3150            if (typeof price === 'number') {
3151                return `$${price.toFixed(2)}`;
3152            }
3153            if (typeof price === 'string') {
3154                const parsed = parseFloat(price);
3155                if (!isNaN(parsed)) {
3156                    return `$${parsed.toFixed(2)}`;
3157                }
3158                return price;
3159            }
3160            return '$0.00';
3161        },
3162
3163        formatSales(sales) {
3164            if (!sales) return '';
3165            if (sales >= 1000) {
3166                return `${(sales / 1000).toFixed(1)}k sold`;
3167            }
3168            return `${sales} sold`;
3169        }
3170    };
3171}
3172
3173// ========================================================================
3174// Admin dropshipping product (formerly admin-dropshipping-product.js)
3175// ========================================================================
3176
3177const DROPSHIPPING_PRODUCT_PLACEHOLDER_IMAGE = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='400' viewBox='0 0 400 400'%3E%3Crect fill='%23f0ebe3' width='400' height='400'/%3E%3Ctext x='50%25' y='50%25' dominant-baseline='middle' text-anchor='middle' fill='%23999' font-family='sans-serif' font-size='16'%3ENo Image%3C/text%3E%3C/svg%3E";
3178
3179function adminDropshippingProductPage() {
3180    return {
3181        product: window.pageConfig?.product ?? null,
3182        productId: window.pageConfig?.productId ?? '',
3183        priceModifier: window.pageConfig?.defaultPriceModifier ?? 10.00,
3184        selectedImageIndex: 0,
3185        selectedVariants: {},
3186        transforming: false,
3187        error: null,
3188
3189        get allImages() {
3190            if (!this.product?.result?.item) return [DROPSHIPPING_PRODUCT_PLACEHOLDER_IMAGE];
3191
3192            const item = this.product.result.item;
3193            const images = [];
3194
3195            item.sku?.props?.forEach(prop => {
3196                prop.values?.forEach(value => {
3197                    if (value.image) {
3198                        const url = this.normalizeImageUrl(value.image);
3199                        if (url && !images.includes(url)) {
3200                            images.push(url);
3201                        }
3202                    }
3203                });
3204            });
3205
3206            item.images?.forEach(img => {
3207                const url = this.normalizeImageUrl(img);
3208                if (url && !images.includes(url)) {
3209                    images.push(url);
3210                }
3211            });
3212
3213            return images.length > 0 ? images : [DROPSHIPPING_PRODUCT_PLACEHOLDER_IMAGE];
3214        },
3215
3216        get currentImage() {
3217            return this.allImages[this.selectedImageIndex] || this.allImages[0];
3218        },
3219
3220        get basePrice() {
3221            const def = this.product?.result?.item?.sku?.def;
3222            if (!def) return 0;
3223
3224            const promoPrice = this.parsePrice(def.promotionPrice);
3225            const originalPrice = this.parsePrice(def.price);
3226
3227            return promoPrice > 0 ? promoPrice : originalPrice;
3228        },
3229
3230        get yourPrice() {
3231            return this.basePrice + parseFloat(this.priceModifier || 0);
3232        },
3233
3234        get reviews() {
3235            return this.product?.result?.reviews;
3236        },
3237
3238        get seller() {
3239            return this.product?.result?.seller;
3240        },
3241
3242        get shippingList() {
3243            return this.product?.result?.delivery?.shippingList || [];
3244        },
3245
3246        normalizeImageUrl(url) {
3247            if (!url) return null;
3248            return url.startsWith('//') ? `https:${url}` : url;
3249        },
3250
3251        parsePrice(price) {
3252            if (!price) return 0;
3253            if (typeof price === 'number') return price;
3254            const parsed = parseFloat(price.toString().split(' ')[0].replace(/[^0-9.]/g, ''));
3255            return isNaN(parsed) ? 0 : parsed;
3256        },
3257
3258        formatCurrency(amount) {
3259            const num = parseFloat(amount);
3260            if (isNaN(num)) return '$0.00';
3261            return `$${num.toFixed(2)}`;
3262        },
3263
3264        formatSalesCount(sales) {
3265            if (!sales) return '';
3266            if (typeof sales === 'string') {
3267                return sales;
3268            }
3269            if (sales >= 1000) {
3270                return `${(sales / 1000).toFixed(1)}k sold`;
3271            }
3272            return `${sales} sold`;
3273        },
3274
3275        selectVariant(propId, value) {
3276            this.selectedVariants[propId] = value;
3277
3278            if (value.image) {
3279                const imageUrl = this.normalizeImageUrl(value.image);
3280                const idx = this.allImages.indexOf(imageUrl);
3281                if (idx >= 0) {
3282                    this.selectedImageIndex = idx;
3283                }
3284            }
3285        },
3286
3287        isVariantSelected(propId, vid) {
3288            return this.selectedVariants[propId]?.vid === vid;
3289        },
3290
3291        prevImage() {
3292            if (this.selectedImageIndex > 0) {
3293                this.selectedImageIndex--;
3294            }
3295        },
3296
3297        nextImage() {
3298            if (this.selectedImageIndex < this.allImages.length - 1) {
3299                this.selectedImageIndex++;
3300            }
3301        },
3302
3303        async addToStore() {
3304            if (this.transforming) return;
3305
3306            this.transforming = true;
3307            this.error = null;
3308
3309            try {
3310                const imageResponse = await fetch(
3311                    `/api/admin/dropshipping/products/${this.productId}/transform-images`,
3312                    {
3313                        method: 'GET',
3314                        headers: this.getApiHeaders()
3315                    }
3316                );
3317
3318                if (!imageResponse.ok) {
3319                    throw new Error('Failed to process images');
3320                }
3321
3322                const url = new URL('/admin/products/form', window.location.origin);
3323                url.searchParams.set('aliExpressProductId', this.productId);
3324                url.searchParams.set('priceModifier', this.priceModifier.toString());
3325
3326                navigateTo(url.toString());
3327            } catch (err) {
3328                console.error('Add to store error:', err);
3329                this.error = err.message || 'Failed to prepare product. Please try again.';
3330                this.transforming = false;
3331            }
3332        },
3333
3334        getApiHeaders() {
3335            const headers = { 'Content-Type': 'application/json' };
3336            const token = window.pageConfig?.antiforgeryToken;
3337            const headerName = window.pageConfig?.antiforgeryHeaderName;
3338            if (token && headerName) {
3339                headers[headerName] = token;
3340            }
3341            return headers;
3342        }
3343    };
3344}
3345
3346// ========================================================================
3347// Blogs (formerly blogs.js)
3348// ========================================================================
3349
3350function blogsIndexPage() {
3351    return {
3352        updateSearch(value) {
3353            const url = new URL(window.location.href);
3354            if (value && value.trim().length > 0) {
3355                url.searchParams.set('search', value.trim());
3356            } else {
3357                url.searchParams.delete('search');
3358            }
3359            url.searchParams.delete('page');
3360            navigateTo(url.toString());
3361        }
3362    };
3363}
3364
3365function blogDetailPage(id) {
3366    return {
3367        async share() {
3368            const title = document.title;
3369            const url = window.location.href;
3370
3371            if (navigator.share) {
3372                try {
3373                    await navigator.share({ title, url });
3374                    return;
3375                } catch (error) {
3376                    console.warn('Share cancelled', error);
3377                }
3378            }
3379
3380            try {
3381                await navigator.clipboard.writeText(url);
3382                alert('Link copied to clipboard.');
3383            } catch (error) {
3384                console.warn('Clipboard failed', error);
3385                prompt('Copy this link:', url);
3386            }
3387        },
3388        scrollTop() {
3389            window.scrollTo({ top: 0, behavior: 'smooth' });
3390        }
3391    };
3392}
3393
3394// ========================================================================
3395// Contact (formerly contact.js)
3396// ========================================================================
3397
3398const CONTACT_MAX_FILES = 5;
3399const CONTACT_MAX_FILE_SIZE = 25 * 1024 * 1024;
3400const CONTACT_GENERAL_ERRORS_KEY = 'generalErrors';
3401
3402function contactNewIdempotencyKey() {
3403    if (window.crypto?.randomUUID) return window.crypto.randomUUID();
3404    return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
3405}
3406
3407function contactFormatBytes(bytes) {
3408    if (bytes < 1024) return `${bytes} B`;
3409    const kb = bytes / 1024;
3410    if (kb < 1024) return `${kb.toFixed(1)} KB`;
3411    const mb = kb / 1024;
3412    return `${mb.toFixed(1)} MB`;
3413}
3414
3415function contactEnsureTurnstile(callback) {
3416    const container = document.getElementById('turnstile-container');
3417    if (window.turnstile && container) {
3418        callback();
3419        return true;
3420    }
3421    return false;
3422}
3423
3424// window.contactConfig comes from Components/Pages/Contact/Index.razor's data block; read it when
3425// the component is created, not when the bundle loads.
3426function contactPage() {
3427    const config = window.contactConfig || {};
3428    return {
3429        isSubmitting: false,
3430        isVerified: false,
3431        successMessage: '',
3432        errorMessage: '',
3433        errors: {},
3434        fileSummary: 'No files selected',
3435        turnstileWidgetId: null,
3436
3437        init() {
3438            this.updateFileSummary([]);
3439            this.setupTurnstile();
3440        },
3441
3442        destroy() {
3443            clearInterval(this.turnstileInterval);
3444            removeTurnstileWidget(this.turnstileWidgetId);
3445        },
3446
3447        setupTurnstile() {
3448            if (!config.cloudflareSiteKey) {
3449                this.errors = { ...this.errors, Turnstile: ['Turnstile site key not configured.'] };
3450                return;
3451            }
3452
3453            loadScriptOnce(TURNSTILE_SCRIPT).catch(() => {});
3454
3455            const renderWidget = () => {
3456                const container = document.getElementById('turnstile-container');
3457                if (!window.turnstile || !container) return false;
3458
3459                if (this.turnstileWidgetId !== null) {
3460                    window.turnstile.remove(this.turnstileWidgetId);
3461                }
3462
3463                this.turnstileWidgetId = window.turnstile.render(container, {
3464                    sitekey: config.cloudflareSiteKey,
3465                    callback: () => {
3466                        this.isVerified = true;
3467                        this.clearFieldError('Turnstile');
3468                    },
3469                    'error-callback': () => {
3470                        this.isVerified = false;
3471                        this.errors = { ...this.errors, Turnstile: ['Verification failed. Please try again.'] };
3472                    },
3473                    'expired-callback': () => {
3474                        this.isVerified = false;
3475                        this.errors = { ...this.errors, Turnstile: ['Verification expired. Please retry.'] };
3476                    },
3477                });
3478
3479                return true;
3480            };
3481
3482            if (contactEnsureTurnstile(renderWidget)) return;
3483
3484            const interval = setInterval(() => {
3485                if (contactEnsureTurnstile(renderWidget)) clearInterval(interval);
3486            }, 250);
3487            this.turnstileInterval = interval;
3488
3489            setTimeout(() => clearInterval(interval), 7000);
3490        },
3491
3492        fieldError(field) {
3493            if (!this.errors) return '';
3494            const messages = this.errors[field];
3495            if (messages && messages.length > 0) return messages[0];
3496            return '';
3497        },
3498
3499        clearFieldError(field) {
3500            if (!this.errors || !this.errors[field]) return;
3501            const { [field]: _, ...rest } = this.errors;
3502            this.errors = rest;
3503        },
3504
3505        updateFileSummary(files) {
3506            if (!files || files.length === 0) {
3507                this.fileSummary = 'No files selected';
3508                return;
3509            }
3510
3511            const totalSize = Array.from(files).reduce((total, file) => total + file.size, 0);
3512            this.fileSummary = `${files.length} file${files.length === 1 ? '' : 's'} · ${contactFormatBytes(totalSize)}`;
3513        },
3514
3515        handleFileChange(event) {
3516            const files = event.target.files || [];
3517            this.updateFileSummary(files);
3518
3519            if (files.length > CONTACT_MAX_FILES) {
3520                this.errors = { ...this.errors, Attachments: [`You can upload up to ${CONTACT_MAX_FILES} files.`] };
3521                event.target.value = '';
3522                this.updateFileSummary([]);
3523                return;
3524            }
3525
3526            const oversizeFile = Array.from(files).find((file) => file.size > CONTACT_MAX_FILE_SIZE);
3527            if (oversizeFile) {
3528                this.errors = { ...this.errors, Attachments: ['Each file must be 25MB or less.'] };
3529                event.target.value = '';
3530                this.updateFileSummary([]);
3531                return;
3532            }
3533
3534            this.clearFieldError('Attachments');
3535        },
3536
3537        autoResize(event) {
3538            const textarea = event.target;
3539            textarea.style.height = 'auto';
3540            textarea.style.height = `${textarea.scrollHeight}px`;
3541        },
3542
3543        async submitForm(event) {
3544            if (this.isSubmitting) return;
3545            this.errorMessage = '';
3546            this.successMessage = '';
3547            this.errors = {};
3548
3549            if (!this.isVerified) {
3550                this.errors = { Turnstile: ['Please complete the Turnstile verification.'] };
3551                return;
3552            }
3553
3554            this.isSubmitting = true;
3555
3556            const form = event.target;
3557            const formData = new FormData(form);
3558            const files = formData.getAll('Attachments');
3559
3560            if (files.length > CONTACT_MAX_FILES) {
3561                this.errors = { Attachments: [`You can upload up to ${CONTACT_MAX_FILES} files.`] };
3562                this.isSubmitting = false;
3563                return;
3564            }
3565
3566            const oversizeFile = files.find((file) => file.size > CONTACT_MAX_FILE_SIZE);
3567            if (oversizeFile) {
3568                this.errors = { Attachments: ['Each file must be 25MB or less.'] };
3569                this.isSubmitting = false;
3570                return;
3571            }
3572
3573            try {
3574                const headers = {};
3575                if (config.antiforgeryHeaderName && config.antiforgeryToken) {
3576                    headers[config.antiforgeryHeaderName] = config.antiforgeryToken;
3577                }
3578
3579                const response = await fetch('/api/support/contact', {
3580                    method: 'POST',
3581                    headers,
3582                    body: formData,
3583                });
3584
3585                if (response.ok) {
3586                    const result = await response.json();
3587                    this.successMessage = result?.message || 'Your message has been sent successfully.';
3588                    this.errorMessage = '';
3589                    this.isVerified = false;
3590                    form.reset();
3591                    this.updateFileSummary([]);
3592
3593                    const idempotencyInput = form.querySelector('input[name="IdempotencyKey"]');
3594                    if (idempotencyInput) idempotencyInput.value = contactNewIdempotencyKey();
3595
3596                    if (window.turnstile && this.turnstileWidgetId !== null) {
3597                        window.turnstile.reset(this.turnstileWidgetId);
3598                    }
3599                    return;
3600                }
3601
3602                if (response.status === 400) {
3603                    const data = await response.json();
3604                    this.errors = data.errors || {};
3605                    const generalErrors = data.errors?.[CONTACT_GENERAL_ERRORS_KEY] || data.errors?.[''] || [];
3606                    this.errorMessage = generalErrors[0] || data.message || 'Please correct the errors in the form.';
3607
3608                    if (window.turnstile && this.turnstileWidgetId !== null) {
3609                        window.turnstile.reset(this.turnstileWidgetId);
3610                        this.isVerified = false;
3611                    }
3612                    return;
3613                }
3614
3615                this.errorMessage = 'An error occurred while submitting your inquiry.';
3616            } catch (error) {
3617                this.errorMessage = 'Unable to submit your request. Please try again.';
3618            } finally {
3619                this.isSubmitting = false;
3620            }
3621        },
3622    };
3623}
3624
3625// ========================================================================
3626// University (formerly university.js)
3627// ========================================================================
3628
3629function universityIndexPage() {
3630    return {
3631        updateSearch(value) {
3632            const url = new URL(window.location.href);
3633            if (value && value.trim().length > 0) {
3634                url.searchParams.set('search', value.trim());
3635            } else {
3636                url.searchParams.delete('search');
3637            }
3638            url.searchParams.delete('page');
3639            navigateTo(url.toString());
3640        }
3641    };
3642}
3643
3644function universityDetailPage() {
3645    return {
3646        showBackToTop: false,
3647        
3648        init() {
3649            this.handleScroll = () => {
3650                this.showBackToTop = window.scrollY > 400;
3651            };
3652            window.addEventListener('scroll', this.handleScroll, { passive: true });
3653            this.handleScroll();
3654        },
3655        
3656        destroy() {
3657            if (this.handleScroll) {
3658                window.removeEventListener('scroll', this.handleScroll);
3659            }
3660        },
3661        
3662        async share() {
3663            const title = document.title;
3664            const url = window.location.href;
3665
3666            if (navigator.share) {
3667                try {
3668                    await navigator.share({ title, url });
3669                    return;
3670                } catch (error) {
3671                    console.warn('Share cancelled', error);
3672                }
3673            }
3674
3675            try {
3676                await navigator.clipboard.writeText(url);
3677                alert('Link copied to clipboard.');
3678            } catch (error) {
3679                console.warn('Clipboard failed', error);
3680                prompt('Copy this link:', url);
3681            }
3682        },
3683        
3684        scrollTop() {
3685            window.scrollTo({ top: 0, behavior: 'smooth' });
3686        }
3687    };
3688}
3689
3690// ========================================================================
3691// Business detail page (formerly business-detail.js)
3692// ========================================================================
3693
3694/**
3695 * A block of text clamped by CSS (`.is-clamped` on x-ref="text") with a Read more / Show less
3696 * toggle. `overflowing` is measured against the clamped height, so short text gets no toggle.
3697 */
3698function expandableText() {
3699    return {
3700        expanded: false,
3701        overflowing: false,
3702
3703        init() {
3704            this.$nextTick(() => this.measure());
3705            listenWhileMounted(this, window, 'resize', () => this.measure());
3706        },
3707
3708        measure() {
3709            if (this.expanded) return;
3710            const text = this.$refs.text;
3711            this.overflowing = text.scrollHeight > text.clientHeight + 1;
3712        },
3713
3714        toggle() {
3715            this.expanded = !this.expanded;
3716            if (this.expanded) return;
3717            // Collapsing from far down the text would leave the reader below it.
3718            this.$nextTick(() => {
3719                if (this.$root.getBoundingClientRect().top < 0) {
3720                    this.$root.scrollIntoView({ block: 'start' });
3721                }
3722            });
3723        }
3724    };
3725}
3726
3727function businessDetailPage() {
3728    const listing = productListingPage();
3729
3730    return {
3731        ...listing,
3732
3733        // Market-specific state
3734        searchTerm: window.businessDetailConfig?.marketSearchTerm ?? '',
3735        searchTimeout: null,
3736
3737        init() {
3738            this.initProductListing();
3739
3740            const config = window.businessDetailConfig || {};
3741            if (config.marketId) {
3742                markMarketAsViewed(config.marketId, config.antiforgeryHeaderName, config.antiforgeryToken);
3743            }
3744
3745            if (config.businessId) {
3746                loadMarketRecommendations(config.businessId);
3747            }
3748        },
3749
3750        onSearchInput(value) {
3751            this.searchTerm = value;
3752            if (this.searchTimeout) {
3753                clearTimeout(this.searchTimeout);
3754            }
3755            this.searchTimeout = setTimeout(() => {
3756                const url = new URL(window.location.href);
3757                if (value && value.trim().length > 0) {
3758                    url.searchParams.set('marketSearchTerm', value.trim());
3759                } else {
3760                    url.searchParams.delete('marketSearchTerm');
3761                }
3762                url.searchParams.delete('page');
3763                navigateTo(url.toString());
3764            }, 450);
3765        }
3766    };
3767}
3768
3769async function markMarketAsViewed(marketId, headerName, token) {
3770    const storageKey = `finnalby:viewed-market:${marketId}`;
3771    const dedupeWindowMs = 30 * 60 * 1000;
3772    const lastViewedAt = parseInt(sessionStorage.getItem(storageKey), 10);
3773    if (!Number.isNaN(lastViewedAt) && Date.now() - lastViewedAt < dedupeWindowMs) return;
3774
3775    try {
3776        const headers = {};
3777        if (headerName && token) {
3778            headers[headerName] = token;
3779        }
3780
3781        const response = await fetch(`/api/markets/${marketId}/mark-as-viewed`, {
3782            method: 'POST',
3783            headers
3784        });
3785        if (response.ok) sessionStorage.setItem(storageKey, Date.now().toString());
3786    } catch (error) {
3787        console.error('Mark market as viewed error:', error);
3788    }
3789}
3790
3791async function loadMarketRecommendations(businessId) {
3792    const container = document.getElementById('market-recommendations');
3793    const emptyState = document.getElementById('market-recommendations-empty');
3794    if (!container) return;
3795
3796    setRecommendationsMessage(container, 'Loading recommendations...');
3797
3798    try {
3799        const response = await fetch(`/api/recommendations/market/${businessId}`);
3800        if (!response.ok) {
3801            throw new Error('Failed to load recommendations');
3802        }
3803
3804        const data = await response.json();
3805        const recommendations = data.recommendations || [];
3806
3807        if (!recommendations.length) {
3808            container.replaceChildren();
3809            if (emptyState) emptyState.hidden = false;
3810            return;
3811        }
3812
3813        const fragment = document.createDocumentFragment();
3814        recommendations.forEach((item) => {
3815            fragment.appendChild(renderRecommendationCard(item));
3816        });
3817        container.replaceChildren(fragment);
3818    } catch (error) {
3819        console.error('Recommendations error:', error);
3820        setRecommendationsMessage(container, 'Unable to load recommendations.');
3821    }
3822}
3823
3824function renderRecommendationCard(item) {
3825    const imageUrl = getSafeAssetUrl(item.image?.imageUrl) || '/images/placeholder-product.png';
3826    const price = item.isCurrentlyOnSale ? item.salePrice : item.price;
3827    const priceLabel = formatCurrency(price);
3828    const msrpLabel = item.isCurrentlyOnSale && item.msrpPrice ? formatCurrency(item.msrpPrice) : '';
3829    const reason = item.recommendationReason || item.recommendationType;
3830    const reasonClass = reason && reason.toLowerCase().includes('popular') ? 'recommendation-tag popular' : 'recommendation-tag';
3831    const card = document.createElement('a');
3832    card.className = 'recommendation-card';
3833    card.href = buildProductDetailPath(item.businessId, item.slug);
3834
3835    const image = document.createElement('div');
3836    image.className = 'recommendation-image';
3837    image.style.backgroundImage = `url("${imageUrl}")`;
3838
3839    const body = document.createElement('div');
3840    body.className = 'recommendation-body';
3841
3842    const meta = document.createElement('div');
3843    meta.className = 'recommendation-meta';
3844
3845    const businessName = document.createElement('span');
3846    businessName.textContent = toDisplayString(item.businessName);
3847
3848    const reasonTag = document.createElement('span');
3849    reasonTag.className = reasonClass;
3850    reasonTag.textContent = toDisplayString(reason);
3851
3852    const title = document.createElement('h3');
3853    title.textContent = toDisplayString(item.name);
3854
3855    const priceRow = document.createElement('div');
3856    priceRow.className = 'recommendation-price';
3857
3858    const currentPrice = document.createElement('span');
3859    currentPrice.textContent = priceLabel;
3860    priceRow.appendChild(currentPrice);
3861
3862    if (msrpLabel) {
3863        const msrp = document.createElement('span');
3864        msrp.className = 'recommendation-msrp';
3865        msrp.textContent = msrpLabel;
3866        priceRow.appendChild(msrp);
3867    }
3868
3869    meta.append(businessName, reasonTag);
3870    body.append(meta, title, priceRow);
3871    card.append(image, body);
3872
3873    return card;
3874}
3875
3876function formatCurrency(value) {
3877    return new Intl.NumberFormat('en-US', {
3878        style: 'currency',
3879        currency: 'USD'
3880    }).format(value);
3881}
3882
3883function setRecommendationsMessage(container, message) {
3884    const status = document.createElement('div');
3885    status.className = 'recommendations-loading';
3886    status.textContent = message;
3887    container.replaceChildren(status);
3888}
3889
3890function buildProductDetailPath(businessId, slug) {
3891    const normalizedBusinessId = Number.isFinite(Number(businessId)) ? String(Number(businessId)) : '0';
3892    return `/products/detail/${normalizedBusinessId}/${encodeURIComponent(toDisplayString(slug))}`;
3893}
3894
3895function getSafeAssetUrl(value) {
3896    if (typeof value !== 'string') return null;
3897
3898    const trimmed = value.trim();
3899    if (!trimmed) return null;
3900
3901    if (trimmed.startsWith('/')) {
3902        return trimmed;
3903    }
3904
3905    try {
3906        const url = new URL(trimmed, window.location.origin);
3907        if (url.protocol === 'http:' || url.protocol === 'https:') {
3908            return url.toString();
3909        }
3910    } catch {
3911        return null;
3912    }
3913
3914    return null;
3915}
3916
3917function toDisplayString(value) {
3918    return value == null ? '' : String(value);
3919}
3920
3921// ========================================================================
3922// Shopping cart (formerly cart.js)
3923// ========================================================================
3924
3925function cartPage() {
3926    return {
3927        debounceTimers: {},
3928
3929        // Helper to get API headers including antiforgery token
3930        getApiHeaders() {
3931            const headers = { 'Content-Type': 'application/json' };
3932            const token = window.cartConfig?.antiforgeryToken;
3933            const headerName = window.cartConfig?.antiforgeryHeaderName;
3934            if (token && headerName) {
3935                headers[headerName] = token;
3936            }
3937            return headers;
3938        },
3939
3940        async updateQuantity(cartItemId, quantity, inputEl) {
3941            const numQuantity = parseInt(quantity, 10);
3942            if (isNaN(numQuantity) || numQuantity < 1) {
3943                return;
3944            }
3945
3946            // Get the product-item element for state management
3947            const productItem = inputEl.closest('.product-item');
3948            const alpineData = productItem ? Alpine.$data(productItem) : null;
3949
3950            if (alpineData) {
3951                alpineData.updating = true;
3952            }
3953
3954            try {
3955                const response = await fetch(`/api/shopping-cart/items/${cartItemId}/quantity`, {
3956                    method: 'POST',
3957                    headers: this.getApiHeaders(),
3958                    body: JSON.stringify({ quantity: numQuantity }),
3959                });
3960
3961                if (response.ok) {
3962                    // Reload the page to reflect updated totals
3963                    refreshPage();
3964                } else {
3965                    const errorData = await response.json().catch(() => ({}));
3966                    console.error('Failed to update quantity:', errorData);
3967                    alert(errorData.message || 'Failed to update quantity. Please try again.');
3968                }
3969            } catch (error) {
3970                console.error('Error updating quantity:', error);
3971                alert('Failed to update quantity. Please try again.');
3972            } finally {
3973                if (alpineData) {
3974                    alpineData.updating = false;
3975                }
3976            }
3977        },
3978
3979        async toggleGiftWrap(cartItemId, isGiftWrapped, inputEl) {
3980            const productItem = inputEl.closest('.product-item');
3981            const alpineData = productItem ? Alpine.$data(productItem) : null;
3982
3983            if (alpineData) {
3984                alpineData.wrapping = true;
3985            }
3986
3987            try {
3988                const response = await fetch(`/api/shopping-cart/items/${cartItemId}/gift-wrap`, {
3989                    method: 'POST',
3990                    headers: this.getApiHeaders(),
3991                    body: JSON.stringify({ isGiftWrapped }),
3992                });
3993
3994                if (response.ok) {
3995                    refreshPage();
3996                } else {
3997                    const errorData = await response.json().catch(() => ({}));
3998                    console.error('Failed to update gift wrap:', errorData);
3999                    alert(errorData.message || 'Failed to update gift wrap. Please try again.');
4000                    inputEl.checked = !isGiftWrapped;
4001                }
4002            } catch (error) {
4003                console.error('Error updating gift wrap:', error);
4004                alert('Failed to update gift wrap. Please try again.');
4005                inputEl.checked = !isGiftWrapped;
4006            } finally {
4007                if (alpineData) {
4008                    alpineData.wrapping = false;
4009                }
4010            }
4011        },
4012
4013        async checkout(businessId, deliveryMethod, event) {
4014            const button = event.currentTarget;
4015            const checkoutDiv = button.closest('.checkout-form');
4016            const alpineData = checkoutDiv ? Alpine.$data(checkoutDiv) : null;
4017            const isLocalPickup =
4018                deliveryMethod === true || deliveryMethod === 'true' || deliveryMethod === 'pickup' || deliveryMethod === 1;
4019
4020            if (alpineData) {
4021                alpineData.submitting = true;
4022            }
4023
4024            try {
4025                const response = await fetch('/api/shopping-cart/session', {
4026                    method: 'POST',
4027                    headers: this.getApiHeaders(),
4028                    body: JSON.stringify({
4029                        businessId: businessId,
4030                        isLocalPickup: isLocalPickup,
4031                    }),
4032                });
4033
4034                if (response.ok) {
4035                    const data = await response.json();
4036                    if (data.checkoutSessionUrl) {
4037                        // Redirect to Stripe checkout
4038                        window.location.href = data.checkoutSessionUrl;
4039                    }
4040                } else {
4041                    const errorData = await response.json().catch(() => ({}));
4042                    console.error('Checkout failed:', errorData);
4043                    alert(errorData.message || 'Checkout failed. Please try again.');
4044                    if (alpineData) {
4045                        alpineData.submitting = false;
4046                    }
4047                }
4048            } catch (error) {
4049                console.error('Error during checkout:', error);
4050                alert('Checkout failed. Please try again.');
4051                if (alpineData) {
4052                    alpineData.submitting = false;
4053                }
4054            }
4055        },
4056
4057        async removeItem(cartItemId, event) {
4058            const button = event.currentTarget;
4059            const productItem = button.closest('.product-item');
4060            const alpineData = productItem ? Alpine.$data(productItem) : null;
4061
4062            if (alpineData) {
4063                alpineData.removing = true;
4064            }
4065
4066            try {
4067                // Use the quantity endpoint with quantity=0 to remove the item entirely
4068                // This uses cartItemId which uniquely identifies the cart item
4069                const response = await fetch(`/api/shopping-cart/items/${cartItemId}/quantity`, {
4070                    method: 'POST',
4071                    headers: this.getApiHeaders(),
4072                    body: JSON.stringify({ quantity: 0 }),
4073                });
4074
4075                if (response.ok) {
4076                    // Dispatch event to update cart badge
4077                    window.dispatchEvent(new CustomEvent('cart-updated'));
4078                    // Reload the page to reflect changes
4079                    refreshPage();
4080                } else {
4081                    const errorData = await response.json().catch(() => ({}));
4082                    console.error('Failed to remove item:', errorData);
4083                    alert(errorData.message || 'Failed to remove item. Please try again.');
4084                }
4085            } catch (error) {
4086                console.error('Error removing item:', error);
4087                alert('Failed to remove item. Please try again.');
4088            } finally {
4089                if (alpineData) {
4090                    alpineData.removing = false;
4091                }
4092            }
4093        }
4094    };
4095}
4096
4097// ========================================================================
4098// Wishlist (formerly wishlist.js)
4099// ========================================================================
4100
4101function wishlistPage() {
4102    return {
4103        searchQuery: new URLSearchParams(window.location.search).get('q') || '',
4104
4105        // Helper to get API headers including antiforgery token
4106        getApiHeaders() {
4107            const headers = { 'Content-Type': 'application/json' };
4108            const token = window.pageConfig?.antiforgeryToken;
4109            const headerName = window.pageConfig?.antiforgeryHeaderName;
4110            if (token && headerName) {
4111                headers[headerName] = token;
4112            }
4113            return headers;
4114        },
4115
4116        // Update URL with search query and reload page
4117        updateSearch() {
4118            const url = new URL(window.location.href);
4119            if (this.searchQuery.trim()) {
4120                url.searchParams.set('q', this.searchQuery.trim());
4121            } else {
4122                url.searchParams.delete('q');
4123            }
4124            navigateTo(url.toString());
4125        },
4126
4127        // Clear search and reload
4128        clearSearch() {
4129            this.searchQuery = '';
4130            const url = new URL(window.location.href);
4131            url.searchParams.delete('q');
4132            navigateTo(url.toString());
4133        },
4134
4135        // Remove product from wishlist
4136        async removeFromWishlist(productId, event) {
4137            const button = event.currentTarget;
4138            const card = button.closest('.wishlist-card');
4139            const alpineData = card ? Alpine.$data(card) : null;
4140
4141            if (alpineData) {
4142                alpineData.removing = true;
4143            }
4144
4145            // Add removing class to card for visual feedback
4146            if (card) {
4147                card.classList.add('removing');
4148            }
4149
4150            try {
4151                const response = await fetch(`/api/wishlists/products/${productId}`, {
4152                    method: 'DELETE',
4153                    headers: this.getApiHeaders(),
4154                });
4155
4156                if (response.ok) {
4157                    // Animate card removal
4158                    if (card) {
4159                        card.style.transition = 'all 0.3s ease';
4160                        card.style.opacity = '0';
4161                        card.style.transform = 'scale(0.95)';
4162
4163                        setTimeout(() => {
4164                            card.remove();
4165
4166                            // Check if grid is empty and reload to show empty state
4167                            const grid = document.getElementById('wishlist-grid');
4168                            if (grid && grid.children.length === 0) {
4169                                refreshPage();
4170                            }
4171
4172                            // Update item count in subtitle if present
4173                            this.updateItemCount();
4174                        }, 300);
4175                    }
4176                } else {
4177                    const errorData = await response.json().catch(() => ({}));
4178                    console.error('Failed to remove from wishlist:', errorData);
4179                    alert(errorData.message || 'Failed to remove item. Please try again.');
4180
4181                    // Reset visual state on error
4182                    if (card) {
4183                        card.classList.remove('removing');
4184                    }
4185                    if (alpineData) {
4186                        alpineData.removing = false;
4187                    }
4188                }
4189            } catch (error) {
4190                console.error('Error removing from wishlist:', error);
4191                alert('Failed to remove item. Please try again.');
4192
4193                // Reset visual state on error
4194                if (card) {
4195                    card.classList.remove('removing');
4196                }
4197                if (alpineData) {
4198                    alpineData.removing = false;
4199                }
4200            }
4201        },
4202
4203        // Update the item count displayed in the subtitle
4204        updateItemCount() {
4205            const subtitle = document.querySelector('.wishlist-subtitle');
4206            const grid = document.getElementById('wishlist-grid');
4207
4208            if (subtitle && grid) {
4209                const count = grid.children.length;
4210                if (count === 0) {
4211                    subtitle.remove();
4212                } else {
4213                    subtitle.textContent = `${count} ${count === 1 ? 'item' : 'items'} saved`;
4214                }
4215            }
4216        }
4217    };
4218}
4219
4220// ========================================================================
4221// Account settings (formerly account-settings.js)
4222// ========================================================================
4223
4224/**
4225 * Account Settings page Alpine.js component
4226 */
4227function settingsPage() {
4228    return {
4229        billingLoading: false,
4230        closeLoading: false,
4231        closeReason: 'Personal',
4232        canContact: false,
4233
4234        /**
4235         * Get API headers with antiforgery token
4236         */
4237        getApiHeaders() {
4238            const headers = { 'Content-Type': 'application/json' };
4239            const token = window.pageConfig?.antiforgeryToken;
4240            const headerName = window.pageConfig?.antiforgeryHeaderName;
4241            if (token && headerName) {
4242                headers[headerName] = token;
4243            }
4244            return headers;
4245        },
4246
4247        /**
4248         * Open Stripe billing portal
4249         */
4250        async openBillingPortal() {
4251            if (this.billingLoading) return;
4252
4253            // Check if user has Stripe customer ID
4254            if (!window.pageConfig?.hasStripeCustomerId) {
4255                this.showToast('You do not have a billing account set up.', 'error');
4256                return;
4257            }
4258
4259            this.billingLoading = true;
4260
4261            try {
4262                const response = await fetch('/api/subscriptions/stripe-portal-session', {
4263                    method: 'POST',
4264                    headers: this.getApiHeaders(),
4265                    body: JSON.stringify({
4266                        returnUrl: window.location.href
4267                    })
4268                });
4269
4270                if (!response.ok) {
4271                    const errorData = await response.json().catch(() => ({}));
4272                    throw new Error(errorData.message || errorData.title || 'Failed to create billing portal session');
4273                }
4274
4275                const data = await response.json();
4276                
4277                if (data.portalSessionUrl) {
4278                    window.location.assign(data.portalSessionUrl);
4279                } else {
4280                    throw new Error('No portal URL returned');
4281                }
4282            } catch (error) {
4283                console.error('Error opening billing portal:', error);
4284                this.showToast(error.message || 'An error occurred while trying to open the billing portal', 'error');
4285                this.billingLoading = false;
4286            }
4287        },
4288
4289        /**
4290         * Handle close account form submission
4291         */
4292        async handleCloseAccount() {
4293            if (this.closeLoading) return;
4294
4295            // Confirm with user
4296            const confirmed = window.confirm(
4297                'Are you sure you want to close your account? You can reopen it later, but your shop and listings will be hidden in the meantime.'
4298            );
4299
4300            if (!confirmed) return;
4301
4302            this.closeLoading = true;
4303
4304            try {
4305                const response = await fetch('/api/identity/me', {
4306                    method: 'DELETE',
4307                    headers: this.getApiHeaders(),
4308                    body: JSON.stringify({
4309                        reason: this.closeReason,
4310                        canContact: this.canContact
4311                    })
4312                });
4313
4314                if (!response.ok) {
4315                    const errorData = await response.json().catch(() => ({}));
4316                    throw new Error(errorData.message || errorData.title || 'Failed to close account');
4317                }
4318
4319                // Success - clear local data and redirect
4320                this.showToast('Your account has been closed successfully', 'success');
4321                localStorage.clear();
4322                
4323                // Redirect to home after brief delay
4324                setTimeout(() => {
4325                    navigateTo('/', { replace: true });
4326                }, 1500);
4327            } catch (error) {
4328                console.error('Error closing account:', error);
4329                this.showToast(error.message || 'Failed to close account', 'error');
4330                this.closeLoading = false;
4331            }
4332        },
4333
4334        /**
4335         * Show a toast notification
4336         */
4337        showToast(message, type = 'info') {
4338            // Check if Sonner toast is available
4339            if (typeof window.toast !== 'undefined') {
4340                if (type === 'error') {
4341                    window.toast.error(message);
4342                } else if (type === 'success') {
4343                    window.toast.success(message);
4344                } else {
4345                    window.toast(message);
4346                }
4347                return;
4348            }
4349
4350            // Fallback to alert
4351            alert(message);
4352        }
4353    };
4354}
4355
4356// ========================================================================
4357// Business setup (formerly businesses-setup.js)
4358// ========================================================================
4359
4360function businessSetupPage() {
4361    return {
4362        market: window.pageConfig?.market ?? null,
4363        hasMarket: window.pageConfig?.hasMarket ?? false,
4364        hasPaidSubscription: window.pageConfig?.hasPaidSubscription ?? false,
4365        isGrandfathered: window.pageConfig?.isGrandfathered ?? false,
4366        form: {
4367            name: '',
4368            description: '',
4369            businessType: 'Individual',
4370        },
4371        marketUrl: '',
4372        logoPreviewUrl: '',
4373        loadingMarket: false,
4374        saving: false,
4375        redirecting: false,
4376        logoUploading: false,
4377        stripeLoading: false,
4378        stripeAccountId: '',
4379        stripeError: '',
4380        pendingLogoFile: null,
4381        currentLogoObjectUrl: '',
4382        fieldErrors: {},
4383        formError: '',
4384        successMessage: '',
4385        planSuccessMessage: '',
4386
4387        init() {
4388            if (this.market) {
4389                this.applyMarketToForm(this.market);
4390                this.marketUrl = this.market.marketUrl || '';
4391                this.logoPreviewUrl = this.market.logo || this.placeholderLogo();
4392                this.stripeAccountId = this.market.stripeAccountId || '';
4393            } else {
4394                this.logoPreviewUrl = this.placeholderLogo();
4395                this.stripeAccountId = '';
4396            }
4397
4398            const url = new URL(window.location.href);
4399            const subscriptionCreated = url.searchParams.get('subscriptionCreated');
4400            if (subscriptionCreated === 'true') {
4401                this.planSuccessMessage = 'Plan activated. Your selling tools are now unlocked.';
4402                this.showToast(this.planSuccessMessage, 'success');
4403                url.searchParams.delete('subscriptionCreated');
4404                window.history.replaceState({}, '', `${url.pathname}${url.search}`);
4405            }
4406
4407            const stripeStatus = window.pageConfig?.stripeStatus;
4408            if (stripeStatus === 'success') {
4409                this.successMessage = 'Stripe onboarding complete. Payouts are now enabled.';
4410                this.showToast(this.successMessage, 'success');
4411            } else if (stripeStatus === 'refresh') {
4412                this.stripeError = 'Stripe onboarding is incomplete. Please continue setup.';
4413                this.showToast(this.stripeError, 'error');
4414            }
4415
4416            if (stripeStatus) {
4417                url.searchParams.delete('stripe');
4418                window.history.replaceState({}, '', `${url.pathname}${url.search}`);
4419                this.loadMarket();
4420            }
4421        },
4422
4423        get setupStep() {
4424            if (!this.hasMarket) return 1;
4425            if (!this.hasPaidSubscription) return 2;
4426            if (!this.market?.stripeChargesEnabled) return 3;
4427            return 4;
4428        },
4429
4430        // 'done', 'current' or 'upcoming' for checklist row n (1-4).
4431        stepState(n) {
4432            if (this.setupStep > n || this.setupStep === 4) return 'done';
4433            return this.setupStep === n ? 'current' : 'upcoming';
4434        },
4435
4436        get setupProgressPercent() {
4437            if (this.setupStep === 1) return 25;
4438            if (this.setupStep === 2) return 50;
4439            if (this.setupStep === 3) return 75;
4440            return 100;
4441        },
4442
4443        get progressNote() {
4444            if (!this.hasMarket) {
4445                return 'Complete your market profile to unlock listings.';
4446            }
4447            if (!this.hasPaidSubscription) {
4448                return this.isGrandfathered
4449                    ? 'Choose a paid plan to continue your seller access.'
4450                    : 'Choose a subscription plan to activate selling tools.';
4451            }
4452            if (!this.market?.stripeChargesEnabled) {
4453                return 'Connect Stripe payouts to complete setup.';
4454            }
4455            return 'Setup complete. Manage your market in admin.';
4456        },
4457
4458        get formattedCreatedAt() {
4459            if (!this.market?.createdAt) return '—';
4460            const date = new Date(this.market.createdAt);
4461            return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
4462        },
4463
4464        placeholderLogo() {
4465            return 'https://placehold.co/400x400?text=Logo';
4466        },
4467
4468        getApiHeaders() {
4469            const headers = { 'Content-Type': 'application/json' };
4470            const token = window.pageConfig?.antiforgeryToken;
4471            const headerName = window.pageConfig?.antiforgeryHeaderName;
4472            if (token && headerName) {
4473                headers[headerName] = token;
4474            }
4475            return headers;
4476        },
4477
4478        applyMarketToForm(market) {
4479            if (!market) return;
4480            this.form.name = market.name ?? '';
4481            this.form.description = market.description ?? '';
4482            this.form.businessType = market.businessType ?? 'Individual';
4483        },
4484
4485        validateForm() {
4486            const errors = {};
4487
4488            if (!this.form.name || this.form.name.trim().length < 3) {
4489                errors.name = 'Name must be at least 3 characters.';
4490            } else if (this.form.name.trim().length > 100) {
4491                errors.name = 'Name cannot exceed 100 characters.';
4492            } else if (this.form.name.toLowerCase().includes('finnalby')) {
4493                errors.name = "Name cannot contain 'Finnalby'.";
4494            }
4495
4496            if (!this.form.description || this.form.description.trim().length < 5) {
4497                errors.description = 'Description must be at least 5 characters.';
4498            } else if (this.form.description.trim().length > 10000) {
4499                errors.description = 'Description cannot exceed 10,000 characters.';
4500            }
4501
4502            if (!this.form.businessType) {
4503                errors.businessType = 'Select a business type.';
4504            }
4505
4506            return errors;
4507        },
4508
4509        async loadMarket() {
4510            if (this.loadingMarket) return;
4511            this.loadingMarket = true;
4512            this.formError = '';
4513
4514            try {
4515                const response = await fetch('/api/markets/me');
4516
4517                if (response.status === 401) {
4518                    navigateTo('/account/login');
4519                    return;
4520                }
4521
4522                if (response.status === 403 || response.status === 404) {
4523                    this.market = null;
4524                    this.hasMarket = false;
4525                    this.marketUrl = '';
4526                    this.stripeAccountId = '';
4527                    return;
4528                }
4529
4530                if (!response.ok) {
4531                    const errorData = await response.json().catch(() => ({}));
4532                    this.formError = errorData.message || errorData.title || 'Unable to load market data.';
4533                    return;
4534                }
4535
4536                const data = await response.json();
4537                this.market = data.market || null;
4538                this.hasMarket = Boolean(data.market);
4539                if (typeof data.hasPaidSubscription === 'boolean') {
4540                    this.hasPaidSubscription = data.hasPaidSubscription;
4541                }
4542                if (typeof data.isGrandfathered === 'boolean') {
4543                    this.isGrandfathered = data.isGrandfathered;
4544                }
4545                if (this.market) {
4546                    this.applyMarketToForm(this.market);
4547                    this.marketUrl = this.market.marketUrl || '';
4548                    this.stripeAccountId = this.market.stripeAccountId || '';
4549                    if (!this.pendingLogoFile && !this.logoUploading) {
4550                        if (this.market.logo) {
4551                            this.clearLogoObjectUrl();
4552                            this.logoPreviewUrl = this.market.logo;
4553                        }
4554                    }
4555                } else {
4556                    this.stripeAccountId = '';
4557                }
4558            } catch (error) {
4559                console.error('Error loading market:', error);
4560                this.formError = 'Unable to load market data.';
4561            } finally {
4562                this.loadingMarket = false;
4563            }
4564        },
4565
4566        async saveMarket(shouldUploadLogo = false) {
4567            if (this.saving) return;
4568
4569            this.fieldErrors = this.validateForm();
4570            if (Object.keys(this.fieldErrors).length > 0) {
4571                this.formError = 'Please fix the highlighted fields.';
4572                return;
4573            }
4574
4575            this.formError = '';
4576            this.successMessage = '';
4577            this.saving = true;
4578
4579            try {
4580                const response = await fetch('/api/markets', {
4581                    method: 'POST',
4582                    headers: this.getApiHeaders(),
4583                    body: JSON.stringify({
4584                        name: this.form.name.trim(),
4585                        description: this.form.description.trim(),
4586                        businessType: this.form.businessType,
4587                    }),
4588                });
4589
4590                if (!response.ok) {
4591                    await this.handleApiError(response);
4592                    return;
4593                }
4594
4595                await response.json();
4596                this.successMessage = 'Market details saved.';
4597                this.showToast('Market details saved.', 'success');
4598                await this.loadMarket();
4599                if (shouldUploadLogo && this.pendingLogoFile) {
4600                    const uploadSucceeded = await this.uploadLogo(this.pendingLogoFile);
4601                    if (uploadSucceeded) {
4602                        this.pendingLogoFile = null;
4603                    }
4604                }
4605            } catch (error) {
4606                console.error('Error saving market:', error);
4607                this.formError = 'Unable to save market details.';
4608            } finally {
4609                this.saving = false;
4610            }
4611        },
4612
4613        async handleLogoChange(event) {
4614            const file = event.target.files?.[0];
4615            if (!file) return;
4616
4617            const maxSize = 10 * 1024 * 1024;
4618            const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/avif'];
4619
4620            if (!allowedTypes.includes(file.type)) {
4621                this.fieldErrors.logo = 'Logo must be a JPG, PNG, WebP, or AVIF file.';
4622                return;
4623            }
4624
4625            if (file.size > maxSize) {
4626                this.fieldErrors.logo = 'Logo must be less than 10MB.';
4627                return;
4628            }
4629
4630            this.fieldErrors.logo = '';
4631            this.clearLogoObjectUrl();
4632            this.currentLogoObjectUrl = URL.createObjectURL(file);
4633            this.logoPreviewUrl = this.currentLogoObjectUrl;
4634            this.pendingLogoFile = file;
4635
4636            if (this.hasMarket) {
4637                const uploadSucceeded = await this.uploadLogo(file);
4638                if (uploadSucceeded) {
4639                    this.pendingLogoFile = null;
4640                }
4641                return;
4642            }
4643
4644            await this.saveMarket(true);
4645        },
4646
4647        async uploadLogo(file) {
4648            if (this.logoUploading) return false;
4649
4650            this.logoUploading = true;
4651            this.formError = '';
4652
4653            try {
4654                const formData = new FormData();
4655                formData.append('Logo', file);
4656
4657                const headers = {};
4658                const token = window.pageConfig?.antiforgeryToken;
4659                const headerName = window.pageConfig?.antiforgeryHeaderName;
4660                if (token && headerName) {
4661                    headers[headerName] = token;
4662                }
4663
4664                const response = await fetch('/api/markets/logo', {
4665                    method: 'PUT',
4666                    headers,
4667                    body: formData,
4668                });
4669
4670                if (!response.ok) {
4671                    await this.handleApiError(response);
4672                    return false;
4673                }
4674
4675                const data = await response.json();
4676                if (data?.imageUrl) {
4677                    this.clearLogoObjectUrl();
4678                    this.logoPreviewUrl = data.imageUrl;
4679                    if (this.market) {
4680                        this.market.logo = data.imageUrl;
4681                    }
4682                }
4683
4684                this.showToast('Logo updated.', 'success');
4685                return true;
4686            } catch (error) {
4687                console.error('Error uploading logo:', error);
4688                this.formError = 'Unable to upload logo.';
4689                return false;
4690            } finally {
4691                this.logoUploading = false;
4692            }
4693        },
4694
4695        async setupStripeConnect() {
4696            if (this.stripeLoading) return;
4697            this.stripeLoading = true;
4698            this.stripeError = '';
4699
4700            try {
4701                const response = await fetch('/api/stripe/connect-account-link', {
4702                    method: 'POST',
4703                    headers: this.getApiHeaders(),
4704                });
4705
4706                if (!response.ok) {
4707                    await this.handleApiError(response, true);
4708                    return;
4709                }
4710
4711                const data = await response.json();
4712                if (data?.accountLinkUrl) {
4713                    window.location.href = data.accountLinkUrl;
4714                } else {
4715                    this.stripeError = 'Unable to get Stripe onboarding link.';
4716                }
4717            } catch (error) {
4718                console.error('Error setting up Stripe Connect:', error);
4719                this.stripeError = 'Unable to set up Stripe Connect.';
4720            } finally {
4721                this.stripeLoading = false;
4722            }
4723        },
4724
4725        async handleApiError(response, isStripe = false) {
4726            const errorData = await response.json().catch(() => ({}));
4727
4728            if (response.status === 400 && errorData.errors) {
4729                this.fieldErrors = Object.entries(errorData.errors).reduce((acc, [key, value]) => {
4730                    acc[key.charAt(0).toLowerCase() + key.slice(1)] = Array.isArray(value) ? value[0] : value;
4731                    return acc;
4732                }, {});
4733                this.formError = 'Please fix the highlighted fields.';
4734                return;
4735            }
4736
4737            const message = errorData.message || errorData.title || 'Something went wrong. Please try again.';
4738            if (isStripe) {
4739                this.stripeError = message;
4740            } else {
4741                this.formError = message;
4742            }
4743            this.showToast(message, 'error');
4744        },
4745
4746        showToast(message, type = 'info') {
4747            if (typeof window.toast !== 'undefined') {
4748                if (type === 'error') {
4749                    window.toast.error(message);
4750                } else if (type === 'success') {
4751                    window.toast.success(message);
4752                } else {
4753                    window.toast(message);
4754                }
4755                return;
4756            }
4757
4758            if (type === 'error') {
4759                alert(message);
4760            }
4761        },
4762
4763        clearLogoObjectUrl() {
4764            if (this.currentLogoObjectUrl) {
4765                URL.revokeObjectURL(this.currentLogoObjectUrl);
4766                this.currentLogoObjectUrl = '';
4767            }
4768        },
4769    };
4770}
4771
4772// ========================================================================
4773// Business setup plans (formerly businesses-setup-plans.js)
4774// ========================================================================
4775
4776function businessSetupPlansPage(initialInterval = 'month') {
4777    return {
4778        interval: initialInterval,
4779        pendingPriceId: '',
4780        error: '',
4781
4782        init() {
4783            // Back from Stripe Checkout, the browser may restore this page from its cache with the
4784            // button still showing "Opening checkout…".
4785            listenWhileMounted(this, window, 'pageshow', (event) => {
4786                if (event.persisted) this.pendingPriceId = '';
4787            });
4788        },
4789
4790        async subscribe(priceId) {
4791            if (this.pendingPriceId) return;
4792            this.pendingPriceId = priceId;
4793            this.error = '';
4794
4795            try {
4796                const headers = { 'Content-Type': 'application/json' };
4797                const token = window.pageConfig?.antiforgeryToken;
4798                const headerName = window.pageConfig?.antiforgeryHeaderName;
4799                if (token && headerName) headers[headerName] = token;
4800
4801                const response = await fetch('/api/subscriptions/seller-checkout', {
4802                    method: 'POST',
4803                    headers,
4804                    body: JSON.stringify({ priceId }),
4805                });
4806                const data = await response.json().catch(() => ({}));
4807
4808                if (response.ok && data.checkoutUrl) {
4809                    // Stripe Checkout is another origin: leave the document.
4810                    window.location.href = data.checkoutUrl;
4811                    return;
4812                }
4813
4814                this.error = data.message || 'Unable to start checkout. Please try again.';
4815            } catch (error) {
4816                console.error('Error starting seller checkout:', error);
4817                this.error = 'Unable to start checkout. Please try again.';
4818            }
4819
4820            this.pendingPriceId = '';
4821        },
4822    };
4823}
4824
4825// ========================================================================
4826// Alpine registration
4827// The only alpine:init listener in the app. Every component must be registered here so it exists before Alpine starts.
4828// ========================================================================
4829
4830// ========================================================================
4831// Account profile (formerly account-profile.js)
4832// ========================================================================
4833
4834/**
4835 * Account Profile page Alpine.js component. Reads window.pageConfig (antiforgery + currentUser)
4836 * when the component is created.
4837 */
4838function profilePage() {
4839    return {
4840        // Form state
4841        firstName: window.pageConfig?.currentUser?.firstName || '',
4842        lastName: window.pageConfig?.currentUser?.lastName || '',
4843        phoneNumber: window.pageConfig?.currentUser?.phoneNumber || '',
4844        email: window.pageConfig?.currentUser?.email || '',
4845        avatarUrl: window.pageConfig?.currentUser?.avatarUrl || '',
4846
4847        // Loading states
4848        profileSaving: false,
4849        imageUploading: false,
4850
4851        // Messages
4852        errorMessage: '',
4853        successMessage: '',
4854
4855        // Validation errors
4856        errors: {
4857            firstName: '',
4858            lastName: '',
4859            phoneNumber: ''
4860        },
4861
4862        /**
4863         * Get computed full name
4864         */
4865        get fullName() {
4866            return `${this.firstName} ${this.lastName}`.trim();
4867        },
4868
4869        /**
4870         * Get API headers with antiforgery token
4871         */
4872        getApiHeaders(isFormData = false) {
4873            const headers = {};
4874            if (!isFormData) {
4875                headers['Content-Type'] = 'application/json';
4876            }
4877            const token = window.pageConfig?.antiforgeryToken;
4878            const headerName = window.pageConfig?.antiforgeryHeaderName;
4879            if (token && headerName) {
4880                headers[headerName] = token;
4881            }
4882            return headers;
4883        },
4884
4885        /**
4886         * Format phone number as user types (XXX-XXX-XXXX)
4887         */
4888        formatPhoneNumber(event) {
4889            let value = event.target.value.replace(/\D/g, '');
4890
4891            if (value.length > 10) {
4892                value = value.slice(0, 10);
4893            }
4894
4895            if (value.length >= 7) {
4896                value = `${value.slice(0, 3)}-${value.slice(3, 6)}-${value.slice(6)}`;
4897            } else if (value.length >= 4) {
4898                value = `${value.slice(0, 3)}-${value.slice(3)}`;
4899            }
4900
4901            this.phoneNumber = value;
4902        },
4903
4904        /**
4905         * Validate form fields
4906         */
4907        validate() {
4908            this.errors = {
4909                firstName: '',
4910                lastName: '',
4911                phoneNumber: ''
4912            };
4913
4914            let isValid = true;
4915
4916            if (!this.firstName.trim()) {
4917                this.errors.firstName = 'First name is required';
4918                isValid = false;
4919            } else if (this.firstName.length > 255) {
4920                this.errors.firstName = 'First name must be less than 255 characters';
4921                isValid = false;
4922            }
4923
4924            if (!this.lastName.trim()) {
4925                this.errors.lastName = 'Last name is required';
4926                isValid = false;
4927            } else if (this.lastName.length > 255) {
4928                this.errors.lastName = 'Last name must be less than 255 characters';
4929                isValid = false;
4930            }
4931
4932            // Phone number validation (optional, but if provided must match format)
4933            if (this.phoneNumber) {
4934                const phoneRegex = /^[2-9]\d{2}-[2-9]\d{2}-\d{4}$/;
4935                if (!phoneRegex.test(this.phoneNumber)) {
4936                    this.errors.phoneNumber = 'Phone number must be in format XXX-XXX-XXXX';
4937                    isValid = false;
4938                }
4939            }
4940
4941            return isValid;
4942        },
4943
4944        /**
4945         * Handle profile form submission
4946         */
4947        async handleSubmit() {
4948            if (this.profileSaving) return;
4949
4950            this.errorMessage = '';
4951            this.successMessage = '';
4952
4953            if (!this.validate()) {
4954                return;
4955            }
4956
4957            this.profileSaving = true;
4958
4959            try {
4960                const response = await fetch('/api/identity/update-profile', {
4961                    method: 'POST',
4962                    headers: this.getApiHeaders(),
4963                    body: JSON.stringify({
4964                        firstName: this.firstName.trim(),
4965                        lastName: this.lastName.trim(),
4966                        phoneNumber: this.phoneNumber || null
4967                    })
4968                });
4969
4970                if (!response.ok) {
4971                    const errorData = await response.json().catch(() => ({}));
4972
4973                    // Handle validation errors
4974                    if (errorData.errors) {
4975                        const firstError = Object.values(errorData.errors).flat()[0];
4976                        throw new Error(firstError || 'Failed to update profile');
4977                    }
4978
4979                    throw new Error(errorData.message || errorData.title || 'Failed to update profile');
4980                }
4981
4982                const data = await response.json();
4983                this.successMessage = data.message || 'Profile updated successfully';
4984                this.showToast(this.successMessage, 'success');
4985
4986                // Auto-hide success message after 5 seconds
4987                setTimeout(() => {
4988                    this.successMessage = '';
4989                }, 5000);
4990            } catch (error) {
4991                console.error('Error updating profile:', error);
4992                this.errorMessage = error.message || 'Failed to update profile';
4993                this.showToast(this.errorMessage, 'error');
4994            } finally {
4995                this.profileSaving = false;
4996            }
4997        },
4998
4999        /**
5000         * Handle image upload
5001         */
5002        async handleImageUpload(event) {
5003            const file = event.target.files?.[0];
5004            if (!file) return;
5005
5006            // Reset file input
5007            event.target.value = '';
5008
5009            // Validate file type
5010            const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
5011            if (!allowedTypes.includes(file.type)) {
5012                this.showToast('Please select a JPEG, PNG, or WebP image', 'error');
5013                return;
5014            }
5015
5016            // Validate file size (10MB max)
5017            const maxSize = 10 * 1024 * 1024;
5018            if (file.size > maxSize) {
5019                this.showToast('Image must be less than 10MB', 'error');
5020                return;
5021            }
5022
5023            this.imageUploading = true;
5024            this.errorMessage = '';
5025
5026            try {
5027                const formData = new FormData();
5028                formData.append('image', file);
5029
5030                const response = await fetch('/api/identity/upload-profile-image', {
5031                    method: 'POST',
5032                    headers: this.getApiHeaders(true),
5033                    body: formData
5034                });
5035
5036                if (!response.ok) {
5037                    const errorData = await response.json().catch(() => ({}));
5038
5039                    if (errorData.errors) {
5040                        const firstError = Object.values(errorData.errors).flat()[0];
5041                        throw new Error(firstError || 'Failed to upload image');
5042                    }
5043
5044                    throw new Error(errorData.message || errorData.title || 'Failed to upload image');
5045                }
5046
5047                const data = await response.json();
5048                this.avatarUrl = data.imageUrl;
5049                this.showToast(data.message || 'Profile photo updated', 'success');
5050            } catch (error) {
5051                console.error('Error uploading image:', error);
5052                this.errorMessage = error.message || 'Failed to upload image';
5053                this.showToast(this.errorMessage, 'error');
5054            } finally {
5055                this.imageUploading = false;
5056            }
5057        },
5058
5059        /**
5060         * Show a toast notification
5061         */
5062        showToast(message, type = 'info') {
5063            // Check if Sonner toast is available
5064            if (typeof window.toast !== 'undefined') {
5065                if (type === 'error') {
5066                    window.toast.error(message);
5067                } else if (type === 'success') {
5068                    window.toast.success(message);
5069                } else {
5070                    window.toast(message);
5071                }
5072                return;
5073            }
5074
5075            // Fallback - already showing in-page alerts
5076        }
5077    };
5078}
5079
5080// ========================================================================
5081// Account orders: review modal (formerly inline in Account/Orders/Index)
5082// ========================================================================
5083
5084/**
5085 * Order history page: the add/edit review modal. Reads window.pageConfig (antiforgery +
5086 * reviewsById, keyed by review id) for prefilling an existing review.
5087 */
5088function ordersReviewsPage() {
5089    return {
5090        showReviewModal: false,
5091        isSaving: false,
5092        errorMessage: '',
5093        reviewForm: {
5094            productId: null,
5095            orderItemId: null,
5096            reviewId: null,
5097            rating: 0,
5098            headline: '',
5099            comments: ''
5100        },
5101
5102        getApiHeaders() {
5103            const headers = { 'Content-Type': 'application/json' };
5104            const token = window.pageConfig?.antiforgeryToken;
5105            const headerName = window.pageConfig?.antiforgeryHeaderName;
5106            if (token && headerName) {
5107                headers[headerName] = token;
5108            }
5109            return headers;
5110        },
5111
5112        openReviewModal(productId, orderItemId, reviewId) {
5113            this.errorMessage = '';
5114            this.reviewForm.productId = productId;
5115            this.reviewForm.orderItemId = orderItemId;
5116            this.reviewForm.reviewId = reviewId;
5117
5118            if (reviewId && window.pageConfig?.reviewsById?.[reviewId]) {
5119                const review = window.pageConfig.reviewsById[reviewId];
5120                this.reviewForm.rating = review.rating;
5121                this.reviewForm.headline = review.headline || '';
5122                this.reviewForm.comments = review.comments || '';
5123            } else {
5124                this.reviewForm.rating = 0;
5125                this.reviewForm.headline = '';
5126                this.reviewForm.comments = '';
5127            }
5128
5129            this.showReviewModal = true;
5130            document.body.style.overflow = 'hidden';
5131        },
5132
5133        closeReviewModal() {
5134            this.showReviewModal = false;
5135            this.isSaving = false;
5136            this.errorMessage = '';
5137            document.body.style.overflow = '';
5138        },
5139
5140        setRating(value) {
5141            this.reviewForm.rating = value;
5142        },
5143
5144        async submitReview() {
5145            if (this.isSaving) return;
5146
5147            this.errorMessage = '';
5148
5149            if (!this.reviewForm.rating) {
5150                this.errorMessage = 'Please select a rating.';
5151                return;
5152            }
5153
5154            if (!this.reviewForm.headline.trim()) {
5155                this.errorMessage = 'Please add a headline.';
5156                return;
5157            }
5158
5159            if (this.reviewForm.comments.trim().length < 20) {
5160                this.errorMessage = 'Review comments must be at least 20 characters.';
5161                return;
5162            }
5163
5164            this.isSaving = true;
5165
5166            try {
5167                const response = await fetch(`/api/products/${this.reviewForm.productId}/reviews`, {
5168                    method: 'POST',
5169                    headers: this.getApiHeaders(),
5170                    body: JSON.stringify({
5171                        rating: this.reviewForm.rating,
5172                        headline: this.reviewForm.headline.trim(),
5173                        comments: this.reviewForm.comments.trim()
5174                    })
5175                });
5176
5177                const data = await response.json().catch(() => ({}));
5178
5179                if (!response.ok) {
5180                    throw new Error(data.message || data.title || 'Failed to submit review');
5181                }
5182
5183                const review = data.review;
5184                if (review && review.id) {
5185                    window.pageConfig.reviewsById = window.pageConfig.reviewsById || {};
5186                    window.pageConfig.reviewsById[review.id] = {
5187                        id: review.id,
5188                        productId: review.productId,
5189                        rating: review.rating,
5190                        headline: review.headline,
5191                        comments: review.comments
5192                    };
5193                }
5194
5195                this.closeReviewModal();
5196                refreshPage();
5197            } catch (error) {
5198                console.error('Review submission error:', error);
5199                this.errorMessage = error.message || 'Unable to submit review. Please try again.';
5200                this.isSaving = false;
5201            }
5202        }
5203    };
5204}
5205
5206// ========================================================================
5207// Admin dashboard stats (formerly admin-dashboard.js)
5208// ========================================================================
5209
5210const adminDashboardStats = (() => {
5211    const monthLabels = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
5212
5213    const numberFormatter = new Intl.NumberFormat("en-US");
5214    const currencyFormatter = new Intl.NumberFormat("en-US", {
5215        style: "currency",
5216        currency: "USD",
5217        minimumFractionDigits: 2,
5218        maximumFractionDigits: 2,
5219    });
5220
5221    return function adminDashboardStats() {
5222        let chartInstance = null;
5223
5224        return {
5225            stats: null,
5226            loading: true,
5227            error: null,
5228            timePeriod: 365,
5229            init() {
5230                const params = new URLSearchParams(window.location.search);
5231                const viewedAtSince = params.get("viewedAtSince");
5232                const parsed = viewedAtSince ? parseInt(viewedAtSince, 10) : NaN;
5233
5234                if (!Number.isNaN(parsed)) {
5235                    this.timePeriod = parsed;
5236                }
5237
5238                this.fetchStats();
5239
5240                this.$watch("timePeriod", () => {
5241                    this.updateUrl();
5242                    this.fetchStats();
5243                });
5244            },
5245            setTimePeriod(days) {
5246                this.timePeriod = days;
5247            },
5248            updateUrl() {
5249                const url = new URL(window.location.href);
5250                url.searchParams.set("viewedAtSince", this.timePeriod.toString());
5251                window.history.replaceState({}, "", url.toString());
5252            },
5253            async fetchStats() {
5254                this.loading = true;
5255                this.error = null;
5256
5257                try {
5258                    const sinceIso = this.toSinceIso(this.timePeriod);
5259                    const response = await fetch(
5260                        `/api/markets/me/stats?viewedAtSince=${encodeURIComponent(sinceIso)}`
5261                    );
5262
5263                    if (!response.ok) {
5264                        throw new Error("Stats request failed");
5265                    }
5266
5267                    this.stats = await response.json();
5268                    this.renderChart();
5269                } catch (err) {
5270                    this.stats = null;
5271                    this.error = "Unable to load stats right now. Please refresh in a moment.";
5272                    this.destroyChart();
5273                } finally {
5274                    this.loading = false;
5275                }
5276            },
5277            toSinceIso(days) {
5278                const date = new Date();
5279                date.setUTCDate(date.getUTCDate() - days);
5280                date.setUTCHours(0, 0, 0, 0);
5281                return date.toISOString();
5282            },
5283            formatNumber(value) {
5284                if (typeof value !== "number") {
5285                    return "-";
5286                }
5287                return numberFormatter.format(value);
5288            },
5289            formatCurrency(value) {
5290                if (typeof value !== "number") {
5291                    return "-";
5292                }
5293                return currencyFormatter.format(value);
5294            },
5295            getMonthBuckets() {
5296                const now = new Date();
5297                let year = now.getUTCFullYear();
5298                let month = now.getUTCMonth();
5299
5300                const buckets = [];
5301                for (let i = 0; i < 12; i += 1) {
5302                    const key = `${year}-${String(month + 1).padStart(2, "0")}`;
5303                    const label = `${monthLabels[month] || ""} '${String(year).slice(-2)}`;
5304
5305                    buckets.push({
5306                        key,
5307                        label,
5308                    });
5309
5310                    month -= 1;
5311                    if (month < 0) {
5312                        month = 11;
5313                        year -= 1;
5314                    }
5315                }
5316
5317                return buckets.reverse();
5318            },
5319            getChartData() {
5320                const buckets = this.getMonthBuckets();
5321                const labels = buckets.map((bucket) => bucket.label);
5322                const views = this.stats?.monthlyProductViews;
5323                
5324                const data = buckets.map((bucket) => {
5325                    if (!views) return 0;
5326
5327                    // 1. Try exact key match (YYYY-MM)
5328                    if (typeof views[bucket.key] === "number") {
5329                        return views[bucket.key];
5330                    }
5331
5332                    // 2. Try case-insensitive match
5333                    const insensitiveKey = Object.keys(views).find(k => k.toLowerCase() === bucket.key.toLowerCase());
5334                    if (insensitiveKey) {
5335                        return views[insensitiveKey];
5336                    }
5337
5338                    // 3. Fallback: Try matching month number (1-12) if keys are just month numbers
5339                    // The bucket.key format is "YYYY-MM", so we parse the MM part
5340                    const monthPart = parseInt(bucket.key.split('-')[1], 10);
5341                    // Check if there is a key that matches the month number (string or number)
5342                    if (monthPart) {
5343                         // Check for string key "5" or number key 5
5344                        if (typeof views[monthPart] === "number") return views[monthPart];
5345                        if (typeof views[String(monthPart)] === "number") return views[String(monthPart)];
5346                    }
5347
5348                    return 0;
5349                });
5350
5351                return { labels, data };
5352            },
5353            destroy() {
5354                this.destroyChart();
5355            },
5356            renderChart() {
5357                if (!this.$refs.chart) {
5358                    return;
5359                }
5360
5361                if (!window.Chart) {
5362                    loadScriptOnce(CHART_JS_SCRIPT, { crossorigin: 'anonymous' })
5363                        .then(() => this.renderChart())
5364                        .catch((error) => console.warn(error));
5365                    return;
5366                }
5367
5368                const canvas = this.$refs.chart;
5369                if (!(canvas instanceof HTMLCanvasElement)) {
5370                    return;
5371                }
5372
5373                const { labels, data } = this.getChartData();
5374
5375                if (chartInstance) {
5376                    chartInstance.data.labels = labels;
5377                    chartInstance.data.datasets[0].data = data;
5378                    chartInstance.update();
5379                    return;
5380                }
5381
5382                const ctx = canvas.getContext("2d");
5383                if (!ctx) {
5384                    return;
5385                }
5386
5387                chartInstance = new window.Chart(canvas, {
5388                    type: "line",
5389                    data: {
5390                        labels,
5391                        datasets: [
5392                            {
5393                                label: "Product Views",
5394                                data,
5395                                borderColor: "#f97316",
5396                                backgroundColor: "rgba(249, 115, 22, 0.15)",
5397                                tension: 0.35,
5398                                fill: true,
5399                                pointRadius: 3,
5400                                pointBackgroundColor: "#f97316",
5401                                pointBorderColor: "#fff",
5402                                pointBorderWidth: 2,
5403                            },
5404                        ],
5405                    },
5406                    options: {
5407                        responsive: true,
5408                        maintainAspectRatio: false,
5409                        plugins: {
5410                            legend: {
5411                                display: false,
5412                            },
5413                            tooltip: {
5414                                callbacks: {
5415                                    label(context) {
5416                                        return `${context.parsed.y} views`;
5417                                    },
5418                                },
5419                            },
5420                        },
5421                        scales: {
5422                            x: {
5423                                grid: {
5424                                    display: false,
5425                                },
5426                                ticks: {
5427                                    color: "#7a7067",
5428                                },
5429                            },
5430                            y: {
5431                                grid: {
5432                                    color: "rgba(27, 26, 23, 0.08)",
5433                                },
5434                                ticks: {
5435                                    color: "#7a7067",
5436                                },
5437                            },
5438                        },
5439                    },
5440                });
5441            },
5442            destroyChart() {
5443                if (chartInstance) {
5444                    chartInstance.destroy();
5445                    chartInstance = null;
5446                }
5447            },
5448        };
5449    };
5450})();
5451
5452// ========================================================================
5453// Admin products (formerly admin-products.js)
5454// ========================================================================
5455
5456// Reads window.pageConfig ({ products, searchTerm, pendingHandmadeApproval, page, pageSize,
5457// totalPages, isAdmin, antiforgery… }), written by Components/Pages/Admin/Products/Index.razor.
5458const adminProductsPage = (() => {
5459    const placeholderImage = "https://placehold.co/90x90?text=Listing";
5460    const DEFAULT_PAGE_SIZE = 50;
5461    const ALLOWED_PAGE_SIZES = [25, 50, 75, 100];
5462
5463    const normalizePageSize = (value) => {
5464        const parsed = Number.parseInt(String(value ?? ""), 10);
5465        return ALLOWED_PAGE_SIZES.includes(parsed) ? parsed : DEFAULT_PAGE_SIZE;
5466    };
5467
5468    const normalizePageNumber = (value) => {
5469        const parsed = Number.parseInt(String(value ?? ""), 10);
5470        return Number.isNaN(parsed) || parsed < 1 ? 1 : parsed;
5471    };
5472
5473    const formatRequestError = async (response) => {
5474        let message = "Request failed";
5475        try {
5476            const data = await response.json();
5477            if (data?.message) {
5478                message = data.message;
5479            }
5480        } catch {
5481            // Ignore JSON parse errors
5482        }
5483        return message;
5484    };
5485
5486    return function adminProductsPage() {
5487        return {
5488            products: window.pageConfig?.products ?? [],
5489            searchTerm: window.pageConfig?.searchTerm ?? "",
5490            pendingHandmadeApproval: window.pageConfig?.pendingHandmadeApproval ?? false,
5491            currentPage: window.pageConfig?.page ?? 1,
5492            totalPages: window.pageConfig?.totalPages ?? 0,
5493            pageSize: normalizePageSize(window.pageConfig?.pageSize ?? DEFAULT_PAGE_SIZE),
5494            isAdmin: window.pageConfig?.isAdmin ?? false,
5495            selectedIds: [],
5496            bulkWorking: false,
5497            loading: false,
5498            quantityTimers: {},
5499            quantityCache: {},
5500            notifications: [],
5501            placeholderImage,
5502            init() {
5503                this.products = window.pageConfig?.products ?? [];
5504                this.cacheQuantities();
5505                this.showPendingFlash();
5506                this.$nextTick(() => this.syncSelectAllState());
5507
5508                // Back/forward between the ?page entries updateUrl pushes needs nothing here:
5509                // Blazor handles popstate as an enhanced navigation and the page renders that
5510                // URL's state from the server.
5511                //
5512                // A full-document back/forward can restore this page from the browser's
5513                // bfcache, which neither re-runs init() nor fires popstate. In that
5514                // case the AJAX-rendered table can be out of sync with the ?page URL,
5515                // so reconcile state from the URL and refresh when restored or stale.
5516                listenWhileMounted(this, window, "pageshow", (event) => {
5517                    if (event.persisted || this.stateDiffersFromUrl()) {
5518                        this.applyStateFromUrl();
5519                        this.refreshListings();
5520                    }
5521                });
5522            },
5523            readStateFromUrl() {
5524                const url = new URL(window.location.href);
5525                return {
5526                    searchTerm: url.searchParams.get("searchTerm") || "",
5527                    currentPage: normalizePageNumber(url.searchParams.get("page")),
5528                    pageSize: normalizePageSize(url.searchParams.get("pageSize")),
5529                    pendingHandmadeApproval: url.searchParams.get("pendingHandmadeApproval") === "true",
5530                };
5531            },
5532            applyStateFromUrl() {
5533                const state = this.readStateFromUrl();
5534                this.searchTerm = state.searchTerm;
5535                this.currentPage = state.currentPage;
5536                this.pageSize = state.pageSize;
5537                this.pendingHandmadeApproval = state.pendingHandmadeApproval;
5538            },
5539            stateDiffersFromUrl() {
5540                const state = this.readStateFromUrl();
5541                return state.searchTerm !== this.searchTerm
5542                    || state.currentPage !== this.currentPage
5543                    || state.pageSize !== this.pageSize
5544                    || state.pendingHandmadeApproval !== this.pendingHandmadeApproval;
5545            },
5546            get pages() {
5547                const pages = [];
5548                const total = this.totalPages;
5549                const current = this.currentPage;
5550
5551                if (total <= 0) return pages;
5552
5553                const start = Math.max(1, current - 2);
5554                const end = Math.min(total, current + 2);
5555
5556                if (start > 1) pages.push(1);
5557                if (start > 2) pages.push(-1);
5558
5559                for (let i = start; i <= end; i++) pages.push(i);
5560
5561                if (end < total - 1) pages.push(-1);
5562                if (end < total) pages.push(total);
5563
5564                return pages;
5565            },
5566            cacheQuantities() {
5567                this.quantityCache = {};
5568                this.products.forEach((product) => {
5569                    this.quantityCache[product.id] = product.quantity;
5570                });
5571            },
5572            editUrl(product) {
5573                if (this.isAdmin) {
5574                    return `/admin/products/form/${product.id}?businessId=${product.businessId}`;
5575                }
5576                return `/admin/products/form/${product.id}`;
5577            },
5578            statusLabel(product) {
5579                if (!product.active) {
5580                    return "Draft";
5581                }
5582                if (product.status === "Listed") {
5583                    return "Listed";
5584                }
5585                if (product.status === "Banned") {
5586                    return "Banned";
5587                }
5588                return "Unlisted";
5589            },
5590            statusDotClass(product) {
5591                if (!product.active) {
5592                    return "is-draft";
5593                }
5594                if (product.status === "Listed") {
5595                    return "is-listed";
5596                }
5597                if (product.status === "Banned") {
5598                    return "is-banned";
5599                }
5600                return "is-unlisted";
5601            },
5602            toggleRowSelection(id) {
5603                if (this.selectedIds.includes(id)) {
5604                    this.selectedIds = this.selectedIds.filter((value) => value !== id);
5605                } else {
5606                    this.selectedIds = [...this.selectedIds, id];
5607                }
5608                this.syncSelectAllState();
5609            },
5610            toggleSelectAll(checked) {
5611                if (checked) {
5612                    this.selectedIds = this.products.map((product) => product.id);
5613                } else {
5614                    this.selectedIds = [];
5615                }
5616                this.syncSelectAllState();
5617            },
5618            isSelected(id) {
5619                return this.selectedIds.includes(id);
5620            },
5621            syncSelectAllState() {
5622                if (!this.$refs?.selectAll) {
5623                    return;
5624                }
5625                const total = this.products.length;
5626                const selected = this.selectedIds.length;
5627                this.$refs.selectAll.checked = total > 0 && selected === total;
5628                this.$refs.selectAll.indeterminate = selected > 0 && selected < total;
5629            },
5630            handleSearch(value) {
5631                this.searchTerm = value;
5632                this.currentPage = 1;
5633                this.updateUrl();
5634                this.refreshListings();
5635            },
5636            clearSearch() {
5637                this.searchTerm = "";
5638                this.currentPage = 1;
5639                this.updateUrl();
5640                this.refreshListings();
5641            },
5642            togglePendingHandmadeApproval(checked) {
5643                this.pendingHandmadeApproval = checked;
5644                this.currentPage = 1;
5645                this.updateUrl();
5646                this.refreshListings();
5647            },
5648            changePageSize(newPageSize) {
5649                const normalizedPageSize = normalizePageSize(newPageSize);
5650                this.pageSize = normalizedPageSize;
5651                this.currentPage = 1;
5652                this.updateUrl();
5653                this.refreshListings();
5654            },
5655            changePage(page) {
5656                if (page < 1 || page > this.totalPages || page === this.currentPage) return;
5657                this.currentPage = page;
5658                this.updateUrl();
5659                this.refreshListings();
5660                window.scrollTo({ top: 0, behavior: "smooth" });
5661            },
5662            updateUrl() {
5663                const url = new URL(window.location.href);
5664                if (this.searchTerm && this.searchTerm.trim().length > 0) {
5665                    url.searchParams.set("searchTerm", this.searchTerm.trim());
5666                } else {
5667                    url.searchParams.delete("searchTerm");
5668                }
5669
5670                if (this.currentPage > 1) {
5671                    url.searchParams.set("page", this.currentPage);
5672                } else {
5673                    url.searchParams.delete("page");
5674                }
5675
5676                if (this.pendingHandmadeApproval) {
5677                    url.searchParams.set("pendingHandmadeApproval", "true");
5678                } else {
5679                    url.searchParams.delete("pendingHandmadeApproval");
5680                }
5681
5682                if (this.pageSize !== DEFAULT_PAGE_SIZE) {
5683                    url.searchParams.set("pageSize", String(this.pageSize));
5684                } else {
5685                    url.searchParams.delete("pageSize");
5686                }
5687                history.pushState({}, "", url.toString());
5688            },
5689            getApiHeaders() {
5690                const headers = { "Content-Type": "application/json" };
5691                const token = window.pageConfig?.antiforgeryToken;
5692                const headerName = window.pageConfig?.antiforgeryHeaderName;
5693                if (token && headerName) {
5694                    headers[headerName] = token;
5695                }
5696                return headers;
5697            },
5698            async request(url, options = {}) {
5699                const response = await fetch(url, {
5700                    credentials: "same-origin",
5701                    ...options,
5702                });
5703
5704                if (!response.ok) {
5705                    const message = await formatRequestError(response);
5706                    throw new Error(message);
5707                }
5708
5709                if (response.status === 204) {
5710                    return null;
5711                }
5712
5713                return response.json();
5714            },
5715            async getListings() {
5716                const url = new URL("/api/products/listings", window.location.origin);
5717                if (this.searchTerm) {
5718                    url.searchParams.set("searchTerm", this.searchTerm);
5719                }
5720                if (this.pendingHandmadeApproval) {
5721                    url.searchParams.set("pendingHandmadeApproval", "true");
5722                }
5723                url.searchParams.set("page", String(this.currentPage));
5724                url.searchParams.set("pageSize", String(normalizePageSize(this.pageSize)));
5725
5726                return this.request(url.toString(), {
5727                    method: "GET",
5728                    headers: this.getApiHeaders(),
5729                });
5730            },
5731            async refreshListings() {
5732                this.loading = true;
5733                try {
5734                    const data = await this.getListings();
5735                    if (data?.products) {
5736                        this.products = data.products;
5737                        this.totalPages = data.totalPages ?? 0;
5738                        if (this.totalPages > 0 && this.currentPage > this.totalPages) {
5739                            this.currentPage = this.totalPages;
5740                            this.updateUrl();
5741                            return this.refreshListings();
5742                        }
5743                        this.cacheQuantities();
5744                        this.selectedIds = [];
5745                        this.$nextTick(() => this.syncSelectAllState());
5746                    }
5747                } catch (error) {
5748                    console.error(error);
5749                    this.showToast(error.message || "Unable to refresh listings", "error");
5750                } finally {
5751                    this.loading = false;
5752                }
5753            },
5754            async connectStripe() {
5755                try {
5756                    document.body.style.cursor = "wait";
5757                    const data = await this.request("/api/stripe/connect-account-link", {
5758                        method: "POST",
5759                        headers: this.getApiHeaders(),
5760                        body: JSON.stringify({}),
5761                    });
5762                    if (data?.accountLinkUrl) {
5763                        window.location.assign(data.accountLinkUrl);
5764                    }
5765                } catch (error) {
5766                    console.error(error);
5767                    this.showToast(error.message || "Unable to connect Stripe", "error");
5768                } finally {
5769                    document.body.style.cursor = "";
5770                }
5771            },
5772            async activateListing(id) {
5773                try {
5774                    await this.request(`/api/products/${id}/activate`, {
5775                        method: "PUT",
5776                        headers: this.getApiHeaders(),
5777                        body: JSON.stringify({}),
5778                    });
5779                    await this.refreshListings();
5780                    this.showToast("Listing activated", "success");
5781                } catch (error) {
5782                    console.error(error);
5783                    this.showToast(error.message || "Unable to activate listing", "error");
5784                }
5785            },
5786            async deactivateListing(id) {
5787                try {
5788                    await this.request(`/api/products/${id}/deactivate`, {
5789                        method: "PUT",
5790                        headers: this.getApiHeaders(),
5791                        body: JSON.stringify({}),
5792                    });
5793                    await this.refreshListings();
5794                    this.showToast("Listing deactivated", "success");
5795                } catch (error) {
5796                    console.error(error);
5797                    this.showToast(error.message || "Unable to deactivate listing", "error");
5798                }
5799            },
5800            async bulkActivateListings() {
5801                if (this.selectedIds.length === 0) {
5802                    return;
5803                }
5804
5805                this.bulkWorking = true;
5806                try {
5807                    await this.request("/api/products/bulk-activate", {
5808                        method: "POST",
5809                        headers: this.getApiHeaders(),
5810                        body: JSON.stringify({ productIds: this.selectedIds }),
5811                    });
5812                    await this.refreshListings();
5813                    this.showToast("Selected listings activated", "success");
5814                } catch (error) {
5815                    console.error(error);
5816                    this.showToast(error.message || "Unable to activate listings", "error");
5817                } finally {
5818                    this.bulkWorking = false;
5819                }
5820            },
5821            async bulkDeactivateListings() {
5822                if (this.selectedIds.length === 0) {
5823                    return;
5824                }
5825
5826                this.bulkWorking = true;
5827                try {
5828                    await this.request("/api/products/bulk-deactivate", {
5829                        method: "POST",
5830                        headers: this.getApiHeaders(),
5831                        body: JSON.stringify({ productIds: this.selectedIds }),
5832                    });
5833                    await this.refreshListings();
5834                    this.showToast("Selected listings deactivated", "success");
5835                } catch (error) {
5836                    console.error(error);
5837                    this.showToast(error.message || "Unable to deactivate listings", "error");
5838                } finally {
5839                    this.bulkWorking = false;
5840                }
5841            },
5842            async duplicateListing(id) {
5843                try {
5844                    const data = await this.request(`/api/products/${id}/duplicate`, {
5845                        method: "POST",
5846                        headers: this.getApiHeaders(),
5847                        body: JSON.stringify({}),
5848                    });
5849                    await this.refreshListings();
5850                    if (data?.productId) {
5851                        this.showToast("Listing duplicated", "success");
5852                    }
5853                } catch (error) {
5854                    console.error(error);
5855                    this.showToast(error.message || "Unable to duplicate listing", "error");
5856                }
5857            },
5858            async approveHandmade(id) {
5859                try {
5860                    await this.request(`/api/products/${id}/approve-handmade`, {
5861                        method: "PUT",
5862                        headers: this.getApiHeaders(),
5863                        body: JSON.stringify({}),
5864                    });
5865                    await this.refreshListings();
5866                    this.showToast("Handmade listing approved", "success");
5867                } catch (error) {
5868                    console.error(error);
5869                    this.showToast(error.message || "Unable to approve handmade listing", "error");
5870                }
5871            },
5872            async approveFineArt(id) {
5873                try {
5874                    await this.request(`/api/products/${id}/approve-fineart`, {
5875                        method: "PUT",
5876                        headers: this.getApiHeaders(),
5877                        body: JSON.stringify({}),
5878                    });
5879                    await this.refreshListings();
5880                    this.showToast("Fine art listing approved", "success");
5881                } catch (error) {
5882                    console.error(error);
5883                    this.showToast(error.message || "Unable to approve fine art listing", "error");
5884                }
5885            },
5886            confirmDelete(id) {
5887                if (window.confirm("Are you sure you want to delete this listing?")) {
5888                    this.deleteProduct(id);
5889                }
5890            },
5891            async deleteProduct(id) {
5892                try {
5893                    await this.request(`/api/products/${id}`, {
5894                        method: "DELETE",
5895                        headers: this.getApiHeaders(),
5896                    });
5897                    await this.refreshListings();
5898                    this.showToast("Listing deleted", "success");
5899                } catch (error) {
5900                    console.error(error);
5901                    this.showToast(error.message || "Unable to delete listing", "error");
5902                }
5903            },
5904            queueQuantityUpdate(id, value, input) {
5905                const parsed = Number.parseInt(value, 10);
5906                if (Number.isNaN(parsed)) {
5907                    if (input) {
5908                        const fallback = this.quantityCache[id] ?? 0;
5909                        input.value = fallback;
5910                    }
5911                    return;
5912                }
5913
5914                const clamped = Math.min(Math.max(parsed, 0), 9999);
5915                if (input) {
5916                    input.value = clamped;
5917                }
5918
5919                this.updateQuantity(id, clamped, input);
5920            },
5921            async updateQuantity(id, quantity, input) {
5922                const previous = this.quantityCache[id];
5923
5924                try {
5925                    await this.request(`/api/products/${id}/quantity`, {
5926                        method: "PUT",
5927                        headers: this.getApiHeaders(),
5928                        body: JSON.stringify({ quantity }),
5929                    });
5930                    this.quantityCache[id] = quantity;
5931                    const product = this.products.find((item) => item.id === id);
5932                    if (product) {
5933                        product.quantity = quantity;
5934                    }
5935                    this.showToast("Quantity updated", "success");
5936                } catch (error) {
5937                    console.error(error);
5938                    this.showToast(error.message || "Unable to update quantity", "error");
5939                    if (input) {
5940                        input.value = previous;
5941                    }
5942                }
5943            },
5944            showToast(message, type = "info") {
5945                const notice = { id: crypto.randomUUID(), type, message };
5946                this.notifications.unshift(notice);
5947                setTimeout(() => {
5948                    this.notifications = this.notifications.filter((n) => n.id !== notice.id);
5949                }, type === "warning" ? 10000 : 5000);
5950            },
5951            // Flash messages persisted by other admin pages (e.g. the product form
5952            // stores a warning before redirecting here).
5953            showPendingFlash() {
5954                let flash = null;
5955                try {
5956                    const raw = sessionStorage.getItem("adminProductsFlash");
5957                    sessionStorage.removeItem("adminProductsFlash");
5958                    flash = raw ? JSON.parse(raw) : null;
5959                } catch {
5960                    // Ignore storage/parse errors; the flash is best-effort.
5961                }
5962                if (flash?.message) {
5963                    this.showToast(flash.message, flash.type || "info");
5964                }
5965            },
5966        };
5967    };
5968})();
5969
5970// ========================================================================
5971// Admin market sales (formerly admin-market-sales.js)
5972// ========================================================================
5973
5974// Reads window.pageConfig ({ sales, hasStripeCustomerId, antiforgery… }), written by
5975// Components/Pages/Admin/MarketSales/Index.razor.
5976const adminMarketSalesPage = (() => {
5977    const formatRequestError = async (response) => {
5978        let message = "Request failed";
5979        try {
5980            const data = await response.json();
5981            if (data?.message) message = data.message;
5982        } catch { /* ignore */ }
5983        return message;
5984    };
5985
5986    return function adminMarketSalesPage() {
5987        return {
5988            sales: window.pageConfig?.sales ?? [],
5989            notifications: [],
5990            showDeleteModal: false,
5991            deleteTargetId: null,
5992            deleting: false,
5993            billingLoading: false,
5994
5995            getApiHeaders() {
5996                const headers = { "Content-Type": "application/json" };
5997                const token = window.pageConfig?.antiforgeryToken;
5998                const headerName = window.pageConfig?.antiforgeryHeaderName;
5999                if (token && headerName) headers[headerName] = token;
6000                return headers;
6001            },
6002
6003            async request(url, options = {}) {
6004                const response = await fetch(url, { credentials: "same-origin", ...options });
6005                if (!response.ok) {
6006                    const message = await formatRequestError(response);
6007                    throw new Error(message);
6008                }
6009                if (response.status === 204) return null;
6010                return response.json();
6011            },
6012
6013            async openBillingPortal() {
6014                if (this.billingLoading) return;
6015
6016                if (!window.pageConfig?.hasStripeCustomerId) {
6017                    this.notify("error", "You do not have a billing account set up.");
6018                    return;
6019                }
6020
6021                this.billingLoading = true;
6022
6023                try {
6024                    const data = await this.request("/api/subscriptions/stripe-portal-session", {
6025                        method: "POST",
6026                        headers: this.getApiHeaders(),
6027                        body: JSON.stringify({
6028                            returnUrl: window.location.href,
6029                        }),
6030                    });
6031
6032                    if (data?.portalSessionUrl) {
6033                        window.location.assign(data.portalSessionUrl);
6034                        return;
6035                    }
6036
6037                    throw new Error("No portal URL returned.");
6038                } catch (error) {
6039                    console.error(error);
6040                    this.notify("error", error.message || "Unable to open subscription management.");
6041                } finally {
6042                    this.billingLoading = false;
6043                }
6044            },
6045
6046            formatDate(dateStr) {
6047                if (!dateStr) return "";
6048                return new Date(dateStr).toLocaleDateString(undefined, {
6049                    year: "numeric",
6050                    month: "short",
6051                    day: "numeric",
6052                });
6053            },
6054
6055            confirmDelete(id) {
6056                this.deleteTargetId = id;
6057                this.showDeleteModal = true;
6058            },
6059
6060            async deleteSale() {
6061                if (!this.deleteTargetId) return;
6062                this.deleting = true;
6063                try {
6064                    await this.request(`/api/admin/market-sales/${this.deleteTargetId}`, {
6065                        method: "DELETE",
6066                        headers: this.getApiHeaders(),
6067                    });
6068                    this.sales = this.sales.filter(s => s.id !== this.deleteTargetId);
6069                    this.notify("success", "Sale deleted successfully.");
6070                } catch (error) {
6071                    console.error(error);
6072                    this.notify("error", error.message || "Unable to delete sale.");
6073                } finally {
6074                    this.deleting = false;
6075                    this.showDeleteModal = false;
6076                    this.deleteTargetId = null;
6077                }
6078            },
6079
6080            notify(type, message) {
6081                const id = Date.now();
6082                this.notifications.unshift({ id, type, message });
6083                setTimeout(() => this.removeNotification(id), 5000);
6084            },
6085
6086            removeNotification(id) {
6087                this.notifications = this.notifications.filter(n => n.id !== id);
6088            },
6089        };
6090    };
6091})();
6092
6093// ========================================================================
6094// Admin market sale form (formerly admin-market-sales-form.js)
6095// ========================================================================
6096
6097// Reads window.pageConfig ({ sale, products, antiforgery… }; empty on the upgrade card), written by
6098// Components/Pages/Admin/MarketSales/Form.razor. Needs window.AdminFormUtils (admin-forms.js).
6099const marketSaleFormPage = (() => {
6100    const config = () => window.pageConfig || {};
6101
6102    const toLocalDatetime = (utcStr) => {
6103        if (!utcStr) return "";
6104        const d = new Date(utcStr);
6105        const offset = d.getTimezoneOffset();
6106        const local = new Date(d.getTime() - offset * 60000);
6107        return local.toISOString().slice(0, 16);
6108    };
6109
6110    const toUtcIso = (localStr) => {
6111        if (!localStr) return "";
6112        return new Date(localStr).toISOString();
6113    };
6114
6115    const normalizeDiscountType = (value, fallback = 1) => {
6116        if (typeof value === "number" && Number.isFinite(value)) return value;
6117        if (typeof value === "string") {
6118            const trimmed = value.trim();
6119            const numeric = Number.parseInt(trimmed, 10);
6120            if (Number.isFinite(numeric)) return numeric;
6121
6122            if (trimmed === "PercentageOff") return 1;
6123            if (trimmed === "FixedAmount") return 2;
6124        }
6125        return fallback;
6126    };
6127
6128    const formatRequestError = async (response) => {
6129        let message = "Request failed";
6130        try {
6131            const data = await response.json();
6132            if (data?.message) message = data.message;
6133            else if (data?.errors) {
6134                const mapped = window.AdminFormUtils?.mapServerErrors(data.errors) || {};
6135                const firstKey = Object.keys(mapped)[0];
6136                if (firstKey) message = mapped[firstKey];
6137            }
6138        } catch { /* ignore */ }
6139        return message;
6140    };
6141
6142    return function marketSaleFormPage() {
6143        const saleData = config().sale;
6144        const isEdit = !!saleData;
6145        const normalizedProducts = (config().products || []).map((product) => {
6146            if (!product?.override) return product;
6147            return {
6148                ...product,
6149                override: {
6150                    ...product.override,
6151                    overrideDiscountType: normalizeDiscountType(product.override.overrideDiscountType, null),
6152                },
6153            };
6154        });
6155
6156        return {
6157            saleData,
6158            isEditMode: isEdit,
6159            activeTab: "details",
6160            loading: false,
6161            deleting: false,
6162            notifications: [],
6163            showDeleteModal: false,
6164
6165            // Form state
6166            form: {
6167                name: saleData?.name || "",
6168                description: saleData?.description || "",
6169                discountType: normalizeDiscountType(saleData?.discountType),
6170                discountValue: saleData?.discountValue || "",
6171                startDate: saleData ? toLocalDatetime(saleData.startDate) : "",
6172                endDate: saleData ? toLocalDatetime(saleData.endDate) : "",
6173                isActive: saleData?.isActive ?? true,
6174                isFeatured: saleData?.isFeatured ?? false,
6175            },
6176            errors: {},
6177
6178            // Product overrides
6179            products: normalizedProducts,
6180            filteredProducts: [],
6181            productSearch: "",
6182
6183            // Override editor modal
6184            showOverrideModal: false,
6185            overrideTargetProduct: null,
6186            overrideForm: { type: 1, value: "" },
6187            overrideSaving: false,
6188            overrideError: null,
6189
6190            init() {
6191                this.filteredProducts = [...this.products];
6192            },
6193
6194            // ── API helpers ──────────────────────────────
6195
6196            getApiHeaders() {
6197                const headers = { "Content-Type": "application/json" };
6198                const c = config();
6199                if (c.antiforgeryToken && c.antiforgeryHeaderName) {
6200                    headers[c.antiforgeryHeaderName] = c.antiforgeryToken;
6201                }
6202                return headers;
6203            },
6204
6205            async request(url, options = {}) {
6206                const response = await fetch(url, { credentials: "same-origin", ...options });
6207                if (!response.ok) {
6208                    if (response.status === 400) {
6209                        const data = await response.json().catch(() => ({}));
6210                        if (data.errors) {
6211                            this.errors = window.AdminFormUtils?.mapServerErrors(data.errors) || {};
6212                            throw new Error("Please fix the errors before saving.");
6213                        }
6214                        if (data.message) throw new Error(data.message);
6215                    }
6216                    const msg = await formatRequestError(response);
6217                    throw new Error(msg);
6218                }
6219                if (response.status === 204) return null;
6220
6221                const contentType = response.headers.get("content-type") || "";
6222                const contentLength = response.headers.get("content-length");
6223                const hasJsonBody = contentType.includes("application/json")
6224                    && contentLength !== "0";
6225
6226                if (!hasJsonBody) return null;
6227                return response.json();
6228            },
6229
6230            // ── Validation ───────────────────────────────
6231
6232            validateField(field) {
6233                const r = window.AdminFormUtils?.rules;
6234                if (!r) return true;
6235                let error = null;
6236
6237                switch (field) {
6238                    case "name":
6239                        error = r.required(this.form.name, "Sale name") ||
6240                                r.maxLength(this.form.name, 100, "Sale name");
6241                        break;
6242                    case "description":
6243                        error = r.maxLength(this.form.description, 500, "Description");
6244                        break;
6245                    case "discountValue":
6246                        if (!this.form.discountValue || this.form.discountValue <= 0) {
6247                            error = "Discount value must be greater than 0.";
6248                        } else if (this.form.discountType === 1 && this.form.discountValue > 100) {
6249                            error = "Percentage discount cannot exceed 100%.";
6250                        }
6251                        break;
6252                    case "startDate":
6253                        if (!this.form.startDate) error = "Start date is required.";
6254                        break;
6255                    case "endDate":
6256                        if (!this.form.endDate) {
6257                            error = "End date is required.";
6258                        } else if (this.form.startDate && new Date(this.form.endDate) <= new Date(this.form.startDate)) {
6259                            error = "End date must be after start date.";
6260                        }
6261                        break;
6262                }
6263
6264                if (error) this.errors[field] = error;
6265                else delete this.errors[field];
6266                return !error;
6267            },
6268
6269            validateAll() {
6270                const fields = ["name", "description", "discountValue", "startDate", "endDate"];
6271                let valid = true;
6272                fields.forEach(f => { if (!this.validateField(f)) valid = false; });
6273                return valid;
6274            },
6275
6276            // ── Form submit ──────────────────────────────
6277
6278            async submitForm() {
6279                if (!this.validateAll()) {
6280                    this.notify("error", "Please fix the errors before saving.");
6281                    return;
6282                }
6283
6284                this.loading = true;
6285                try {
6286                    const payload = {
6287                        name: this.form.name.trim(),
6288                        description: this.form.description?.trim() || null,
6289                        discountType: this.form.discountType,
6290                        discountValue: parseFloat(this.form.discountValue),
6291                        startDate: toUtcIso(this.form.startDate),
6292                        endDate: toUtcIso(this.form.endDate),
6293                        isActive: this.form.isActive,
6294                        isFeatured: this.form.isFeatured,
6295                    };
6296
6297                    if (this.isEditMode) {
6298                        await this.request(`/api/admin/market-sales/${this.saleData.id}`, {
6299                            method: "PUT",
6300                            headers: this.getApiHeaders(),
6301                            body: JSON.stringify(payload),
6302                        });
6303                        this.notify("success", "Sale updated successfully!");
6304                    } else {
6305                        const data = await this.request("/api/admin/market-sales", {
6306                            method: "POST",
6307                            headers: this.getApiHeaders(),
6308                            body: JSON.stringify(payload),
6309                        });
6310                        this.notify("success", "Sale created successfully!");
6311                        // Redirect to edit form so user can manage product overrides
6312                        if (data?.id) {
6313                            setTimeout(() => {
6314                                navigateTo(`/admin/market-sales/form/${data.id}`);
6315                            }, 800);
6316                        }
6317                    }
6318                } catch (error) {
6319                    this.notify("error", error.message);
6320                } finally {
6321                    this.loading = false;
6322                }
6323            },
6324
6325            // ── Delete ───────────────────────────────────
6326
6327            async deleteSale() {
6328                if (!this.isEditMode) return;
6329                this.deleting = true;
6330                try {
6331                    await this.request(`/api/admin/market-sales/${this.saleData.id}`, {
6332                        method: "DELETE",
6333                        headers: this.getApiHeaders(),
6334                    });
6335                    this.notify("success", "Sale deleted.");
6336                    setTimeout(() => { navigateTo("/admin/market-sales"); }, 800);
6337                } catch (error) {
6338                    this.notify("error", error.message);
6339                } finally {
6340                    this.deleting = false;
6341                    this.showDeleteModal = false;
6342                }
6343            },
6344
6345            // ── Product overrides ────────────────────────
6346
6347            filterProducts() {
6348                const term = this.productSearch.toLowerCase().trim();
6349                if (!term) {
6350                    this.filteredProducts = [...this.products];
6351                    return;
6352                }
6353                this.filteredProducts = this.products.filter(p =>
6354                    p.name.toLowerCase().includes(term) ||
6355                    (p.sku && p.sku.toLowerCase().includes(term))
6356                );
6357            },
6358
6359            getProductOverride(product) {
6360                return product.override || null;
6361            },
6362
6363            isProductExcluded(product) {
6364                return this.getProductOverride(product)?.isExcluded === true;
6365            },
6366
6367            hasOverrideDiscount(product) {
6368                const o = this.getProductOverride(product);
6369                return o && o.overrideDiscountType != null && o.overrideDiscountValue != null && !o.isExcluded;
6370            },
6371
6372            getOverrideLabel(product) {
6373                const o = this.getProductOverride(product);
6374                if (!o || o.overrideDiscountType == null) return "";
6375                return o.overrideDiscountType === 1
6376                    ? `${o.overrideDiscountValue}% off`
6377                    : `$${o.overrideDiscountValue} off`;
6378            },
6379
6380            calculateSalePrice(product) {
6381                const o = this.getProductOverride(product);
6382                let type = this.form.discountType;
6383                let value = parseFloat(this.form.discountValue) || 0;
6384
6385                if (o && o.overrideDiscountType != null && o.overrideDiscountValue != null && !o.isExcluded) {
6386                    type = o.overrideDiscountType;
6387                    value = o.overrideDiscountValue;
6388                }
6389
6390                if (type === 1) {
6391                    return Math.max(0, product.price - product.price * value / 100);
6392                }
6393                return Math.max(0, product.price - value);
6394            },
6395
6396            async toggleProductExclusion(product) {
6397                const currentlyExcluded = this.isProductExcluded(product);
6398                const newExcluded = !currentlyExcluded;
6399
6400                try {
6401                    await this.request(
6402                        `/api/admin/market-sales/${this.saleData.id}/products/${product.id}/override`,
6403                        {
6404                            method: "PUT",
6405                            headers: this.getApiHeaders(),
6406                            body: JSON.stringify({
6407                                isExcluded: newExcluded,
6408                                overrideDiscountType: null,
6409                                overrideDiscountValue: null,
6410                            }),
6411                        }
6412                    );
6413
6414                    // Update local state
6415                    if (!product.override) {
6416                        product.override = { isExcluded: newExcluded, overrideDiscountType: null, overrideDiscountValue: null };
6417                    } else {
6418                        product.override.isExcluded = newExcluded;
6419                        if (newExcluded) {
6420                            product.override.overrideDiscountType = null;
6421                            product.override.overrideDiscountValue = null;
6422                        }
6423                    }
6424
6425                    this.notify("success", newExcluded ? "Product excluded from sale." : "Product included in sale.");
6426                } catch (error) {
6427                    this.notify("error", error.message);
6428                }
6429            },
6430
6431            showOverrideEditor(product) {
6432                this.overrideTargetProduct = product;
6433                const o = this.getProductOverride(product);
6434                this.overrideForm.type = o?.overrideDiscountType || 1;
6435                this.overrideForm.value = o?.overrideDiscountValue || "";
6436                this.overrideError = null;
6437                this.showOverrideModal = true;
6438            },
6439
6440            async saveOverrideDiscount() {
6441                this.overrideError = null;
6442                if (!this.overrideTargetProduct || !this.overrideForm.value || this.overrideForm.value <= 0) {
6443                    this.overrideError = "Please enter a valid discount value.";
6444                    return;
6445                }
6446
6447                this.overrideSaving = true;
6448                const product = this.overrideTargetProduct;
6449
6450                try {
6451                    await this.request(
6452                        `/api/admin/market-sales/${this.saleData.id}/products/${product.id}/override`,
6453                        {
6454                            method: "PUT",
6455                            headers: this.getApiHeaders(),
6456                            body: JSON.stringify({
6457                                isExcluded: false,
6458                                overrideDiscountType: this.overrideForm.type,
6459                                overrideDiscountValue: parseFloat(this.overrideForm.value),
6460                            }),
6461                        }
6462                    );
6463
6464                    // Update local state
6465                    if (!product.override) {
6466                        product.override = {};
6467                    }
6468                    product.override.isExcluded = false;
6469                    product.override.overrideDiscountType = this.overrideForm.type;
6470                    product.override.overrideDiscountValue = parseFloat(this.overrideForm.value);
6471
6472                    this.showOverrideModal = false;
6473                    this.notify("success", "Custom discount saved.");
6474                } catch (error) {
6475                    this.overrideError = error.message;
6476                } finally {
6477                    this.overrideSaving = false;
6478                }
6479            },
6480
6481            async removeOverrideDiscount(product) {
6482                try {
6483                    await this.request(
6484                        `/api/admin/market-sales/${this.saleData.id}/products/${product.id}/override`,
6485                        {
6486                            method: "PUT",
6487                            headers: this.getApiHeaders(),
6488                            body: JSON.stringify({
6489                                isExcluded: false,
6490                                overrideDiscountType: null,
6491                                overrideDiscountValue: null,
6492                            }),
6493                        }
6494                    );
6495
6496                    if (product.override) {
6497                        product.override.overrideDiscountType = null;
6498                        product.override.overrideDiscountValue = null;
6499                    }
6500
6501                    this.notify("success", "Custom discount removed.");
6502                } catch (error) {
6503                    this.notify("error", error.message);
6504                }
6505            },
6506
6507            // ── Helpers ──────────────────────────────────
6508
6509            getFormattedDiscount() {
6510                const v = parseFloat(this.form.discountValue);
6511                if (!v || v <= 0) return "—";
6512                return this.form.discountType === 1 ? `${v}% off` : `$${v.toFixed(2)} off`;
6513            },
6514
6515            formatDisplayDate(dateStr) {
6516                if (!dateStr) return "";
6517                return new Date(dateStr).toLocaleDateString(undefined, {
6518                    year: "numeric",
6519                    month: "short",
6520                    day: "numeric",
6521                    hour: "numeric",
6522                    minute: "2-digit",
6523                });
6524            },
6525
6526            getDurationText() {
6527                if (!this.form.startDate || !this.form.endDate) return "";
6528                const start = new Date(this.form.startDate);
6529                const end = new Date(this.form.endDate);
6530                const diffMs = end - start;
6531                if (diffMs <= 0) return "";
6532                const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
6533                const hours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
6534                if (days > 0) return `Duration: ${days} day${days === 1 ? "" : "s"}${hours > 0 ? `, ${hours}h` : ""}`;
6535                return `Duration: ${hours} hour${hours === 1 ? "" : "s"}`;
6536            },
6537
6538            // ── Notifications ────────────────────────────
6539
6540            notify(type, message) {
6541                const id = Date.now();
6542                this.notifications.unshift({ id, type, message });
6543                setTimeout(() => this.removeNotification(id), 5000);
6544            },
6545
6546            removeNotification(id) {
6547                this.notifications = this.notifications.filter(n => n.id !== id);
6548            },
6549        };
6550    };
6551})();
6552
6553// ========================================================================
6554// Admin messages (formerly inline in Pages/Admin/Messages/Index.cshtml)
6555// ========================================================================
6556
6557// Reads window.pageConfig ({ searchTerm }), written by Components/Pages/Admin/Messages/Index.razor.
6558function adminMessagesPage() {
6559    return {
6560        searchTerm: window.pageConfig?.searchTerm || '',
6561
6562        filterMessages() {
6563            const url = new URL(window.location);
6564            if (this.searchTerm) {
6565                url.searchParams.set('searchTerm', this.searchTerm);
6566            } else {
6567                url.searchParams.delete('searchTerm');
6568            }
6569            navigateTo(url.toString());
6570        }
6571    };
6572}
6573
6574// ========================================================================
6575// Admin customers (formerly inline in Pages/Admin/Customers/Index.cshtml)
6576// ========================================================================
6577
6578// Reads window.pageConfig ({ searchTerm, registrationSource, isAdmin }), written by
6579// Components/Pages/Admin/Customers/Index.razor.
6580function adminCustomersPage() {
6581    const config = window.pageConfig || {};
6582
6583    return {
6584        isAdmin: !!config.isAdmin,
6585        searchTerm: config.searchTerm || '',
6586        registrationSource: (config.registrationSource || '').toLowerCase(),
6587
6588        search() {
6589            this.navigateWithFilters();
6590        },
6591
6592        setRegistrationSource(source) {
6593            this.registrationSource = (source || '').toLowerCase();
6594            this.navigateWithFilters();
6595        },
6596
6597        navigateWithFilters() {
6598            const nextUrl = this.buildSearchUrl();
6599            if (window.location.href === nextUrl) {
6600                return;
6601            }
6602
6603            navigateTo(nextUrl, { replace: true });
6604        },
6605
6606        buildSearchUrl() {
6607            const url = new URL(window.location.href);
6608
6609            for (const key of Array.from(url.searchParams.keys())) {
6610                if (key.toLowerCase() === 'registrationsource') {
6611                    url.searchParams.delete(key);
6612                }
6613            }
6614
6615            const trimmedSearchTerm = (this.searchTerm || '').trim();
6616            if (trimmedSearchTerm) {
6617                url.searchParams.set('searchTerm', trimmedSearchTerm);
6618            } else {
6619                url.searchParams.delete('searchTerm');
6620            }
6621
6622            if (this.isAdmin && this.registrationSource) {
6623                url.searchParams.set('registrationSource', this.registrationSource);
6624            }
6625
6626            return url.toString();
6627        }
6628    };
6629}
6630
6631// ========================================================================
6632// Admin markets (formerly the marketsList global in Pages/Admin/Markets/Index.cshtml)
6633// ========================================================================
6634
6635// Reads window.pageConfig ({ searchTerm, registrationSource, antiforgeryToken,
6636// antiforgeryHeaderName }), written by Components/Pages/Admin/Markets/Index.razor.
6637function adminMarketsPage() {
6638    const config = window.pageConfig || {};
6639    const antiforgeryHeaderName = config.antiforgeryHeaderName || 'RequestVerificationToken';
6640    const antiforgeryToken = config.antiforgeryToken || '';
6641
6642    return {
6643        searchTerm: config.searchTerm || '',
6644        registrationSource: (config.registrationSource || '').toLowerCase(),
6645        updating: null,
6646        toasts: [],
6647
6648        showToast(message, type = 'success') {
6649            const id = Date.now();
6650            this.toasts.push({ id, message, type, visible: true });
6651            setTimeout(() => this.removeToast(id), 5000);
6652        },
6653
6654        removeToast(id) {
6655            const toast = this.toasts.find(t => t.id === id);
6656            if (toast) {
6657                toast.visible = false;
6658                setTimeout(() => {
6659                    this.toasts = this.toasts.filter(t => t.id !== id);
6660                }, 300);
6661            }
6662        },
6663
6664        search() {
6665            this.navigateWithFilters();
6666        },
6667
6668        setRegistrationSource(source) {
6669            this.registrationSource = (source || '').toLowerCase();
6670            this.navigateWithFilters();
6671        },
6672
6673        navigateWithFilters() {
6674            const nextUrl = this.buildSearchUrl();
6675            if (window.location.href === nextUrl) {
6676                return;
6677            }
6678
6679            navigateTo(nextUrl, { replace: true });
6680        },
6681
6682        buildSearchUrl() {
6683            const url = new URL(window.location.href);
6684            // Clear any existing key variants so "All" always resets the filter.
6685            for (const key of Array.from(url.searchParams.keys())) {
6686                if (key.toLowerCase() === 'registrationsource') {
6687                    url.searchParams.delete(key);
6688                }
6689            }
6690
6691            const trimmedSearchTerm = (this.searchTerm || '').trim();
6692            if (trimmedSearchTerm) {
6693                url.searchParams.set('searchTerm', trimmedSearchTerm);
6694            } else {
6695                url.searchParams.delete('searchTerm');
6696            }
6697
6698            if (this.registrationSource) {
6699                url.searchParams.set('registrationSource', this.registrationSource);
6700            }
6701
6702            return url.toString();
6703        },
6704
6705        async toggleAutoApprove(businessId, newValue) {
6706            this.updating = businessId;
6707            try {
6708                const response = await fetch(`/api/admin/markets/businesses/${businessId}/auto-approve`, {
6709                    method: 'PUT',
6710                    headers: {
6711                        'Content-Type': 'application/json',
6712                        [antiforgeryHeaderName]: antiforgeryToken
6713                    },
6714                    body: JSON.stringify({ businessId, autoApproveHandmadeProducts: newValue })
6715                });
6716
6717                if (response.ok) {
6718                    this.showToast('Auto-approve setting updated successfully');
6719                    return true;
6720                } else {
6721                    this.showToast('Failed to update auto-approve setting', 'error');
6722                    return false;
6723                }
6724            } catch (e) {
6725                console.error(e);
6726                this.showToast('An error occurred', 'error');
6727                return false;
6728            } finally {
6729                this.updating = null;
6730            }
6731        },
6732
6733        async updateRetailFee(businessId, feePercentInput, revertValue, commitValue) {
6734            const normalizedFeePercent = String(feePercentInput ?? '').trim();
6735            if (normalizedFeePercent === '') {
6736                this.showToast('Retail fee is required. Enter 0 to set the fee to zero.', 'error');
6737                if (typeof revertValue === 'function') {
6738                    revertValue();
6739                }
6740                return false;
6741            }
6742
6743            const parsedPercent = Number(normalizedFeePercent);
6744            if (!Number.isFinite(parsedPercent)) {
6745                this.showToast('Retail fee must be a valid number.', 'error');
6746                if (typeof revertValue === 'function') {
6747                    revertValue();
6748                }
6749                return false;
6750            }
6751
6752            if (parsedPercent < 0 || parsedPercent > 15) {
6753                this.showToast('Retail fee must be between 0% and 15%.', 'error');
6754                if (typeof revertValue === 'function') {
6755                    revertValue();
6756                }
6757                return false;
6758            }
6759
6760            const normalizedForDisplay = parsedPercent.toFixed(2);
6761            const newRate = parsedPercent / 100;
6762            this.updating = businessId;
6763            try {
6764                const response = await fetch(`/api/admin/markets/businesses/${businessId}/retail-fee`, {
6765                    method: 'PUT',
6766                    headers: {
6767                        'Content-Type': 'application/json',
6768                        [antiforgeryHeaderName]: antiforgeryToken
6769                    },
6770                    body: JSON.stringify({ businessId, retailTransactionFeeRate: newRate })
6771                });
6772
6773                if (response.ok) {
6774                    if (typeof commitValue === 'function') {
6775                        commitValue(normalizedForDisplay);
6776                    }
6777                    this.showToast('Retail fee updated successfully');
6778                    return true;
6779                } else {
6780                    if (typeof revertValue === 'function') {
6781                        revertValue();
6782                    }
6783                    this.showToast('Failed to update retail fee', 'error');
6784                    return false;
6785                }
6786            } catch (e) {
6787                console.error(e);
6788                if (typeof revertValue === 'function') {
6789                    revertValue();
6790                }
6791                this.showToast('An error occurred', 'error');
6792                return false;
6793            } finally {
6794                this.updating = null;
6795            }
6796        }
6797    };
6798}
6799
6800// ========================================================================
6801// Admin unsubscribed sellers (formerly the unsubscribedSellers global in
6802// Pages/Admin/Markets/Unsubscribed.cshtml)
6803// ========================================================================
6804
6805// Reads window.pageConfig ({ searchTerm, cohort, includeVacationMode, sortBy, antiforgeryToken,
6806// antiforgeryHeaderName }), written by Components/Pages/Admin/Markets/Unsubscribed.razor.
6807function adminUnsubscribedSellersPage() {
6808    const config = window.pageConfig || {};
6809    const antiforgeryHeaderName = config.antiforgeryHeaderName || 'RequestVerificationToken';
6810    const antiforgeryToken = config.antiforgeryToken || '';
6811
6812    return {
6813        searchTerm: config.searchTerm || '',
6814        cohort: config.cohort || '',
6815        includeVacationMode: config.includeVacationMode !== false,
6816        sortBy: config.sortBy || 'most-listings',
6817        repairingBusinessId: null,
6818        repairResults: {},
6819        repairErrors: {},
6820
6821        async repairLapsedAccess(businessId) {
6822            const confirmed = window.confirm(
6823                'Remove stale seller roles from every user attached to this Lapsed business? Active and trialing subscriptions are protected.'
6824            );
6825            if (!confirmed) {
6826                return;
6827            }
6828
6829            this.repairingBusinessId = businessId;
6830            delete this.repairResults[businessId];
6831            delete this.repairErrors[businessId];
6832
6833            try {
6834                const response = await fetch(`/api/admin/markets/businesses/${businessId}/repair-lapsed-access`, {
6835                    method: 'POST',
6836                    headers: {
6837                        [antiforgeryHeaderName]: antiforgeryToken
6838                    }
6839                });
6840                const payload = await response.json().catch(() => ({}));
6841
6842                if (!response.ok) {
6843                    throw new Error(payload.message || 'Unable to repair seller access.');
6844                }
6845
6846                this.repairResults[businessId] = payload.message || 'Seller access repaired.';
6847            } catch (error) {
6848                this.repairErrors[businessId] = error instanceof Error
6849                    ? error.message
6850                    : 'Unable to repair seller access.';
6851            } finally {
6852                this.repairingBusinessId = null;
6853            }
6854        },
6855
6856        setCohort(value) {
6857            this.cohort = value || '';
6858            this.applyFilters();
6859        },
6860
6861        toggleVacationMode() {
6862            this.includeVacationMode = !this.includeVacationMode;
6863            this.applyFilters();
6864        },
6865
6866        clearFilters() {
6867            this.searchTerm = '';
6868            this.cohort = '';
6869            this.includeVacationMode = true;
6870            this.sortBy = 'most-listings';
6871            this.applyFilters();
6872        },
6873
6874        applyFilters() {
6875            const url = new URL(window.location.href);
6876            const params = url.searchParams;
6877
6878            // Any filter change invalidates the current page offset.
6879            params.delete('page');
6880
6881            const trimmed = (this.searchTerm || '').trim();
6882            if (trimmed) {
6883                params.set('searchTerm', trimmed);
6884            } else {
6885                params.delete('searchTerm');
6886            }
6887
6888            if (this.cohort) {
6889                params.set('cohort', this.cohort);
6890            } else {
6891                params.delete('cohort');
6892            }
6893
6894            if (this.includeVacationMode) {
6895                params.delete('includeVacationMode');
6896            } else {
6897                params.set('includeVacationMode', 'false');
6898            }
6899
6900            if (this.sortBy && this.sortBy !== 'most-listings') {
6901                params.set('sortBy', this.sortBy);
6902            } else {
6903                params.delete('sortBy');
6904            }
6905
6906            const nextUrl = url.toString();
6907            if (window.location.href === nextUrl) {
6908                return;
6909            }
6910
6911            navigateTo(nextUrl);
6912        }
6913    };
6914}
6915
6916// ========================================================================
6917// Admin reviews (formerly inline in Pages/Admin/Reviews/Index.cshtml)
6918// ========================================================================
6919
6920// Reads window.pageConfig ({ antiforgeryToken, antiforgeryHeaderName, searchTerm, sortBy,
6921// statusFilter }), written by Components/Pages/Admin/Reviews/Index.razor.
6922function adminReviewsPage() {
6923    const config = window.pageConfig || {};
6924
6925    return {
6926        antiforgeryToken: config.antiforgeryToken || '',
6927        antiforgeryHeaderName: config.antiforgeryHeaderName || 'RequestVerificationToken',
6928        searchTerm: config.searchTerm || '',
6929        sortBy: config.sortBy || 'recent',
6930        statusFilter: config.statusFilter || 'all',
6931
6932        detailsModal: {
6933            open: false,
6934            reviewId: null,
6935            headline: '',
6936            comments: '',
6937            flagReason: null
6938        },
6939
6940        rejectModal: {
6941            open: false,
6942            reviewId: null,
6943            action: 'reject',
6944            reason: '',
6945            loading: false
6946        },
6947
6948        applyFilters() {
6949            const url = new URL(window.location);
6950            url.searchParams.set('page', '1');
6951
6952            if (this.searchTerm) {
6953                url.searchParams.set('searchTerm', this.searchTerm);
6954            } else {
6955                url.searchParams.delete('searchTerm');
6956            }
6957
6958            url.searchParams.set('sortBy', this.sortBy);
6959
6960            url.searchParams.delete('isFlagged');
6961            url.searchParams.delete('isPublished');
6962
6963            if (this.statusFilter === 'flagged') {
6964                url.searchParams.set('isFlagged', 'true');
6965            } else if (this.statusFilter === 'published') {
6966                url.searchParams.set('isPublished', 'true');
6967            } else if (this.statusFilter === 'hidden') {
6968                url.searchParams.set('isPublished', 'false');
6969            }
6970
6971            navigateTo(url.toString());
6972        },
6973
6974        showDetailsFromElement(el) {
6975            this.detailsModal.reviewId = parseInt(el.dataset.reviewId, 10);
6976            this.detailsModal.headline = el.dataset.headline || '';
6977            this.detailsModal.comments = el.dataset.comments || '';
6978            this.detailsModal.flagReason = el.dataset.flagReason || null;
6979            this.detailsModal.open = true;
6980        },
6981
6982        openRejectModal(reviewId, action) {
6983            this.rejectModal.reviewId = reviewId;
6984            this.rejectModal.action = action;
6985            this.rejectModal.reason = '';
6986            this.rejectModal.loading = false;
6987            this.rejectModal.open = true;
6988        },
6989
6990        async moderate(reviewId, action, reason = null) {
6991            try {
6992                const response = await fetch(`/api/admin/reviews/${reviewId}/moderate`, {
6993                    method: 'POST',
6994                    headers: {
6995                        'Content-Type': 'application/json',
6996                        [this.antiforgeryHeaderName]: this.antiforgeryToken
6997                    },
6998                    body: JSON.stringify({ action, reason })
6999                });
7000
7001                if (!response.ok) {
7002                    const data = await response.json();
7003                    alert(data.message || 'Failed to moderate review');
7004                    return false;
7005                }
7006
7007                this.updateRowStatus(reviewId, action);
7008                return true;
7009            } catch (err) {
7010                console.error('Moderation error:', err);
7011                alert('An error occurred while moderating the review');
7012                return false;
7013            }
7014        },
7015
7016        async submitReject() {
7017            this.rejectModal.loading = true;
7018            const success = await this.moderate(
7019                this.rejectModal.reviewId,
7020                this.rejectModal.action,
7021                this.rejectModal.reason || null
7022            );
7023            this.rejectModal.loading = false;
7024            if (success) {
7025                this.rejectModal.open = false;
7026            }
7027        },
7028
7029        updateRowStatus(reviewId, action) {
7030            const statusEl = document.getElementById(`review-status-${reviewId}`);
7031            if (!statusEl) return;
7032
7033            statusEl.classList.remove('is-published', 'is-hidden', 'is-flagged');
7034
7035            if (action === 'approve') {
7036                statusEl.textContent = 'Published';
7037                statusEl.classList.add('is-published');
7038            } else {
7039                statusEl.textContent = 'Hidden';
7040                statusEl.classList.add('is-hidden');
7041            }
7042        }
7043    };
7044}
7045
7046// ========================================================================
7047// Admin review details (formerly inline in Pages/Admin/Reviews/Details.cshtml)
7048// ========================================================================
7049
7050// Reads window.pageConfig ({ antiforgeryToken, antiforgeryHeaderName, reviewId, isPublished,
7051// isFlagged, flagReason }), written by Components/Pages/Admin/Reviews/Details.razor.
7052function adminReviewDetails() {
7053    const config = window.pageConfig || {};
7054
7055    return {
7056        reviewId: config.reviewId,
7057        antiforgeryToken: config.antiforgeryToken || '',
7058        antiforgeryHeaderName: config.antiforgeryHeaderName || 'RequestVerificationToken',
7059        isPublished: config.isPublished,
7060        isFlagged: config.isFlagged,
7061        flagReason: config.flagReason || null,
7062        isLoading: false,
7063        loadingAction: null,
7064
7065        rejectModal: {
7066            open: false,
7067            action: 'reject',
7068            reason: '',
7069            loading: false
7070        },
7071
7072        get statusText() {
7073            return this.isFlagged ? 'Flagged' : (this.isPublished ? 'Published' : 'Hidden');
7074        },
7075
7076        get statusClass() {
7077            return this.isFlagged ? 'is-flagged' : (this.isPublished ? 'is-published' : 'is-hidden');
7078        },
7079
7080        openRejectModal(action) {
7081            this.rejectModal.action = action;
7082            this.rejectModal.reason = '';
7083            this.rejectModal.loading = false;
7084            this.rejectModal.open = true;
7085        },
7086
7087        async moderate(action, reason = null) {
7088            if (!this.reviewId) {
7089                return;
7090            }
7091
7092            this.isLoading = true;
7093            this.loadingAction = action;
7094
7095            try {
7096                const response = await fetch(`/api/admin/reviews/${this.reviewId}/moderate`, {
7097                    method: 'POST',
7098                    headers: {
7099                        'Content-Type': 'application/json',
7100                        [this.antiforgeryHeaderName]: this.antiforgeryToken
7101                    },
7102                    body: JSON.stringify({ action, reason })
7103                });
7104
7105                if (!response.ok) {
7106                    const data = await response.json();
7107                    alert(data.message || 'Failed to moderate review');
7108                    return;
7109                }
7110
7111                this.updateStatus(action, reason);
7112            } catch (err) {
7113                console.error('Moderation error:', err);
7114                alert('An error occurred while moderating the review');
7115            } finally {
7116                this.isLoading = false;
7117                this.loadingAction = null;
7118            }
7119        },
7120
7121        async submitReject() {
7122            this.rejectModal.loading = true;
7123            await this.moderate(this.rejectModal.action, this.rejectModal.reason || null);
7124            this.rejectModal.loading = false;
7125            this.rejectModal.open = false;
7126        },
7127
7128        updateStatus(action, reason) {
7129            if (action === 'approve') {
7130                this.isPublished = true;
7131                this.isFlagged = false;
7132                this.flagReason = null;
7133            } else {
7134                this.isPublished = false;
7135                this.isFlagged = false;
7136                this.flagReason = reason || null;
7137            }
7138        }
7139    };
7140}
7141
7142// ========================================================================
7143// Admin categories (formerly inline in Pages/Admin/Categories/Index.cshtml)
7144// ========================================================================
7145
7146// Reads window.pageConfig ({ searchTerm, status, depth }), written by
7147// Components/Pages/Admin/Categories/Index.razor. A filter change navigates to the new query
7148// string; the page no longer fetches itself and swaps the results region in.
7149function adminCategoriesPage() {
7150    const config = window.pageConfig || {};
7151
7152    return {
7153        searchTerm: config.searchTerm || '',
7154        status: (config.status || 'all').toLowerCase(),
7155        depth: (config.depth || 'all').toLowerCase(),
7156        isLoading: false,
7157
7158        get hasActiveFilters() {
7159            return !!(this.searchTerm || '').trim() || this.status !== 'all' || this.depth !== 'all';
7160        },
7161
7162        setStatus(status) {
7163            this.status = (status || 'all').toLowerCase();
7164            this.navigateWithFilters();
7165        },
7166
7167        clearFilters() {
7168            this.searchTerm = '';
7169            this.status = 'all';
7170            this.depth = 'all';
7171            this.navigateWithFilters();
7172        },
7173
7174        buildFilteredUrl() {
7175            const url = new URL(window.location.href);
7176            const trimmedSearchTerm = (this.searchTerm || '').trim();
7177            const setOrDelete = (key, value, fallback) => {
7178                if (value && value !== fallback) {
7179                    url.searchParams.set(key, value);
7180                } else {
7181                    url.searchParams.delete(key);
7182                }
7183            };
7184
7185            setOrDelete('searchTerm', trimmedSearchTerm, '');
7186            setOrDelete('status', this.status, 'all');
7187            setOrDelete('depth', this.depth, 'all');
7188            return url.toString();
7189        },
7190
7191        navigateWithFilters() {
7192            const nextUrl = this.buildFilteredUrl();
7193            if (window.location.href === nextUrl) {
7194                return;
7195            }
7196
7197            this.isLoading = true;
7198            navigateTo(nextUrl);
7199        }
7200    };
7201}
7202
7203// ========================================================================
7204// Admin category details (formerly inline in Pages/Admin/Categories/Details.cshtml)
7205// ========================================================================
7206
7207// Reads window.pageConfig ({ children: [{ name, fullName }] }), written by
7208// Components/Pages/Admin/Categories/Details.razor. Filters the child cards client-side.
7209function adminCategoryDetailsPage() {
7210    const config = window.pageConfig || {};
7211    const normalize = value => (value || '').trim().toLowerCase();
7212
7213    return {
7214        childSearchTerm: '',
7215        children: Array.isArray(config.children) ? config.children : [],
7216
7217        get hasChildSearch() {
7218            return !!normalize(this.childSearchTerm);
7219        },
7220
7221        get filteredChildCount() {
7222            return this.children.filter(child => this.matchesChild(child.name, child.fullName)).length;
7223        },
7224
7225        clearChildSearch() {
7226            this.childSearchTerm = '';
7227        },
7228
7229        matchesChild(name, fullName) {
7230            const query = normalize(this.childSearchTerm);
7231            return !query || normalize(name).includes(query) || normalize(fullName).includes(query);
7232        }
7233    };
7234}
7235
7236// ========================================================================
7237// Admin category form (formerly wwwroot/js/admin-category-form.js)
7238// ========================================================================
7239
7240// Reads window.pageConfig ({ parentOptions, initialParentId, initialParentLabel, initialName,
7241// initialSlug, initialActive, initialAgeGroup, initialImagePath, initialRestriction,
7242// initialGoogleCategoryId }), written by Components/Pages/Admin/Categories/{Create,Edit}.razor
7243// through CategoryForm.PageConfig. Needs window.AdminFormUtils (admin-forms.js, loaded by the page).
7244function adminCategoryFormPage() {
7245    const config = window.pageConfig || {};
7246    const slugify = value => window.AdminFormUtils.slugify(value || '');
7247    const toNumericId = value => {
7248        if (value === null || value === undefined || value === '') {
7249            return null;
7250        }
7251
7252        const numericValue = Number(value);
7253        return Number.isFinite(numericValue) && numericValue > 0 ? numericValue : null;
7254    };
7255
7256    return {
7257        parentOptions: Array.isArray(config.parentOptions) ? config.parentOptions : [],
7258        parentSearchOpen: false,
7259        parentSearchTerm: '',
7260        manualSlugOverride: false,
7261        selectedParent: null,
7262        form: {
7263            name: config.initialName || '',
7264            parentId: config.initialParentId ?? null,
7265            parentLabel: config.initialParentLabel || '',
7266            primarySlug: config.initialSlug || '',
7267            active: config.initialActive !== false,
7268            googleCategoryId: config.initialGoogleCategoryId || '',
7269            restriction: config.initialRestriction || '',
7270            ageGroup: config.initialAgeGroup || 'adult',
7271            imagePath: config.initialImagePath || '',
7272        },
7273
7274        // Alpine calls init() itself; the old markup's x-init="init()" ran it twice, registering
7275        // every $watch twice.
7276        init() {
7277            const initialParentId = toNumericId(this.form.parentId);
7278            this.form.parentId = initialParentId;
7279            this.selectedParent = this.parentOptions.find(option => option.id === initialParentId) || null;
7280            this.form.parentLabel = this.selectedParent ? this.selectedParent.label : '';
7281            this.manualSlugOverride = !!(this.form.primarySlug || '').trim() && this.form.primarySlug !== slugify(this.form.name);
7282
7283            this.$watch('form.name', value => {
7284                if (!this.manualSlugOverride) {
7285                    this.form.primarySlug = slugify(value);
7286                }
7287            });
7288
7289            this.$watch('form.primarySlug', value => {
7290                const normalized = slugify(value);
7291                if ((value || '') !== normalized) {
7292                    this.form.primarySlug = normalized;
7293                    return;
7294                }
7295
7296                this.manualSlugOverride = normalized.length > 0 && normalized !== slugify(this.form.name);
7297            });
7298
7299            if (!this.form.primarySlug && this.form.name) {
7300                this.form.primarySlug = slugify(this.form.name);
7301                this.manualSlugOverride = false;
7302            }
7303        },
7304
7305        get filteredParentOptions() {
7306            const query = (this.parentSearchTerm || '').trim().toLowerCase();
7307            if (!query) {
7308                return this.parentOptions.slice(0, 12);
7309            }
7310
7311            return this.parentOptions
7312                .filter(option => option.label.toLowerCase().includes(query) || option.fullName.toLowerCase().includes(query))
7313                .slice(0, 12);
7314        },
7315
7316        get preview() {
7317            const trimmedName = (this.form.name || '').trim();
7318            const fullName = trimmedName
7319                ? this.selectedParent ? `${this.selectedParent.fullName} > ${trimmedName}` : trimmedName
7320                : '';
7321            const ancestorSlugPath = this.form.primarySlug
7322                ? this.selectedParent
7323                    ? `${this.selectedParent.ancestorSlugPath}${this.form.primarySlug}/`
7324                    : `/${this.form.primarySlug}/`
7325                : '';
7326            const depth = this.selectedParent ? this.selectedParent.depth + 1 : 1;
7327            const depthLabel = depth === 1 ? 'Top Level' : depth === 2 ? 'Child' : 'Grandchild';
7328
7329            return {
7330                fullName,
7331                ancestorSlugPath,
7332                depthLabel,
7333                depthClass: depth === 3 ? 'is-grandchild' : depth === 2 ? 'is-child' : 'is-top-level',
7334                depthMessage: depth === 3
7335                    ? 'This category will be created as a grandchild and cannot have children created beneath it from this flow.'
7336                    : depth === 2
7337                        ? 'This category will sit directly under the selected top-level parent.'
7338                        : 'This category will live at the root of the taxonomy.',
7339            };
7340        },
7341
7342        get imagePreviewVisible() {
7343            return !!(this.form.imagePath || '').trim();
7344        },
7345
7346        toggleParentSearch() {
7347            this.parentSearchOpen = !this.parentSearchOpen;
7348            if (!this.parentSearchOpen) {
7349                this.parentSearchTerm = '';
7350            }
7351        },
7352
7353        closeParentSearch() {
7354            this.parentSearchOpen = false;
7355            this.parentSearchTerm = '';
7356        },
7357
7358        selectParent(option) {
7359            this.selectedParent = option || null;
7360            this.form.parentId = option ? option.id : null;
7361            this.form.parentLabel = option ? option.label : '';
7362            this.closeParentSearch();
7363        }
7364    };
7365}
7366
7367// ========================================================================
7368// Admin background-job polling (Category AI, listing scores)
7369// ========================================================================
7370
7371// Formats a UTC timestamp the way the server's "u" format does: 2026-09-22 14:05:00Z.
7372function formatUtcTimestamp(value) {
7373    return value ? new Date(value).toISOString().slice(0, 19).replace('T', ' ') + 'Z' : '-';
7374}
7375
7376// Polls url every 3s while the job is not in terminalStatuses, until the component leaves the page.
7377// onData(data) updates the page; a 404 goes to pageUrl, and a terminal status goes to
7378// pageUrl?jobId=… after a moment (so the page's server-rendered totals catch up). The page URL is
7379// rebuilt rather than reloaded: after a form post, a reload would resubmit it.
7380function pollAdminJob(component, { url, pageUrl, jobId, terminalStatuses, onData }) {
7381    const jobUrl = `${pageUrl}?jobId=${encodeURIComponent(jobId)}`;
7382    let timer = null;
7383    let stopped = false;
7384    const stop = () => {
7385        stopped = true;
7386        window.clearInterval(timer);
7387    };
7388
7389    const poll = async () => {
7390        try {
7391            const response = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
7392            if (stopped) return;
7393            if (response.status === 404) {
7394                stop();
7395                navigateTo(pageUrl);
7396                return;
7397            }
7398            if (!response.ok) {
7399                return;
7400            }
7401
7402            const data = await response.json();
7403            if (stopped) return;
7404            onData(data);
7405
7406            if (terminalStatuses.includes(data.status)) {
7407                window.clearInterval(timer);
7408                window.setTimeout(() => { if (!stopped) navigateTo(jobUrl, { keepScroll: true }); }, 1200);
7409            }
7410        } catch {
7411            // Keep polling through transient network failures.
7412        }
7413    };
7414
7415    timer = window.setInterval(poll, 3000);
7416    poll();
7417    onUnmount(component, stop);
7418}
7419
7420// ========================================================================
7421// Admin Category AI (formerly inline in Pages/Admin/Categories/Embeddings.cshtml)
7422// ========================================================================
7423
7424// Reads window.pageConfig ({ job: { jobId, status, processed, updated, failed, startedAt,
7425// completedAt, errorMessage } | null }), written by Components/Pages/Admin/Categories/Embeddings.razor.
7426// Polls the job, runs the similarity-search box, and dims the submit button while posting.
7427function adminCategoryEmbeddingsPage() {
7428    const config = window.pageConfig || {};
7429    const terminalStatuses = ['Completed', 'Failed', 'Canceled'];
7430    const toNumber = value => {
7431        const parsed = Number(value ?? 0);
7432        return Number.isFinite(parsed) ? parsed : 0;
7433    };
7434
7435    return {
7436        job: config.job || null,
7437        showToast: true,
7438        submitting: false,
7439        searchQuery: 'Vintage > Jewelry > Brooches, Pins & Clips',
7440        searching: false,
7441        searchResults: [],
7442        searchNotice: '',
7443        searchError: '',
7444
7445        get jobCancelable() {
7446            return !!this.job && (this.job.status === 'Queued' || this.job.status === 'Running');
7447        },
7448
7449        init() {
7450            window.setTimeout(() => { this.showToast = false; }, 5000);
7451
7452            if (!this.job || terminalStatuses.includes(this.job.status)) {
7453                return;
7454            }
7455
7456            pollAdminJob(this, {
7457                url: `/api/categories/backfill-embeddings/${encodeURIComponent(this.job.jobId)}`,
7458                pageUrl: '/admin/categories/embeddings',
7459                jobId: this.job.jobId,
7460                terminalStatuses,
7461                onData: data => {
7462                    this.job = {
7463                        ...this.job,
7464                        status: data.status ?? '-',
7465                        processed: toNumber(data.processed).toLocaleStr
7465ing(),
7466                        updated: toNumber(data.updated).toLocaleString(),
7467                        failed: toNumber(data.failed).toLocaleString(),
7468                        startedAt: formatUtcTimestamp(data.startedAtUtc),
7469                        completedAt: formatUtcTimestamp(data.completedAtUtc),
7470                        errorMessage: data.errorMessage || null
7471                    };
7472                }
7473            });
7474        },
7475
7476        async search() {
7477            const query = (this.searchQuery || '').trim();
7478            this.searchResults = [];
7479            this.searchError = '';
7480            this.searchNotice = '';
7481
7482            if (!query) {
7483                this.searchNotice = 'Enter a query to search.';
7484                return;
7485            }
7486
7487            this.searching = true;
7488            try {
7489                const response = await fetch(`/api/categories/embedding-search?query=${encodeURIComponent(query)}`, {
7490                    headers: { 'X-Requested-With': 'XMLHttpRequest' }
7491                });
7492                if (!response.ok) {
7493                    throw new Error(`HTTP ${response.status}`);
7494                }
7495
7496                const data = await response.json();
7497                this.searchResults = Array.isArray(data.results) ? data.results : [];
7498                if (this.searchResults.length === 0) {
7499                    this.searchNotice = 'No results found.';
7500                }
7501            } catch (error) {
7502                this.searchError = `Search failed: ${error.message}`;
7503            } finally {
7504                this.searching = false;
7505            }
7506        }
7507    };
7508}
7509
7510// ========================================================================
7511// Admin listing scores (formerly inline in Pages/Admin/Recommendations/ListingScores.cshtml)
7512// ========================================================================
7513
7514// Reads window.pageConfig ({ job: { jobId, status, updated, startedAt, completedAt,
7515// errorMessage } | null }), written by Components/Pages/Admin/Recommendations/ListingScores.razor.
7516function adminListingScoresPage() {
7517    const config = window.pageConfig || {};
7518    const terminalStatuses = ['Completed', 'Failed'];
7519
7520    return {
7521        job: config.job || null,
7522        showToast: true,
7523        submitting: false,
7524
7525        init() {
7526            window.setTimeout(() => { this.showToast = false; }, 5000);
7527
7528            if (!this.job || terminalStatuses.includes(this.job.status)) {
7529                return;
7530            }
7531
7532            pollAdminJob(this, {
7533                url: `/api/recommendations/listing-score-jobs/${encodeURIComponent(this.job.jobId)}`,
7534                pageUrl: '/admin/recommendations/listing-scores',
7535                jobId: this.job.jobId,
7536                terminalStatuses,
7537                onData: data => {
7538                    const updated = Number(data.updatedListingCount ?? 0);
7539                    this.job = {
7540                        ...this.job,
7541                        status: data.status ?? '-',
7542                        updated: (Number.isFinite(updated) ? updated : 0).toLocaleString(),
7543                        startedAt: formatUtcTimestamp(data.startedAtUtc),
7544                        completedAt: formatUtcTimestamp(data.completedAtUtc),
7545                        errorMessage: data.errorMessage || null
7546                    };
7547                }
7548            });
7549        }
7550    };
7551}
7552
7553// ========================================================================
7554// Admin orders (formerly inline in Pages/Admin/Orders/Products/List.cshtml)
7555// ========================================================================
7556
7557// Reads window.pageConfig ({ orders }), written by Components/Pages/Admin/Orders/Products/List.razor.
7558// Statuses and delivery methods arrive as enum names; the numeric checks are kept for safety.
7559function adminOrdersPage() {
7560    const config = window.pageConfig || {};
7561
7562    return {
7563        searchTerm: '',
7564        orders: config.orders || [],
7565        openMenuId: null,
7566        menuTrigger: null,
7567        menuStyle: {},
7568        showTrackingModal: false,
7569        currentOrder: null,
7570        trackingNumber: '',
7571
7572        get filteredOrders() {
7573            if (!this.searchTerm) return this.orders;
7574            const term = this.searchTerm.toLowerCase();
7575            return this.orders.filter(o => o.id.toLowerCase().includes(term));
7576        },
7577
7578        toggleMenu(id, trigger) {
7579            this.openMenuId = this.openMenuId === id ? null : id;
7580            this.menuTrigger = this.openMenuId ? trigger : null;
7581            this.positionMenu();
7582        },
7583
7584        // A click on another row's trigger is outside this menu, but toggleMenu has already
7585        // switched openMenuId to that row; closing here would shut the menu it just opened.
7586        closeMenuOnClickAway(event) {
7587            if (event.target.closest('.actions-button')) return;
7588            this.openMenuId = null;
7589        },
7590
7591        // The menu is position: fixed so the table's horizontal scroll container cannot clip it.
7592        // It opens below its trigger, or above it when the trigger is in the lower half of the
7593        // viewport (anchored by bottom, so its height need not be known), right-aligned, and
7594        // capped to the room available. An object, not a string: a string x-bind:style replaces
7595        // the whole attribute, including x-show's display: none.
7596        positionMenu() {
7597            if (!this.openMenuId || !this.menuTrigger) return;
7598
7599            const rect = this.menuTrigger.getBoundingClientRect();
7600            const width = 220;
7601            const gutter = 12;
7602            const left = Math.max(gutter, Math.min(rect.right - width, window.innerWidth - width - gutter));
7603            const openUp = rect.top > window.innerHeight / 2;
7604            this.menuStyle = {
7605                top: openUp ? 'auto' : `${rect.bottom + 8}px`,
7606                bottom: openUp ? `${window.innerHeight - rect.top + 8}px` : 'auto',
7607                maxHeight: `${openUp ? rect.top - 8 - gutter : window.innerHeight - rect.bottom - 8 - gutter}px`,
7608                left: `${left}px`,
7609                width: `${width}px`,
7610            };
7611        },
7612
7613        formatDate(dateStr) {
7614            return new Date(dateStr).toLocaleDateString('en-US', {
7615                year: 'numeric',
7616                month: 'short',
7617                day: 'numeric'
7618            });
7619        },
7620
7621        formatCurrency(amount) {
7622            return new Intl.NumberFormat('en-US', {
7623                style: 'currency',
7624                currency: 'USD'
7625            }).format(amount);
7626        },
7627
7628        formatStatus(status) {
7629            if (typeof status === 'number') {
7630                const statuses = {
7631                    1: 'Processing',
7632                    2: 'Shipped',
7633                    3: 'Completed',
7634                    4: 'Cancelled',
7635                    5: 'Refunded',
7636                    6: 'Partially Refunded',
7637                    7: 'Ready For Pickup'
7638                };
7639                return statuses[status] || status;
7640            }
7641            return status.replace(/([A-Z])/g, ' $1').trim();
7642        },
7643
7644        isProcessing(order) {
7645            return order?.status === 'Processing' || order?.status === 1;
7646        },
7647
7648        isShipped(order) {
7649            return order?.status === 'Shipped' || order?.status === 2;
7650        },
7651
7652        isReadyForPickup(order) {
7653            return order?.status === 'ReadyForPickup' || order?.status === 'Ready For Pickup' || order?.status === 7;
7654        },
7655
7656        isLocalPickupOrder(order) {
7657            if (!order) return false;
7658            return order.deliveryMethod === 'LocalPickup' || order.deliveryMethod === 2;
7659        },
7660
7661        async fetchReceipt(orderId) {
7662            try {
7663                const response = await fetch(`/api/orders/${orderId}/receipt-url`, {
7664                    method: 'POST',
7665                    headers: { 'Content-Type': 'application/json' }
7666                });
7667                if (!response.ok) throw new Error('Failed to fetch receipt');
7668                const data = await response.json();
7669                window.open(data.receiptUrl, '_blank');
7670            } catch (error) {
7671                alert(error.message);
7672            }
7673        },
7674
7675        async updateStatus(orderId, newStatus) {
7676            try {
7677                const response = await fetch(`/api/orders/${orderId}/status`, {
7678                    method: 'PUT',
7679                    headers: { 'Content-Type': 'application/json' },
7680                    body: JSON.stringify({ newStatus })
7681                });
7682                if (!response.ok) throw new Error('Failed to update status');
7683                refreshPage();
7684            } catch (error) {
7685                alert(error.message);
7686            }
7687        },
7688
7689        openTrackingModal(order) {
7690            this.currentOrder = order;
7691            this.trackingNumber = order.trackingNumber || '';
7692            this.showTrackingModal = true;
7693            this.openMenuId = null;
7694        },
7695
7696        async saveTracking() {
7697            if (!this.currentOrder) return;
7698            try {
7699                const response = await fetch(`/api/orders/${this.currentOrder.id}/tracking-number`, {
7700                    method: 'PUT',
7701                    headers: { 'Content-Type': 'application/json' },
7702                    body: JSON.stringify({ trackingNumber: this.trackingNumber })
7703                });
7704                if (!response.ok) throw new Error('Failed to add tracking number');
7705                refreshPage();
7706            } catch (error) {
7707                alert(error.message);
7708            }
7709        }
7710    };
7711}
7712
7713// ========================================================================
7714// Admin order refund (formerly inline in Pages/Admin/Orders/Refund.cshtml)
7715// ========================================================================
7716
7717// Reads window.pageConfig ({ orderId, maxRefundable, antiforgeryToken, antiforgeryHeaderName }),
7718// written by Components/Pages/Admin/Orders/Refund.razor.
7719function adminOrderRefundPage() {
7720    const config = window.pageConfig || {};
7721
7722    return {
7723        amount: null,
7724        reason: 'requested_by_customer',
7725        isSubmitting: false,
7726        successMessage: '',
7727        errorMessage: '',
7728        maxRefundable: Number(config.maxRefundable ?? 0),
7729        orderId: config.orderId,
7730
7731        get amountError() {
7732            if (this.amount === null || this.amount === '') return '';
7733            if (this.amount <= 0) return 'Amount must be greater than 0';
7734            if (this.amount > this.maxRefundable) return `Amount cannot exceed ${this.formatCurrency(this.maxRefundable)}`;
7735            return '';
7736        },
7737
7738        get isValid() {
7739            return this.amount > 0 && this.amount <= this.maxRefundable && !this.amountError;
7740        },
7741
7742        formatCurrency(amount) {
7743            return new Intl.NumberFormat('en-US', {
7744                style: 'currency',
7745                currency: 'USD'
7746            }).format(amount);
7747        },
7748
7749        async submitRefund() {
7750            if (!this.isValid || this.isSubmitting) return;
7751
7752            this.isSubmitting = true;
7753            this.successMessage = '';
7754            this.errorMessage = '';
7755
7756            const headers = { 'Content-Type': 'application/json' };
7757            if (config.antiforgeryHeaderName && config.antiforgeryToken) {
7758                headers[config.antiforgeryHeaderName] = config.antiforgeryToken;
7759            }
7760
7761            try {
7762                const response = await fetch(`/api/admin/orders/${encodeURIComponent(this.orderId)}/refund`, {
7763                    method: 'POST',
7764                    headers,
7765                    body: JSON.stringify({
7766                        amount: this.amount,
7767                        reason: this.reason
7768                    })
7769                });
7770
7771                const data = await response.json().catch(() => ({}));
7772
7773                if (!response.ok) {
7774                    throw new Error(data.message || 'Failed to process refund');
7775                }
7776
7777                this.successMessage = 'Refund processed successfully! Reloading...';
7778
7779                // Reload after a moment to show the updated amounts and history.
7780                setTimeout(() => {
7781                    refreshPage();
7782                }, 1500);
7783            } catch (error) {
7784                this.errorMessage = error.message;
7785                this.isSubmitting = false;
7786            }
7787        }
7788    };
7789}
7790
7791// ========================================================================
7792// Admin shipping labels: form, rates, labels (formerly inline in Pages/Admin/Shipping/*.cshtml)
7793// ========================================================================
7794
7795// The antiforgery header from window.pageConfig, merged into extra headers.
7796function adminShippingHeaders(extra = {}) {
7797    const config = window.pageConfig || {};
7798    const headers = { ...extra };
7799    if (config.antiforgeryHeaderName && config.antiforgeryToken) {
7800        headers[config.antiforgeryHeaderName] = config.antiforgeryToken;
7801    }
7802    return headers;
7803}
7804
7805// Reads window.pageConfig ({ orderId, addressFrom, addressTo, antiforgery… }), written by
7806// Components/Pages/Admin/Shipping/Form.razor. Creates the Shippo shipment, then moves to its rates.
7807function adminShippingFormPage() {
7808    const config = window.pageConfig || {};
7809
7810    return {
7811        loading: false,
7812        error: null,
7813        orderId: config.orderId,
7814        addressFrom: config.addressFrom || {},
7815        addressTo: config.addressTo || {},
7816
7817        parcel: {
7818            length: '',
7819            width: '',
7820            height: '',
7821            weight: '',
7822            distance_unit: 'in',
7823            mass_unit: 'oz'
7824        },
7825
7826        get isValid() {
7827            return this.parcel.length > 0 &&
7828                   this.parcel.width > 0 &&
7829                   this.parcel.height > 0 &&
7830                   this.parcel.weight > 0 &&
7831                   this.hasText(this.addressFrom.phone) &&
7832                   this.hasText(this.addressTo.phone);
7833        },
7834
7835        hasText(value) {
7836            return typeof value === 'string' && value.trim().length > 0;
7837        },
7838
7839        async getRates() {
7840            if (!this.isValid) {
7841                this.error = 'Ship from and ship to phone numbers are required before getting rates.';
7842                return;
7843            }
7844
7845            this.loading = true;
7846            this.error = null;
7847
7848            try {
7849                const addressFrom = {
7850                    ...this.addressFrom,
7851                    phone: this.addressFrom.phone.trim()
7852                };
7853                const addressTo = {
7854                    ...this.addressTo,
7855                    phone: this.addressTo.phone.trim()
7856                };
7857
7858                const response = await fetch('/api/admin/shipping/rates', {
7859                    method: 'POST',
7860                    headers: adminShippingHeaders({ 'Content-Type': 'application/json' }),
7861                    body: JSON.stringify({
7862                        orderId: this.orderId,
7863                        shipment: {
7864                            address_from: addressFrom,
7865                            address_to: addressTo,
7866                            parcel: {
7867                                length: this.parcel.length.toString(),
7868                                width: this.parcel.width.toString(),
7869                                height: this.parcel.height.toString(),
7870                                weight: this.parcel.weight.toString(),
7871                                distance_unit: this.parcel.distance_unit,
7872                                mass_unit: this.parcel.mass_unit
7873                            },
7874                            metadata: ""
7875                        }
7876                    })
7877                });
7878
7879                if (!response.ok) {
7880                    const data = await response.json().catch(() => ({}));
7881                    throw new Error(data.message || 'Failed to get shipping rates');
7882                }
7883
7884                const data = await response.json();
7885
7886                if (data.summary && data.summary.object_id) {
7887                    navigateTo(`/admin/shipping/${encodeURIComponent(this.orderId)}/${encodeURIComponent(data.summary.object_id)}/rates`);
7888                } else {
7889                    throw new Error('No shipment ID returned');
7890                }
7891            } catch (err) {
7892                this.error = err.message;
7893            } finally {
7894                this.loading = false;
7895            }
7896        }
7897    };
7898}
7899
7900// Reads window.pageConfig ({ orderId, shipmentId, antiforgery… }), written by
7901// Components/Pages/Admin/Shipping/Rates.razor. Loads the shipment's rates and sends the chosen one
7902// to Stripe Checkout (a plain redirect to the URL the API returns).
7903function adminShippingRatesPage() {
7904    const config = window.pageConfig || {};
7905
7906    return {
7907        loading: true,
7908        error: null,
7909        rates: [],
7910        selectedRate: null,
7911        checkingOut: false,
7912        orderId: config.orderId,
7913        shipmentId: config.shipmentId,
7914        fallbackLogo: 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ccc"><rect width="24" height="24" rx="4"/></svg>'),
7915
7916        init() {
7917            this.loadRates();
7918        },
7919
7920        async loadRates() {
7921            this.loading = true;
7922            this.error = null;
7923
7924            try {
7925                const response = await fetch(`/api/admin/shipping/rates/${encodeURIComponent(this.shipmentId)}`);
7926
7927                if (!response.ok) {
7928                    const data = await response.json().catch(() => ({}));
7929                    throw new Error(data.message || 'Failed to load rates');
7930                }
7931
7932                const data = await response.json();
7933                this.rates = data.rates || [];
7934
7935                // Preselect the first rate Shippo returns.
7936                if (this.rates.length > 0) {
7937                    this.selectedRate = this.rates[0];
7938                }
7939            } catch (err) {
7940                this.error = err.message;
7941            } finally {
7942                this.loading = false;
7943            }
7944        },
7945
7946        selectRate(rate) {
7947            this.selectedRate = rate;
7948        },
7949
7950        formatCurrency(amount) {
7951            return new Intl.NumberFormat('en-US', {
7952                style: 'currency',
7953                currency: 'USD'
7954            }).format(amount);
7955        },
7956
7957        async checkout() {
7958            if (!this.selectedRate || this.checkingOut) return;
7959
7960            this.checkingOut = true;
7961            this.error = null;
7962
7963            try {
7964                const response = await fetch(
7965                    `/api/admin/shipping/${encodeURIComponent(this.orderId)}/${encodeURIComponent(this.shipmentId)}/rates/${encodeURIComponent(this.selectedRate.object_id)}/checkout`,
7966                    {
7967                        method: 'POST',
7968                        headers: adminShippingHeaders()
7969                    }
7970                );
7971
7972                if (!response.ok) {
7973                    const data = await response.json().catch(() => ({}));
7974                    throw new Error(data.message || 'Failed to create checkout session');
7975                }
7976
7977                const data = await response.json();
7978
7979                if (data.checkoutUrl) {
7980                    window.location.href = data.checkoutUrl;
7981                } else {
7982                    throw new Error('No checkout URL returned');
7983                }
7984            } catch (err) {
7985                this.error = err.message;
7986                this.checkingOut = false;
7987            }
7988        }
7989    };
7990}
7991
7992// Reads window.pageConfig ({ orderId, sessionId, labels, attempt, antiforgery… }), written by
7993// Components/Pages/Admin/Shipping/Labels.razor. After Stripe Checkout (?sessionId=) it polls the
7994// shipment data until Shippo returns the label or reports a failure, which can be retried.
7995function adminShippingLabelsPage() {
7996    const config = window.pageConfig || {};
7997    const initialLabels = config.labels || [];
7998    const autoPoll = initialLabels.length === 0 && !!config.sessionId;
7999
8000    return {
8001        orderId: config.orderId,
8002        sessionId: config.sessionId,
8003        labels: initialLabels,
8004        attempt: config.attempt || null,
8005        isPolling: autoPoll,
8006        pollCount: 0,
8007        pollDelayMs: 2000,
8008        maxPolls: 30,
8009        pollTimedOut: false,
8010        refreshing: false,
8011        retrying: false,
8012
8013        get isFailed() {
8014            return this.attempt?.status === 'Failed';
8015        },
8016        get isInProgress() {
8017            return this.labels.length === 0 &&
8018                (this.isPolling ||
8019                    this.attempt?.status === 'PendingPayment' ||
8020                    this.attempt?.status === 'Processing' ||
8021                    (!!this.sessionId && !this.attempt && !this.isFailed));
8022        },
8023
8024        get processingTitle() {
8025            if (this.attempt?.status === 'Processing') return 'Creating your label with Shippo';
8026            if (this.attempt?.status === 'PendingPayment') return 'Confirming your label payment';
8027            if (this.pollTimedOut) return 'Still waiting for an update';
8028            return 'Checking label status';
8029        },
8030        get processingMessage() {
8031            if (this.pollTimedOut) {
8032                return 'The payment may still be processing, or the webhook may be delayed. You can check again now.';
8033            }
8034
8035            return 'We will show the label here as soon as Shippo returns it. If Shippo reports a problem, you will see the error and a retry option.';
8036        },
8037        get attemptErrorMessage() {
8038            return this.attempt?.errorMessage || 'Shippo did not return a detailed error. Please retry label creation.';
8039        },
8040        get statusMeta() {
8041            if (!this.attempt) return '';
8042
8043            const parts = [`Status: ${this.formatStatus(this.attempt.status)}`];
8044            if (this.attempt.attemptCount) parts.push(`Attempts: ${this.attempt.attemptCount}`);
8045            if (this.attempt.updatedAt) parts.push(`Last update: ${this.formatDate(this.attempt.updatedAt)}`);
8046
8047            return parts.join(' | ');
8048        },
8049
8050        init() {
8051            if (this.isPolling) {
8052                this.startPolling();
8053            }
8054        },
8055
8056        destroy() {
8057            this.isPolling = false;
8058        },
8059
8060        shipmentDataUrl() {
8061            return `/api/admin/shipping/${encodeURIComponent(this.orderId)}/shipment-data`;
8062        },
8063
8064        async startPolling() {
8065            if (!this.isPolling) return;
8066            this.pollCount++;
8067
8068            try {
8069                const response = await fetch(this.shipmentDataUrl());
8070                if (response.ok) {
8071                    const data = await response.json();
8072                    this.labels = data.labels || [];
8073                    this.attempt = data.attempt || this.attempt;
8074
8075                    if (this.labels.length > 0 || this.isFailed) {
8076                        this.isPolling = false;
8077                        return;
8078                    }
8079                } else {
8080                    console.error('Shipment data request failed:', response.status);
8081                }
8082            } catch (e) {
8083                console.error('Polling failed:', e);
8084            }
8085
8086            if (this.pollCount < this.maxPolls) {
8087                setTimeout(() => this.startPolling(), this.pollDelayMs);
8088            } else {
8089                this.isPolling = false;
8090                this.pollTimedOut = true;
8091            }
8092        },
8093
8094        async refreshShipmentData() {
8095            this.refreshing = true;
8096            this.pollTimedOut = false;
8097
8098            try {
8099                const response = await fetch(this.shipmentDataUrl());
8100
8101                if (response.ok) {
8102                    const data = await response.json();
8103                    this.labels = data.labels || [];
8104                    this.attempt = data.attempt || this.attempt;
8105                }
8106            } finally {
8107                this.refreshing = false;
8108            }
8109        },
8110
8111        async retryLabelPurchase() {
8112            if (!this.attempt?.stripeSessionId || this.retrying) return;
8113
8114            this.retrying = true;
8115
8116            try {
8117                const response = await fetch(
8118                    `/api/admin/shipping/${encodeURIComponent(this.orderId)}/label-attempts/${encodeURIComponent(this.attempt.stripeSessionId)}/retry`,
8119                    {
8120                        method: 'POST',
8121                        headers: adminShippingHeaders()
8122                    }
8123                );
8124
8125                const data = await response.json().catch(() => ({}));
8126                this.attempt = {
8127                    ...this.attempt,
8128                    status: data.status || this.attempt.status,
8129                    errorMessage: data.errorMessage || this.attempt.errorMessage
8130                };
8131
8132                await this.refreshShipmentData();
8133            } finally {
8134                this.retrying = false;
8135            }
8136        },
8137
8138        formatStatus(status) {
8139            return (status || '').replace(/([a-z])([A-Z])/g, '$1 $2');
8140        },
8141
8142        formatDate(value) {
8143            return new Intl.DateTimeFormat('en-US', {
8144                month: 'short',
8145                day: 'numeric',
8146                hour: 'numeric',
8147                minute: '2-digit'
8148            }).format(new Date(value));
8149        }
8150    };
8151}
8152
8153// ========================================================================
8154// Admin market edit (formerly inline in Pages/Admin/Markets/Edit.cshtml)
8155// ========================================================================
8156
8157// Reads window.pageConfig ({ business, antiforgeryToken, antiforgeryHeaderName }), written by
8158// Components/Pages/Admin/Markets/Edit.razor. Needs window.AdminFormUtils (admin-forms.js).
8159function adminMarketEditPage() {
8160    const config = window.pageConfig || {};
8161    const business = config.business || {};
8162    const antiforgery = config.antiforgeryHeaderName && config.antiforgeryToken
8163        ? { [config.antiforgeryHeaderName]: config.antiforgeryToken }
8164        : {};
8165
8166    return {
8167        business,
8168        form: {
8169            name: business.name,
8170            description: business.description,
8171            returnPolicy: business.returnPolicy,
8172            phoneNumber: business.phoneNumber,
8173            pickupAddress: business.pickupAddress,
8174            pickupCrossStreets: business.pickupCrossStreets,
8175            pickupCity: business.pickupCity,
8176            pickupState: business.pickupState,
8177            pickupPostalCode: business.pickupPostalCode,
8178            pickupInstructions: business.pickupInstructions,
8179            pickupHours: business.pickupHours,
8180            twitterProfileUrl: business.twitterProfileUrl,
8181            facebookProfileUrl: business.facebookProfileUrl,
8182            pinterestProfileUrl: business.pinterestProfileUrl,
8183            instagramProfileUrl: business.instagramProfileUrl,
8184            isVacationModeActive: business.isVacationModeActive
8185        },
8186        logoFile: null,
8187        bannerFile: null,
8188        logoPreview: null,
8189        bannerPreview: null,
8190        loading: false,
8191        notifications: [],
8192        errors: {},
8193
8194        formatDate(dateStr) {
8195            if (!dateStr) return '';
8196            return new Date(dateStr).toLocaleDateString(undefined, {
8197                year: 'numeric',
8198                month: 'short',
8199                day: 'numeric'
8200            });
8201        },
8202
8203        notify(type, message) {
8204            const id = Date.now();
8205            this.notifications.unshift({ id, type, message });
8206            setTimeout(() => {
8207                this.removeNotification(id);
8208            }, 5000);
8209        },
8210
8211        removeNotification(id) {
8212            this.notifications = this.notifications.filter(n => n.id !== id);
8213        },
8214
8215        validateField(field) {
8216            const utils = window.AdminFormUtils;
8217            const r = utils.rules;
8218            let error = null;
8219
8220            if (field === 'name') {
8221                error = r.required(this.form.name, 'Market name') || 
8222                        r.minLength(this.form.name, 2, 'Name') || 
8223                        r.maxLength(this.form.name, 100, 'Name') ||
8224                        r.noKeyword(this.form.name, 'finnalby', 'Business name') ||
8225                        r.noSlug(this.form.name, 'me', 'market name');
8226            }
8227            else if (field === 'description') {
8228                error = r.required(this.form.description, 'Description') || 
8229                        r.minLength(this.form.description, 5, 'Description') || 
8230                        r.maxLength(this.form.description, 10000, 'Description');
8231            }
8232            else if (field === 'returnPolicy') {
8233                error = r.required(this.form.returnPolicy, 'Return policy') || 
8234                        r.minLength(this.form.returnPolicy, 5, 'Return policy') || 
8235                        r.maxLength(this.form.returnPolicy, 10000, 'Return policy');
8236            }
8237            else if (field === 'phoneNumber') {
8238                error = r.maxLength(this.form.phoneNumber, 15, 'Phone number');
8239            }
8240            else if (field === 'pickupCity') {
8241                error = r.maxLength(this.form.pickupCity, 100, 'Pickup city');
8242            }
8243            else if (field === 'pickupState') {
8244                error = r.maxLength(this.form.pickupState, 50, 'Pickup state');
8245            }
8246            else if (field === 'pickupPostalCode') {
8247                error = r.maxLength(this.form.pickupPostalCode, 20, 'Pickup postal code');
8248            }
8249            else if (field === 'pickupAddress') {
8250                error = r.maxLength(this.form.pickupAddress, 200, 'Pickup address');
8251            }
8252            else if (field === 'pickupCrossStreets') {
8253                error = r.maxLength(this.form.pickupCrossStreets, 200, 'Pickup cross streets');
8254            }
8255            else if (field === 'pickupInstructions') {
8256                error = r.maxLength(this.form.pickupInstructions, 1000, 'Pickup instructions');
8257            }
8258            else if (field === 'pickupHours') {
8259                error = r.maxLength(this.form.pickupHours, 500, 'Pickup hours');
8260            }
8261            else if (['twitterProfileUrl', 'facebookProfileUrl', 'pinterestProfileUrl', 'instagramProfileUrl'].includes(field)) {
8262                const label = field.replace('ProfileUrl', '').charAt(0).toUpperCase() + field.replace('ProfileUrl', '').slice(1);
8263                error = r.httpsUrl(this.form[field], label);
8264            }
8265
8266            if (error) this.errors[field] = error;
8267            else delete this.errors[field];
8268
8269            return !error;
8270        },
8271
8272        validateAll() {
8273            const fields = [
8274                'name',
8275                'description',
8276                'returnPolicy',
8277                'phoneNumber',
8278                'pickupAddress',
8279                'pickupCrossStreets',
8280                'pickupCity',
8281                'pickupState',
8282                'pickupPostalCode',
8283                'pickupInstructions',
8284                'pickupHours',
8285                'twitterProfileUrl',
8286                'facebookProfileUrl',
8287                'pinterestProfileUrl',
8288                'instagramProfileUrl'
8289            ];
8290            let isValid = true;
8291            fields.forEach(f => {
8292                if (!this.validateField(f)) isValid = false;
8293            });
8294
8295            // Logo & Banner validation
8296            if (this.logoFile) {
8297                if (this.logoFile.size > 10 * 1024 * 1024) {
8298                    this.errors.logo = 'Logo must be less than 10MB.';
8299                    isValid = false;
8300                }
8301                if (!['image/jpeg', 'image/png', 'image/webp'].includes(this.logoFile.type)) {
8302                    this.errors.logo = 'Logo must be a JPEG, PNG, or WebP file.';
8303                    isValid = false;
8304                }
8305            }
8306
8307            if (this.bannerFile) {
8308                if (this.bannerFile.size > 10 * 1024 * 1024) {
8309                    this.errors.banner = 'Banner must be less than 10MB.';
8310                    isValid = false;
8311                }
8312                if (!['image/jpeg', 'image/png', 'image/webp'].includes(this.bannerFile.type)) {
8313                    this.errors.banner = 'Banner must be a JPEG, PNG, or WebP file.';
8314                    isValid = false;
8315                }
8316            }
8317
8318            return isValid;
8319        },
8320
8321        handleLogoChange(event) {
8322            const file = event.target.files[0];
8323            if (!file) return;
8324            delete this.errors.logo;
8325            if (this.logoPreview) URL.revokeObjectURL(this.logoPreview);
8326            this.logoFile = file;
8327            this.logoPreview = URL.createObjectURL(file);
8328            this.validateAll(); 
8329        },
8330
8331        handleBannerChange(event) {
8332            const file = event.target.files[0];
8333            if (!file) return;
8334            delete this.errors.banner;
8335            if (this.bannerPreview) URL.revokeObjectURL(this.bannerPreview);
8336            this.bannerFile = file;
8337            this.bannerPreview = URL.createObjectURL(file);
8338            this.validateAll();
8339        },
8340
8341        copyLink() {
8342            if (this.business && this.business.marketUrl) {
8343                navigator.clipboard.writeText(this.business.marketUrl);
8344                alert('Market link copied to clipboard');
8345            }
8346        },
8347
8348        async manageSubscriptions() {
8349            try {
8350                this.loading = true;
8351                const response = await fetch('/api/subscriptions/stripe-portal-session', {
8352                    method: 'POST',
8353                    headers: {
8354                        'Content-Type': 'application/json',
8355                        ...antiforgery
8356                    },
8357                    body: JSON.stringify({
8358                        returnUrl: window.location.href
8359                    })
8360                });
8361
8362                if (response.ok) {
8363                    const data = await response.json();
8364                    if (data.portalSessionUrl) {
8365                        window.location.assign(data.portalSessionUrl);
8366                    } else {
8367                        throw new Error('No portal URL returned');
8368                    }
8369                } else {
8370                    const errorData = await response.json().catch(() => ({}));
8371                    throw new Error(errorData.message || 'Could not open subscription management portal.');
8372                }
8373            } catch (error) {
8374                console.error('Error managing subscriptions:', error);
8375                this.notify('error', error.message || 'An error occurred while trying to manage subscriptions.');
8376            } finally {
8377                this.loading = false;
8378            }
8379        },
8380
8381        async submitForm() {
8382            if (this.loading) return;
8383
8384            if (!this.validateAll()) {
8385                this.notify('error', 'Please fix the errors before saving.');
8386                window.scrollTo({ top: 0, behavior: 'smooth' });
8387                return;
8388            }
8389
8390            this.loading = true;
8391
8392            try {
8393                // 1. Update Market Details
8394                const response = await fetch('/api/markets/me', {
8395                    method: 'PUT',
8396                    headers: {
8397                        'Content-Type': 'application/json',
8398                        ...antiforgery
8399                    },
8400                    body: JSON.stringify(this.form)
8401                });
8402
8403                if (!response.ok) {
8404                    if (response.status === 400) {
8405                        const errorData = await response.json();
8406                        if (errorData.errors) {
8407                            this.errors = window.AdminFormUtils.mapServerErrors(errorData.errors);
8408                            throw new Error('Validation failed. Please check the form.');
8409                        }
8410                    }
8411                    throw new Error('Failed to update market details');
8412                }
8413
8414                // 2. Upload Logo if changed
8415                const uploadWarnings = [];
8416                if (this.logoFile) {
8417                    const logoFormData = new FormData();
8418                    logoFormData.append('logo', this.logoFile);
8419
8420                    const logoResponse = await fetch('/api/markets/logo', {
8421                        method: 'PUT',
8422                        headers: {
8423                            ...antiforgery
8424                        },
8425                        body: logoFormData
8426                    });
8427
8428                    if (!logoResponse.ok) {
8429                        console.error('Failed to upload logo');
8430                        uploadWarnings.push('logo');
8431                    }
8432                }
8433
8434                // 3. Upload Banner if changed
8435                if (this.bannerFile) {
8436                    const bannerFormData = new FormData();
8437                    bannerFormData.append('banner', this.bannerFile);
8438
8439                    const bannerResponse = await fetch('/api/markets/banner', {
8440                        method: 'PUT',
8441                        headers: {
8442                            ...antiforgery
8443                        },
8444                        body: bannerFormData
8445                    });
8446
8447                    if (!bannerResponse.ok) {
8448                        console.error('Failed to upload banner');
8449                        uploadWarnings.push('banner');
8450                    }
8451                }
8452
8453                // Show warning if uploads failed, otherwise show success toast
8454                if (uploadWarnings.length > 0) {
8455                    this.notify('warning', `Market saved, but ${uploadWarnings.join(' and ')} upload failed. Please try again.`);
8456                } else {
8457                    this.notify('success', 'Market details updated successfully!');
8458                    // Optional: reload after a short delay if names changed to update URL/Title
8459                    if (this.form.name !== this.business.name) {
8460                        setTimeout(() => refreshPage(), 1500);
8461                    } else {
8462                        // Update local state to match form
8463                        this.business.name = this.form.name;
8464                    }
8465                }
8466
8467            } catch (error) {
8468                this.notify('error', error.message);
8469            } finally {
8470                this.loading = false;
8471            }
8472        }
8473    };
8474}
8475
8476// ========================================================================
8477// Admin product form (formerly admin-products-form.js, a window global)
8478// ========================================================================
8479
8480// Reads window.pageConfig ({ productId, isEditMode, externalId, existingProduct, existingImages,
8481// existingVideo, existingDigitalFiles, existingVariations, existingOptions, categories, colors, the
8482// API URLs, antiforgeryToken, antiforgeryHeaderName }), written by
8483// Components/Pages/Admin/Products/Form.razor. Needs window.AdminFormUtils (admin-forms.js).
8484const adminProductFormPage = (() => {
8485    function getApiHeaders({ isJson = true } = {}) {
8486        const headers = {};
8487        if (isJson) {
8488            headers['Content-Type'] = 'application/json';
8489        }
8490        const token = window.pageConfig?.antiforgeryToken;
8491        const headerName = window.pageConfig?.antiforgeryHeaderName;
8492        if (token && headerName) {
8493            headers[headerName] = token;
8494        }
8495        return headers;
8496    }
8497
8498    return function adminProductFormPage() {
8499        const config = window.pageConfig || {};
8500        const existingProduct = config.existingProduct || null;
8501
8502        const productTypeToInt = (value) => {
8503            if (typeof value === 'number') return value;
8504            const mapping = { Handmade: 1, FineArt: 2, Digital: 3, Default: 99 };
8505            return mapping[value] ?? 99;
8506        };
8507
8508        const enumToInt = (value, mapping, fallback) => {
8509            if (typeof value === 'number') return value;
8510            if (!value) return fallback;
8511            return mapping[value] ?? fallback;
8512        };
8513
8514        const toNumberOrNull = (value) => {
8515            if (value === '' || value === null || value === undefined) return null;
8516            const number = Number(value);
8517            return Number.isNaN(number) ? null : number;
8518        };
8519
8520        const dimensionUnitMap = {
8521            None: 1,
8522            Inches: 2,
8523            Feet: 3,
8524            Centimeters: 4,
8525            Millimeters: 5,
8526        };
8527
8528        const weightUnitMap = {
8529            None: 1,
8530            Pounds: 2,
8531            Ounces: 3,
8532            Grams: 4,
8533            Kilograms: 5,
8534        };
8535
8536        const volumeUnitMap = {
8537            None: 1,
8538            FluidOunces: 2,
8539            Pint: 3,
8540            Liter: 4,
8541            Milliliter: 5,
8542        };
8543
8544        const normalizeImage = (image) => ({
8545            id: image?.id ?? image?.Id,
8546            imageUrl: image?.imageUrl ?? image?.ImageUrl,
8547            sortOrder: image?.sortOrder ?? image?.SortOrder ?? 50,
8548        });
8549
8550        const normalizeVideo = (video) => {
8551            if (!video) return null;
8552            return {
8553                id: video?.id ?? video?.Id,
8554                uuid: video?.uuid ?? video?.Uuid,
8555                videoUrl: video?.videoUrl ?? video?.VideoUrl,
8556                posterImageUrl: video?.posterImageUrl ?? video?.PosterImageUrl ?? '',
8557                mimeType: video?.mimeType ?? video?.MimeType ?? 'video/mp4',
8558                fileSizeBytes: video?.fileSizeBytes ?? video?.FileSizeBytes ?? 0,
8559                sortOrder: video?.sortOrder ?? video?.SortOrder ?? 1,
8560                productId: video?.productId ?? video?.ProductId ?? null,
8561            };
8562        };
8563
8564        const cloneVideo = (video) => (video ? { ...video } : null);
8565
8566        const normalizeDigitalFile = (file) => ({
8567            id: file?.id ?? file?.Id,
8568            url: file?.url ?? file?.Url,
8569            fileName: file?.fileName ?? file?.FileName ?? 'Digital file',
8570            contentType: file?.contentType ?? file?.ContentType ?? '',
8571        });
8572
8573        const normalizeOptionValue = (value) => ({
8574            id: value?.id ?? value?.Id ?? crypto.randomUUID(),
8575            name: value?.name ?? value?.Name ?? '',
8576            imageUrl: value?.imageUrl ?? value?.ImageUrl ?? null,
8577        });
8578
8579        const normalizeOption = (option) => ({
8580            id: option?.id ?? option?.Id ?? crypto.randomUUID(),
8581            name: option?.name ?? option?.Name ?? '',
8582            values: (option?.values ?? option?.Values ?? []).map(normalizeOptionValue),
8583        });
8584
8585        const normalizeVariation = (variation) => ({
8586            clientId: crypto.randomUUID(),
8587            id: variation?.id ?? variation?.Id ?? null,
8588            active: variation?.active ?? variation?.Active ?? true,
8589            name: variation?.name ?? variation?.Name ?? '',
8590            price: variation?.price ?? variation?.Price ?? 0,
8591            quantity: variation?.quantity ?? variation?.Quantity ?? 0,
8592            sku: variation?.sku ?? variation?.Sku ?? '',
8593            optionValueIds: (variation?.optionValueIds ?? variation?.OptionValueIds ?? []).map((id) => id.toString()),
8594        });
8595
8596        const VIDEO_POSTER_CAPTURE_MAX_WIDTH = 960;
8597        const VIDEO_POSTER_ANALYSIS_SIZE = 96;
8598        const VIDEO_POSTER_SAMPLE_POINTS = [0.12, 0.22, 0.35, 0.5, 0.68, 0.82];
8599
8600        const stripExtension = (fileName) => {
8601            const lastDotIndex = fileName.lastIndexOf('.');
8602            return lastDotIndex > 0 ? fileName.slice(0, lastDotIndex) : fileName;
8603        };
8604
8605        const waitForVideoReady = (video) =>
8606            new Promise((resolve, reject) => {
8607                let settled = false;
8608                const timeoutId = window.setTimeout(() => {
8609                    finish(() => reject(new Error('Timed out while reading the uploaded video.')));
8610                }, 5000);
8611
8612                const cleanup = () => {
8613                    window.clearTimeout(timeoutId);
8614                    video.removeEventListener('loadeddata', handleReady);
8615                    video.removeEventListener('loadedmetadata', handleReady);
8616                    video.removeEventListener('error', handleError);
8617                };
8618
8619                const finish = (callback) => {
8620                    if (settled) return;
8621                    settled = true;
8622                    cleanup();
8623                    callback();
8624                };
8625
8626                const handleReady = () => {
8627                    if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
8628                        finish(resolve);
8629                    }
8630                };
8631
8632                const handleError = () => {
8633                    finish(() => reject(new Error('Unable to read the uploaded video.')));
8634                };
8635
8636                video.addEventListener('loadeddata', handleReady);
8637                video.addEventListener('loadedmetadata', handleReady);
8638                video.addEventListener('error', handleError);
8639
8640                if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
8641                    finish(resolve);
8642                    return;
8643                }
8644
8645                video.load();
8646            });
8647
8648        const seekVideo = (video, timeInSeconds) =>
8649            new Promise((resolve, reject) => {
8650                const duration = Number.isFinite(video.duration) ? video.duration : 0;
8651                const maxSeekTime = duration > 0.1 ? Math.max(duration - 0.05, 0) : timeInSeconds;
8652                const targetTime = Math.max(0, Math.min(timeInSeconds, maxSeekTime));
8653
8654                if (Math.abs(video.currentTime - targetTime) < 0.05) {
8655                    requestAnimationFrame(() => resolve(targetTime));
8656                    return;
8657                }
8658
8659                let settled = false;
8660                const timeoutId = window.setTimeout(() => {
8661                    finish(() => reject(new Error('Timed out while analyzing the uploaded video.')));
8662                }, 3000);
8663
8664                const cleanup = () => {
8665                    window.clearTimeout(timeoutId);
8666                    video.removeEventListener('seeked', handleSeeked);
8667                    video.removeEventListener('error', handleError);
8668                };
8669
8670                const finish = (callback) => {
8671                    if (settled) return;
8672                    settled = true;
8673                    cleanup();
8674                    callback();
8675                };
8676
8677                const handleSeeked = () => finish(() => resolve(targetTime));
8678                const handleError = () => finish(() => reject(new Error('Unable to analyze the uploaded video.')));
8679
8680                video.addEventListener('seeked', handleSeeked);
8681                video.addEventListener('error', handleError);
8682                video.currentTime = targetTime;
8683            });
8684
8685        const getPosterSampleTimes = (duration) => {
8686            if (!Number.isFinite(duration) || duration <= 0) {
8687                return [0];
8688            }
8689
8690            if (duration <= 1.5) {
8691                return [Number((duration / 2).toFixed(3))];
8692            }
8693
8694            const minTime = Math.min(0.3, duration * 0.15);
8695            const maxTime = Math.max(duration - 0.2, minTime);
8696            const seenTimes = new Set();
8697
8698            return VIDEO_POSTER_SAMPLE_POINTS
8699                .map((ratio) => Number(Math.min(Math.max(duration * ratio, minTime), maxTime).toFixed(3)))
8700                .filter((time) => {
8701                    if (seenTimes.has(time)) {
8702                        return false;
8703                    }
8704                    seenTimes.add(time);
8705                    return true;
8706                });
8707        };
8708
8709        const scorePosterFrame = (imageData) => {
8710            const { data, width, height } = imageData;
8711            if (!data?.length || width === 0 || height === 0) {
8712                return {
8713                    score: Number.NEGATIVE_INFINITY,
8714                    isUsable: false,
8715                };
8716            }
8717
8718            let brightnessSum = 0;
8719            let brightnessSquaredSum = 0;
8720            let saturationSum = 0;
8721            let edgeSum = 0;
8722            const previousRow = new Float32Array(width);
8723
8724            for (let y = 0; y < height; y += 1) {
8725                let previousLuma = 0;
8726                for (let x = 0; x < width; x += 1) {
8727                    const offset = (y * width + x) * 4;
8728                    const red = data[offset];
8729                    const green = data[offset + 1];
8730                    const blue = data[offset + 2];
8731                    const luma = red * 0.2126 + green * 0.7152 + blue * 0.0722;
8732                    const maxChannel = Math.max(red, green, blue);
8733                    const minChannel = Math.min(red, green, blue);
8734                    const saturation = maxChannel === 0 ? 0 : (maxChannel - minChannel) / maxChannel;
8735
8736                    brightnessSum += luma;
8737                    brightnessSquaredSum += luma * luma;
8738                    saturationSum += saturation;
8739
8740                    if (x > 0) {
8741                        edgeSum += Math.abs(luma - previousLuma);
8742                    }
8743
8744                    if (y > 0) {
8745                        edgeSum += Math.abs(luma - previousRow[x]);
8746                    }
8747
8748                    previousRow[x] = luma;
8749                    previousLuma = luma;
8750                }
8751            }
8752
8753            const pixelCount = width * height;
8754            const averageBrightness = brightnessSum / pixelCount;
8755            const averageSaturation = saturationSum / pixelCount;
8756            const contrast = Math.sqrt(
8757                Math.max(brightnessSquaredSum / pixelCount - averageBrightness * averageBrightness, 0)
8758            );
8759            const edgeCount = Math.max((width - 1) * height + (height - 1) * width, 1);
8760            const edgeDensity = edgeSum / edgeCount;
8761            const brightnessPenalty = Math.abs(averageBrightness - 138) * 0.65;
8762            const lowContrastPenalty = contrast < 18 ? (18 - contrast) * 4 : 0;
8763            const exposurePenalty = averageBrightness < 26 || averageBrightness > 235 ? 140 : 0;
8764
8765            return {
8766                score:
8767                    edgeDensity * 2.8 +
8768                    contrast * 1.6 +
8769                    averageSaturation * 90 -
8770                    brightnessPenalty -
8771                    lowContrastPenalty -
8772                    exposurePenalty,
8773                isUsable: exposurePenalty === 0 && contrast >= 12,
8774            };
8775        };
8776
8777        const canvasToBlob = (canvas, type, quality) =>
8778            new Promise((resolve, reject) => {
8779                canvas.toBlob((blob) => {
8780                    if (!blob) {
8781                        reject(new Error('Unable to create a video poster image.'));
8782                        return;
8783                    }
8784
8785                    resolve(blob);
8786                }, type, quality);
8787            });
8788
8789        const createSmartVideoPosterFile = async (videoFile) => {
8790            const objectUrl = URL.createObjectURL(videoFile);
8791            const video = document.createElement('video');
8792            video.preload = 'metadata';
8793            video.muted = true;
8794            video.playsInline = true;
8795            video.src = objectUrl;
8796
8797            try {
8798                await waitForVideoReady(video);
8799
8800                if (!video.videoWidth || !video.videoHeight) {
8801                    throw new Error('Video dimensions are unavailable.');
8802                }
8803
8804                const captureScale = Math.min(1, VIDEO_POSTER_CAPTURE_MAX_WIDTH / video.videoWidth);
8805                const captureCanvas = document.createElement('canvas');
8806                captureCanvas.width = Math.max(1, Math.round(video.videoWidth * captureScale));
8807                captureCanvas.height = Math.max(1, Math.round(video.videoHeight * captureScale));
8808
8809                const captureContext = captureCanvas.getContext('2d', { alpha: false });
8810                const analysisCanvas = document.createElement('canvas');
8811                analysisCanvas.width = VIDEO_POSTER_ANALYSIS_SIZE;
8812                analysisCanvas.height = VIDEO_POSTER_ANALYSIS_SIZE;
8813                const analysisContext = analysisCanvas.getContext('2d', { alpha: false, willReadFrequently: true });
8814
8815                if (!captureContext || !analysisContext) {
8816                    throw new Error('Canvas processing is unavailable.');
8817                }
8818
8819                const sampleTimes = getPosterSampleTimes(video.duration);
8820                let bestUsableFrame = null;
8821                let bestFallbackFrame = null;
8822
8823                for (const sampleTime of sampleTimes) {
8824                    await seekVideo(video, sampleTime);
8825                    captureContext.drawImage(video, 0, 0, captureCanvas.width, captureCanvas.height);
8826                    analysisContext.drawImage(captureCanvas, 0, 0, analysisCanvas.width, analysisCanvas.height);
8827
8828                    const frameScore = scorePosterFrame(
8829                        analysisContext.getImageData(0, 0, analysisCanvas.width, analysisCanvas.height)
8830                    );
8831
8832                    const candidate = {
8833                        time: sampleTime,
8834                        score: frameScore.score,
8835                        isUsable: frameScore.isUsable,
8836                    };
8837
8838                    if (!bestFallbackFrame || candidate.score > bestFallbackFrame.score) {
8839                        bestFallbackFrame = candidate;
8840                    }
8841
8842                    if (candidate.isUsable && (!bestUsableFrame || candidate.score > bestUsableFrame.score)) {
8843                        bestUsableFrame = candidate;
8844                    }
8845                }
8846
8847                const selectedFrame = bestUsableFrame || bestFallbackFrame;
8848                if (!selectedFrame) {
8849                    return null;
8850                }
8851
8852                await seekVideo(video, selectedFrame.time);
8853                captureContext.drawImage(video, 0, 0, captureCanvas.width, captureCanvas.height);
8854
8855                const posterBlob = await canvasToBlob(captureCanvas, 'image/jpeg', 0.82);
8856                const posterFileName = `${stripExtension(videoFile.name || 'product-video')}-poster.jpg`;
8857                return new File([posterBlob], posterFileName, { type: 'image/jpeg' });
8858            } finally {
8859                video.pause?.();
8860                video.removeAttribute('src');
8861                video.load?.();
8862                URL.revokeObjectURL(objectUrl);
8863            }
8864        };
8865
8866        return {
8867            isEditMode: !!config.isEditMode,
8868            productId: (config.productId && config.productId > 0) ? config.productId : (existingProduct?.id && existingProduct.id > 0 ? existingProduct.id : null),
8869            externalId: existingProduct?.externalId || config.externalId || null,
8870            approvalResourcesCount: existingProduct?.approvalResourcesCount ?? 0,
8871            digitalFilesCount: existingProduct?.digitalFilesCount ?? 0,
8872            isGeneratingDescription: false,
8873            showDescriptionModal: false,
8874            showVariationsModal: false,
8875            descriptionModalTrigger: null,
8876            variationModalTrigger: null,
8877            lightboxOpen: false,
8878            lightboxImage: '',
8879            lightboxTrigger: null,
8880            previousBodyOverflow: null,
8881            isDragging: false,
8882            isVideoDragging: false,
8883            dragIndex: null,
8884            dragOverIndex: null,
8885            variationDragIndex: null,
8886            variationDragOverIndex: null,
8887            isActionsMenuOpen: false,
8888            isSubmitting: false,
8889            isWorking: false,
8890            submitIntent: null,
8891            notifications: [],
8892            validationErrors: {},
8893            categorySearch: '',
8894            colorSearch: '',
8895            categoryChoices: (config.categories || []).map((c) => ({
8896                id: c.id ?? c.Id,
8897                label: c.label ?? c.Label,
8898            })),
8899            colorChoices: (config.colors || []).map((c) => ({
8900                id: c.id ?? c.Id,
8901                label: c.label ?? c.Label,
8902            })),
8903            isCategoryLoading: false,
8904            isColorLoading: false,
8905            images: (config.existingImages || []).map(normalizeImage),
8906            uploadingImages: [],
8907            savedVideo: normalizeVideo(config.existingVideo),
8908            video: normalizeVideo(config.existingVideo),
8909            removedSavedVideo: false,
8910            uploadingVideo: false,
8911            uploadingVideoMessage: 'Uploading video...',
8912            selectedApprovalFiles: [],
8913            selectedDigitalFiles: [],
8914            digitalFiles: (config.existingDigitalFiles || existingProduct?.digitalFiles || []).map(normalizeDigitalFile),
8915            options: (config.existingOptions || existingProduct?.options || []).map(normalizeOption),
8916            variations: (config.existingVariations || existingProduct?.variations || []).map(normalizeVariation),
8917            variationDraft: [],
8918            variationModalError: '',
8919            variationErrors: [],
8920            // Tracks whether the server currently has variations/options for this product,
8921            // so removing the last one still triggers a sync (deletion) on save.
8922            hasPersistedVariations:
8923                (config.existingVariations || existingProduct?.variations || []).length > 0 ||
8924                (config.existingOptions || existingProduct?.options || []).length > 0,
8925            activeSection: 'about',
8926            formData: {
8927                name: '',
8928                description: '',
8929                sku: '',
8930                price: 0,
8931                msrpPrice: null,
8932                quantity: 0,
8933                continueSellingWhenOutOfStock: false,
8934                featuredListings: false,
8935                active: 'true',
8936                personalization: false,
8937                personalizationNotes: '',
8938                requiresDateNeeded: false,
8939                minimumLeadTimeDays: 0,
8940                dateNeededHelpText: '',
8941                categoryId: null,
8942                colorId: null,
8943                productType: 99,
8944                domesticShippingCost: 0,
8945                additionalItemDomesticShippingCost: 0,
8946                length: null,
8947                width: null,
8948                height: null,
8949                weight: null,
8950                unit: 1,
8951                weightUnit: 1,
8952                volumeUnit: 1,
8953                isGiftWrapAvailable: false,
8954                isGiftMessageAvailable: false,
8955                giftWrapPrice: 0,
8956                isLocalPickupAvailable: false,
8957            },
8958            tags: {
8959                materials: [],
8960                hashTags: [],
8961            },
8962            tagDrafts: {
8963                materials: '',
8964                hashTags: '',
8965            },
8966            init() {
8967                if (existingProduct) {
8968                    this.formData = {
8969                        ...this.formData,
8970                        name: existingProduct.name || '',
8971                        description: existingProduct.description || '',
8972                        sku: existingProduct.sku || '',
8973                        price: existingProduct.price ?? 0,
8974                        msrpPrice: existingProduct.msrpPrice ?? null,
8975                        quantity: existingProduct.quantity ?? 0,
8976                        continueSellingWhenOutOfStock: !!existingProduct.continueSellingWhenOutOfStock,
8977                        featuredListings: !!existingProduct.featuredListings,
8978                        active: existingProduct.active ? 'true' : 'false',
8979                        personalization: !!existingProduct.personalization,
8980                        personalizationNotes: existingProduct.personalizationNotes || '',
8981                        requiresDateNeeded: !!existingProduct.requiresDateNeeded,
8982                        minimumLeadTimeDays: existingProduct.minimumLeadTimeDays ?? 0,
8983                        dateNeededHelpText: existingProduct.dateNeededHelpText || '',
8984                        categoryId: existingProduct.categoryId ?? null,
8985                        colorId: existingProduct.colorId ?? null,
8986                        productType: productTypeToInt(existingProduct.productType),
8987                        domesticShippingCost: existingProduct.domesticShippingCost ?? 0,
8988                        additionalItemDomesticShippingCost: existingProduct.additionalItemDomesticShippingCost ?? 0,
8989                        length: existingProduct.length ?? null,
8990                        width: existingProduct.width ?? null,
8991                        height: existingProduct.height ?? null,
8992                        weight: existingProduct.weight ?? null,
8993                        unit: enumToInt(existingProduct.unit, dimensionUnitMap, 1),
8994                        weightUnit: enumToInt(existingProduct.weightUnit, weightUnitMap, 1),
8995                        volumeUnit: enumToInt(existingProduct.volumeUnit, volumeUnitMap, 1),
8996                        isGiftWrapAvailable: !!existingProduct.isGiftWrapAvailable,
8997                        isGiftMessageAvailable: !!existingProduct.isGiftMessageAvailable,
8998                        giftWrapPrice: existingProduct.giftWrapPrice ?? 0,
8999                        isLocalPickupAvailable: !!existingProduct.isLocalPickupAvailable,
9000                    };
9001
9002                    this.tags.materials = existingProduct.materialsList || [];
9003                    this.tags.hashTags = existingProduct.hashtagsList || [];
9004
9005                    if (!this.categoryChoices.some((c) => c.id === existingProduct.categoryId)) {
9006                        this.categoryChoices.push({
9007                            id: existingProduct.categoryId,
9008                            label: existingProduct.categoryFullName || 'Selected category',
9009                        });
9010                    }
9011
9012                    if (existingProduct.colorId && !this.colorChoices.some((c) => c.id === existingProduct.colorId)) {
9013                        this.colorChoices.push({
9014                            id: existingProduct.colorId,
9015                            label: existingProduct.colorFullName || 'Selected color',
9016                        });
9017                    }
9018                }
9019
9020                this.setupScrollSpy();
9021                this.setupExternalSubmitListeners();
9022            },
9023            setupExternalSubmitListeners() {
9024                const form = document.getElementById('product-form');
9025                if (form) {
9026                    form.addEventListener('submit-draft', () => {
9027                        this.submitWithIntent('draft');
9028                    });
9029                    form.addEventListener('submit-publish', () => {
9030                        this.submitWithIntent('publish');
9031                    });
9032                }
9033            },
9034            get aliExpressUrl() {
9035                return this.externalId ? `https://www.aliexpress.us/item/${this.externalId}.html` : null;
9036            },
9037            get previewUrl() {
9038                // null, not '#': with <base href="/"> a '#' link opens the home page.
9039                if (!existingProduct?.businessId || !existingProduct?.slug) return null;
9040                return `/products/detail/${existingProduct.businessId}/${existingProduct.slug}?preview=true`;
9041            },
9042            get listedOn() {
9043                if (!existingProduct?.createdAt) return '';
9044                const date = new Date(existingProduct.createdAt);
9045                return `Listed on ${date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })}`;
9046            },
9047            get isActive() {
9048                return this.formData.active === true || this.formData.active === 'true';
9049            },
9050            get selectedCategoryLabel() {
9051                const match = this.categoryChoices.find((c) => c.id === this.formData.categoryId);
9052                return match?.label || 'Select category';
9053            },
9054            get selectedColorLabel() {
9055                const match = this.colorChoices.find((c) => c.id === this.formData.colorId);
9056                return match?.label || 'No color';
9057            },
9058            get productTypeLabel() {
9059                const mapping = {
9060                    1: 'Handmade',
9061                    2: 'Fine Art',
9062                    3: 'Digital',
9063                    99: 'Standard',
9064                };
9065                return mapping[this.formData.productType] || 'Standard';
9066            },
9067            get showApprovalResources() {
9068                return [1, 2, 3].includes(Number(this.formData.productType));
9069            },
9070            get approvalResourcesLabel() {
9071                if (this.formData.productType === 3) return 'Digital Download Assets';
9072                if (this.formData.productType === 1) return 'Handmade approval resources';
9073                if (this.formData.productType === 2) return 'Fine Art approval resources';
9074                return 'Approval resources';
9075            },
9076            get approvalResourcesDescription() {
9077                if (this.formData.productType === 3) return 'Upload digital download assets for your product.';
9078                if (this.formData.productType === 1) {
9079                    return `Upload resources that show your handmade process. This keeps our handmade community authentic. (${this.approvalResourcesCount} uploaded)`;
9080                }
9081                if (this.formData.productType === 2) {
9082                    return `Upload resources that show your handmade process. This keeps our Fine Art community authentic. (${this.approvalResourcesCount} uploaded)`;
9083                }
9084                return '';
9085            },
9086            formatCurrency(value) {
9087                const amount = Number(value ?? 0);
9088                return amount.toLocaleString(undefined, { style: 'currency', currency: 'USD' });
9089            },
9090            notify(type, message) {
9091                const notice = { id: crypto.randomUUID(), type, message };
9092                this.notifications.unshift(notice);
9093                setTimeout(() => {
9094                    this.notifications = this.notifications.filter((n) => n.id !== notice.id);
9095                }, 5000);
9096            },
9097            toggleActionsMenu() {
9098                this.isActionsMenuOpen = !this.isActionsMenuOpen;
9099            },
9100            closeActionsMenu() {
9101                this.isActionsMenuOpen = false;
9102            },
9103            async copyListingLink() {
9104                if (!existingProduct?.businessId || !existingProduct?.slug) return;
9105                const link = `${window.location.origin}/products/detail/${existingProduct.businessId}/${existingProduct.slug}`;
9106                try {
9107                    await navigator.clipboard.writeText(link);
9108                    this.notify('success', 'Listing link copied');
9109                } catch (error) {
9110                    console.warn(error);
9111                    this.notify('error', 'Unable to copy link');
9112                }
9113                this.closeActionsMenu();
9114            },
9115            async toggleActiveStatus() {
9116                if (!this.productId) return;
9117                this.isWorking = true;
9118                const url = `/api/products/${this.productId}/${this.isActive ? 'deactivate' : 'activate'}`;
9119                try {
9120                    const response = await fetch(url, {
9121                        method: 'PUT',
9122                        headers: getApiHeaders(),
9123                    });
9124                    if (!response.ok) throw new Error('Unable to update status');
9125                    this.formData.active = this.isActive ? 'false' : 'true';
9126                    this.notify('success', this.isActive ? 'Listing activated' : 'Listing deactivated');
9127                } catch (error) {
9128                    console.warn(error);
9129                    this.notify('error', 'Unable to update listing status');
9130                } finally {
9131                    this.isWorking = false;
9132                    this.closeActionsMenu();
9133                }
9134            },
9135            async deleteListing() {
9136                if (!this.productId) return;
9137                if (!confirm('Delete this listing? This cannot be undone.')) return;
9138                this.isWorking = true;
9139                try {
9140                    const response = await fetch(`/api/products/${this.productId}`, {
9141                        method: 'DELETE',
9142                        headers: getApiHeaders(),
9143                    });
9144                    if (!response.ok) throw new Error('Unable to delete');
9145                    navigateTo('/admin/products');
9146                } catch (error) {
9147                    console.warn(error);
9148                    this.notify('error', 'Unable to delete listing');
9149                } finally {
9150                    this.isWorking = false;
9151                    this.closeActionsMenu();
9152                }
9153            },
9154            async duplicateListing() {
9155                if (!this.productId) return;
9156                this.isWorking = true;
9157                try {
9158                    const response = await fetch(`/api/products/${this.productId}/duplicate`, {
9159                        method: 'POST',
9160                        headers: getApiHeaders(),
9161                    });
9162                    if (!response.ok) throw new Error('Unable to duplicate listing');
9163                    const data = await response.json();
9164                    if (data?.productId) {
9165                        navigateTo(`/admin/products/form/${data.productId}`);
9166                    }
9167                } catch (error) {
9168                    console.warn(error);
9169                    this.notify('error', 'Unable to duplicate listing');
9170                } finally {
9171                    this.isWorking = false;
9172                }
9173            },
9174            openDescriptionModal() {
9175                this.descriptionModalTrigger = document.activeElement;
9176                this.showDescriptionModal = true;
9177                this.$nextTick(() => {
9178                    this.$refs.descriptionModalConfirmButton?.focus();
9179                });
9180            },
9181            closeDescriptionModal() {
9182                this.showDescriptionModal = false;
9183                const trigger = this.descriptionModalTrigger;
9184                this.descriptionModalTrigger = null;
9185                this.$nextTick(() => {
9186                    trigger?.focus?.();
9187                });
9188            },
9189            async confirmGenerateDescription() {
9190                this.closeDescriptionModal();
9191                await this.generateDescription();
9192            },
9193            async fetchFirstImageUrl() {
9194                try {
9195                    if (this.images.length > 0) {
9196                        return this.images[0].imageUrl;
9197                    }
9198
9199                    if (!this.productId && this.externalId) {
9200                        const response = await fetch(`/api/products/transformed-aliexpress/${this.externalId}/images`);
9201                        if (response.ok) {
9202                            const data = await response.json();
9203                            return data?.images?.[0]?.imageUrl ?? null;
9204                        }
9205                    }
9206
9207                    if (this.productId) {
9208                        const response = await fetch(`/api/products/${this.productId}/images`);
9209                        if (!response.ok) return null;
9210                        const data = await response.json();
9211                        return data?.images?.[0]?.imageUrl ?? null;
9212                    }
9213                } catch (error) {
9214                    console.warn(error);
9215                }
9216                return null;
9217            },
9218            async generateDescription() {
9219                if (this.isGeneratingDescription) return;
9220                if (!this.formData.name.trim()) {
9221                    this.notify('error', 'Product title is required to generate a description.');
9222                    return;
9223                }
9224                this.isGeneratingDescription = true;
9225
9226                try {
9227                    const imageUrl = await this.fetchFirstImageUrl();
9228                    const response = await fetch(config.generateDescriptionUrl, {
9229                        method: 'POST',
9230                        headers: getApiHeaders(),
9231                        body: JSON.stringify({
9232                            title: this.formData.name,
9233                            currentDescription: this.formData.description,
9234                            imageUrl,
9235                        }),
9236                    });
9237
9238                    if (!response.ok) {
9239                        throw new Error('Unable to generate description');
9240                    }
9241
9242                    const data = await response.json();
9243                    if (data?.isSuccess && data?.enhancedDescription) {
9244                        this.formData.description = data.enhancedDescription;
9245                        this.notify('success', data?.wasEnhanced ? 'Description Enhanced' : 'Description processed');
9246                    } else {
9247                        throw new Error(data?.errorMessage || 'Unable to enhance description');
9248                    }
9249                } catch (error) {
9250                    console.warn(error);
9251                    this.notify('error', 'Unable to generate description');
9252                } finally {
9253                    this.isGeneratingDescription = false;
9254                }
9255            },
9256            handleDragOver() {
9257                this.isDragging = true;
9258            },
9259            handleDragLeave() {
9260                this.isDragging = false;
9261            },
9262            handleVideoDragOver() {
9263                this.isVideoDragging = true;
9264            },
9265            handleVideoDragLeave() {
9266                this.isVideoDragging = false;
9267            },
9268            async handleDrop(event) {
9269                this.isDragging = false;
9270                const files = Array.from(event.dataTransfer.files || []);
9271                await this.uploadImages(files);
9272            },
9273            async handleVideoDrop(event) {
9274                this.isVideoDragging = false;
9275                const files = Array.from(event.dataTransfer.files || []);
9276                await this.uploadVideo(files[0]);
9277            },
9278            async handleImageSelect(event) {
9279                const files = Array.from(event.target.files || []);
9280                await this.uploadImages(files);
9281                event.target.value = '';
9282            },
9283            async handleVideoSelect(event) {
9284                const [file] = Array.from(event.target.files || []);
9285                await this.uploadVideo(file);
9286                event.target.value = '';
9287            },
9288            handleApprovalFileChange(event) {
9289                const files = Array.from(event.target.files || []);
9290                this.selectedApprovalFiles = files.map((f) => ({
9291                    name: f.name,
9292                    size: f.size,
9293                }));
9294            },
9295            handleDigitalAssetChange(event) {
9296                const maxDigitalFiles = 10;
9297                let files = Array.from(event.target.files || []);
9298                if (files.length > maxDigitalFiles) {
9299                    this.notify('error', `Maximum ${maxDigitalFiles} files allowed per upload.`);
9300                    files = files.slice(0, maxDigitalFiles);
9301                }
9302                this.selectedDigitalFiles = files.map((f) => ({
9303                    name: f.name,
9304                    size: f.size,
9305                }));
9306            },
9307            async removeDigitalFile(fileId, index) {
9308                if (!this.productId || !fileId) return;
9309                if (!confirm('Remove this digital file? Customers will no longer have access.')) return;
9310                if (this.isWorking) return;
9311                this.isWorking = true;
9312                try {
9313                    const response = await fetch(
9314                        `/api/products/${this.productId}/digital-download-assets/${fileId}`,
9315                        {
9316                            method: 'DELETE',
9317                            headers: getApiHeaders(),
9318                        }
9319                    );
9320                    if (!response.ok) throw new Error('Unable to delete digital file');
9321                    this.digitalFiles.splice(index, 1);
9322                    this.digitalFilesCount = Math.max(0, this.digitalFilesCount - 1);
9323                    this.notify('success', 'Digital file removed');
9324                } catch (error) {
9325                    console.warn(error);
9326                    this.notify('error', 'Unable to remove digital file');
9327                } finally {
9328                    this.isWorking = false;
9329                }
9330            },
9331            startImageDrag(index) {
9332                this.dragIndex = index;
9333            },
9334            dragImageOver(index) {
9335                this.dragOverIndex = index;
9336            },
9337            dropImage(index) {
9338                if (this.dragIndex === null || this.dragIndex === index) return;
9339                const moved = this.images.splice(this.dragIndex, 1)[0];
9340                this.images.splice(index, 0, moved);
9341                this.dragIndex = null;
9342                this.dragOverIndex = null;
9343            },
9344            startVariationDrag(index) {
9345                this.variationDragIndex = index;
9346            },
9347            dragVariationOver(index) {
9348                this.variationDragOverIndex = index;
9349            },
9350            endVariationDrag() {
9351                this.variationDragIndex = null;
9352                this.variationDragOverIndex = null;
9353            },
9354            dropVariation(index) {
9355                if (this.variationDragIndex === null || this.variationDragIndex === index) {
9356                    this.endVariationDrag();
9357                    return;
9358                }
9359                const moved = this.variations.splice(this.variationDragIndex, 1)[0];
9360                this.variations.splice(index, 0, moved);
9361                this.endVariationDrag();
9362            },
9363            removeImage(index) {
9364                this.images.splice(index, 1);
9365            },
9366            isTemporaryVideo(video) {
9367                return !!video && !video.productId;
9368            },
9369            async deleteTemporaryVideo(videoId) {
9370                if (!videoId) return true;
9371                const response = await fetch(`${config.temporaryVideoDeleteUrl}/${videoId}`, {
9372                    method: 'DELETE',
9373                    headers: getApiHeaders(),
9374                });
9375
9376                if (!response.ok) {
9377                    const data = await response.json().catch(() => ({}));
9378                    throw new Error(data?.message || 'Unable to remove temporary video');
9379                }
9380
9381                return true;
9382            },
9383            async removeVideo() {
9384                if (!this.video) return;
9385
9386                const videoToRemove = this.video;
9387                if (this.isTemporaryVideo(videoToRemove)) {
9388                    try {
9389                        await this.deleteTemporaryVideo(videoToRemove.id);
9390                        this.video = this.removedSavedVideo ? null : cloneVideo(this.savedVideo);
9391                        this.notify('success', 'Video removed');
9392                    } catch (error) {
9393                        console.warn(error);
9394                        this.notify('error', error.message || 'Unable to remove video');
9395                    }
9396                    return;
9397                }
9398
9399                this.video = null;
9400                this.removedSavedVideo = !!this.savedVideo;
9401            },
9402            resolveVideoPoster() {
9403                return this.video?.posterImageUrl || this.images[0]?.imageUrl || '';
9404            },
9405            async uploadImages(files) {
9406                if (!files.length) return;
9407                const remainingSlots = 20 - this.images.length;
9408                const filesToUpload = files.slice(0, remainingSlots);
9409
9410                if (files.length > remainingSlots) {
9411                    this.notify('error', 'You can upload up to 20 images.');
9412                }
9413
9414                for (const file of filesToUpload) {
9415                    if (!file.type.startsWith('image/')) {
9416                        this.notify('error', 'Only image files are allowed.');
9417                        continue;
9418                    }
9419                    const placeholderId = crypto.randomUUID();
9420                    this.uploadingImages.push({ id: placeholderId, name: file.name });
9421                    try {
9422                        const formData = new FormData();
9423                        formData.append('file', file);
9424                        const response = await fetch(config.imageUploadUrl, {
9425                            method: 'POST',
9426                            headers: getApiHeaders({ isJson: false }),
9427                            body: formData,
9428                        });
9429                        if (!response.ok) throw new Error('Upload failed');
9430                        const data = await response.json();
9431                        this.images.push(normalizeImage(data));
9432                    } catch (error) {
9433                        console.warn(error);
9434                        this.notify('error', 'Unable to upload image');
9435                    } finally {
9436                        this.uploadingImages = this.uploadingImages.filter((p) => p.id !== placeholderId);
9437                    }
9438                }
9439            },
9440            async uploadVideo(file) {
9441                if (!file) return;
9442
9443                if (this.images.length === 0) {
9444                    this.notify('error', 'Upload at least one product image before adding a video.');
9445                    return;
9446                }
9447
9448                if (file.type !== 'video/mp4' && !file.name.toLowerCase().endsWith('.mp4')) {
9449                    this.notify('error', 'Only MP4 video files are allowed.');
9450                    return;
9451                }
9452
9453                if (file.size > 20 * 1024 * 1024) {
9454                    this.notify('error', 'Video must be 20 MB or smaller.');
9455                    return;
9456                }
9457
9458                this.uploadingVideo = true;
9459                try {
9460                    this.uploadingVideoMessage = 'Selecting a cover frame...';
9461                    let posterFile = null;
9462                    try {
9463                        posterFile = await createSmartVideoPosterFile(file);
9464                    } catch (error) {
9465                        console.warn(error);
9466                    }
9467
9468                    if (!posterFile) {
9469                        this.notify(
9470                            'error',
9471                            'We could not create a cover image for this video. Please try a different MP4 or browser.'
9472                        );
9473                        return;
9474                    }
9475
9476                    const previousVideo = this.video ? cloneVideo(this.video) : null;
9477                    this.uploadingVideoMessage = 'Uploading video and cover...';
9478                    const formData = new FormData();
9479                    formData.append('file', file);
9480                    formData.append('posterFile', posterFile);
9481                    const response = await fetch(config.videoUploadUrl, {
9482                        method: 'POST',
9483                        headers: getApiHeaders({ isJson: false }),
9484                        body: formData,
9485                    });
9486                    if (!response.ok) {
9487                        const data = await response.json().catch(() => ({}));
9488                        throw new Error(data?.message || 'Upload failed');
9489                    }
9490
9491                    const data = await response.json();
9492                    this.video = normalizeVideo(data);
9493
9494                    if (previousVideo && this.isTemporaryVideo(previousVideo) && previousVideo.id !== this.video?.id) {
9495                        try {
9496                            await this.deleteTemporaryVideo(previousVideo.id);
9497                        } catch (cleanupError) {
9498                            console.warn(cleanupError);
9499                        }
9500                    }
9501                } catch (error) {
9502                    console.warn(error);
9503                    this.notify('error', error.message || 'Unable to upload video');
9504                } finally {
9505                    this.uploadingVideo = false;
9506                    this.uploadingVideoMessage = 'Uploading video...';
9507                }
9508            },
9509            openVariationModal() {
9510                this.variationModalTrigger = document.activeElement;
9511                this.variationModalError = '';
9512                this.variationDraft = this.options.map((option) => ({
9513                    ...option,
9514                    values: option.values.map((value) => ({ ...value })),
9515                }));
9516                if (!this.variationDraft.length) {
9517                    this.addVariationOption();
9518                }
9519                this.showVariationsModal = true;
9520                this.$nextTick(() => {
9521                    const firstInput = this.$refs.variationsModalCard?.querySelector('[data-variation-option="0"]');
9522                    firstInput?.focus();
9523                });
9524            },
9525            closeVariationModal() {
9526                this.showVariationsModal = false;
9527                this.variationModalError = '';
9528                const trigger = this.variationModalTrigger;
9529                this.variationModalTrigger = null;
9530                this.$nextTick(() => {
9531                    trigger?.focus?.();
9532                });
9533            },
9534            trapModalFocus(event, modalRefName) {
9535                const modal = this.$refs[modalRefName];
9536                if (!modal) return;
9537                const focusable = Array.from(
9538                    modal.querySelectorAll(
9539                        'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
9540                    )
9541                ).filter((element) => {
9542                    if (!(element instanceof HTMLElement)) return false;
9543                    if (element.hasAttribute('disabled') || element.getAttribute('aria-hidden') === 'true') return false;
9544                    if (element.tabIndex < 0) return false;
9545                    const style = window.getComputedStyle(element);
9546                    if (style.display === 'none' || style.visibility === 'hidden') return false;
9547                    if (element.getClientRects().length === 0) return false;
9548                    return true;
9549                });
9550                if (!focusable.length) return;
9551
9552                const first = focusable[0];
9553                const last = focusable[focusable.length - 1];
9554                const active = document.activeElement;
9555
9556                if (event.shiftKey && active === first) {
9557                    event.preventDefault();
9558                    last.focus();
9559                    return;
9560                }
9561
9562                if (!event.shiftKey && active === last) {
9563                    event.preventDefault();
9564                    first.focus();
9565                }
9566            },
9567            getSafeExternalUrl(url) {
9568                if (!url || typeof url !== 'string') return null;
9569                try {
9570                    const parsedUrl = new URL(url);
9571                    if (parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:') {
9572                        return parsedUrl.href;
9573                    }
9574                } catch {
9575                    // relative paths and invalid values fall through
9576                }
9577                return null;
9578            },
9579            addVariationOption() {
9580                if (this.variationDraft.length >= 3) return;
9581                this.variationDraft.push({
9582                    id: crypto.randomUUID(),
9583                    name: '',
9584                    values: [],
9585                });
9586            },
9587            removeVariationOption(index) {
9588                this.variationDraft.splice(index, 1);
9589            },
9590            addVariationValue(optionIndex) {
9591                this.variationDraft[optionIndex].values.push({
9592                    id: crypto.randomUUID(),
9593                    name: '',
9594                    imageUrl: null,
9595                });
9596            },
9597            removeVariationValue(optionIndex, valueIndex) {
9598                this.variationDraft[optionIndex].values.splice(valueIndex, 1);
9599            },
9600            selectVariationImage(optionIndex, valueIndex, imageUrl) {
9601                this.variationDraft[optionIndex].values[valueIndex].imageUrl = imageUrl;
9602            },
9603            clearVariationImage(optionIndex, valueIndex) {
9604                this.variationDraft[optionIndex].values[valueIndex].imageUrl = null;
9605            },
9606            handleVariationOptionKeydown(event, optionIndex) {
9607                if (event.key === 'Enter') {
9608                    event.preventDefault();
9609                    this.addVariationValue(optionIndex);
9610                    this.$nextTick(() => {
9611                        const valueIndex = this.variationDraft[optionIndex].values.length - 1;
9612                        const input = document.querySelector(`[data-variation-value="${optionIndex}-${valueIndex}"]`);
9613                        input?.focus();
9614                    });
9615                } else if (event.key === 'Backspace' && !event.target.value && this.variationDraft[optionIndex].values.length === 0) {
9616                    event.preventDefault();
9617                    this.removeVariationOption(optionIndex);
9618                    this.$nextTick(() => {
9619                        const prevIndex = optionIndex - 1;
9620                        if (prevIndex >= 0) {
9621                            const prevOptionInput = document.querySelector(`[data-variation-option="${prevIndex}"]`);
9622                            prevOptionInput?.focus();
9623                        } else if (this.variationDraft.length > 0) {
9624                            const firstInput = document.querySelector('[data-variation-option="0"]');
9625                            firstInput?.focus();
9626                        }
9627                    });
9628                }
9629            },
9630            handleVariationValueKeydown(event, optionIndex, valueIndex) {
9631                if (event.key === 'Enter') {
9632                    event.preventDefault();
9633                    this.addVariationValue(optionIndex);
9634                    this.$nextTick(() => {
9635                        const newIndex = this.variationDraft[optionIndex].values.length - 1;
9636                        const input = document.querySelector(`[data-variation-value="${optionIndex}-${newIndex}"]`);
9637                        input?.focus();
9638                    });
9639                } else if (event.key === 'Backspace' && !event.target.value) {
9640                    event.preventDefault();
9641                    this.removeVariationValue(optionIndex, valueIndex);
9642                    this.$nextTick(() => {
9643                        const prevValueIndex = valueIndex - 1;
9644                        if (prevValueIndex >= 0) {
9645                            const prevInput = document.querySelector(`[data-variation-value="${optionIndex}-${prevValueIndex}"]`);
9646                            prevInput?.focus();
9647                        } else {
9648                            const optionInput = document.querySelector(`[data-variation-option="${optionIndex}"]`);
9649                            optionInput?.focus();
9650                        }
9651                    });
9652                } else if (event.key === 'ArrowDown' && event.altKey) {
9653                    event.preventDefault();
9654                    const nextInput = document.querySelector(`[data-variation-value="${optionIndex}-${valueIndex + 1}"]`);
9655                    nextInput?.focus();
9656                } else if (event.key === 'ArrowUp' && event.altKey) {
9657                    event.preventDefault();
9658                    if (valueIndex > 0) {
9659                        const prevInput = document.querySelector(`[data-variation-value="${optionIndex}-${valueIndex - 1}"]`);
9660                        prevInput?.focus();
9661                    } else {
9662                        const optionInput = document.querySelector(`[data-variation-option="${optionIndex}"]`);
9663                        optionInput?.focus();
9664                    }
9665                }
9666            },
9667            handleVariationModalKeydown(event) {
9668                if (event.key === 'Tab') {
9669                    this.trapModalFocus(event, 'variationsModalCard');
9670                } else if (event.key === 'Escape') {
9671                    event.preventDefault();
9672                    this.closeVariationModal();
9673                } else if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
9674                    event.preventDefault();
9675                    this.saveVariations();
9676                }
9677            },
9678            saveVariations() {
9679                const trimmedOptions = this.variationDraft.map((option) => ({
9680                    ...option,
9681                    name: option.name.trim(),
9682                    values: option.values
9683                        .map((value) => ({
9684                            ...value,
9685                            name: value.name.trim(),
9686                        }))
9687                        .filter((value) => value.name.length > 0),
9688                }));
9689
9690                // Block saving when an option has partial data instead of silently
9691                // discarding it. Completely empty rows are dropped without warning.
9692                for (let index = 0; index < trimmedOptions.length; index++) {
9693                    const option = trimmedOptions[index];
9694                    if (!option.name && option.values.length > 0) {
9695                        this.variationModalError = `Give option ${index + 1} a name before saving, or remove its values.`;
9696                        return;
9697                    }
9698                    if (option.name && option.values.length === 0) {
9699                        this.variationModalError = `Add at least one value to "${option.name}", or remove the option.`;
9700                        return;
9701                    }
9702                }
9703
9704                const cleanedOptions = trimmedOptions.filter(
9705                    (option) => option.name.length > 0 && option.values.length > 0
9706                );
9707
9708                this.variationModalError = '';
9709                this.options = cleanedOptions;
9710                this.variations = this.buildVariationsFromOptions(cleanedOptions);
9711                this.closeVariationModal();
9712            },
9713            buildVariationsFromOptions(options) {
9714                if (!options.length) return [];
9715
9716                const existingMap = new Map();
9717                const existingOrder = new Map();
9718                this.variations.forEach((variation, position) => {
9719                    const key = variation.optionValueIds.slice().sort().join('|');
9720                    existingMap.set(key, variation);
9721                    existingOrder.set(key, position);
9722                });
9723
9724                const valueSets = options.map((option) => option.values);
9725                const combos = valueSets.reduce(
9726                    (acc, values) =>
9727                        acc.flatMap((combo) => values.map((value) => combo.concat(value))),
9728                    [[]]
9729                );
9730
9731                const usedSkus = new Set();
9732                const built = combos.map((combo, index) => {
9733                    const optionValueIds = combo.map((value) => value.id.toString());
9734                    const key = optionValueIds.slice().sort().join('|');
9735                    const existing = existingMap.get(key);
9736                    const name = combo.map((value) => value.name).join(' / ');
9737                    const sku = existing?.sku || this.generateVariationSku(combo, usedSkus);
9738                    usedSkus.add(sku.toLowerCase());
9739                    return {
9740                        variation: {
9741                            clientId: existing?.clientId || crypto.randomUUID(),
9742                            id: existing?.id ?? null,
9743                            // Keep deactivated variations inactive; the server deactivates
9744                            // (rather than deletes) variations referenced by orders/carts.
9745                            active: existing?.active ?? true,
9746                            name,
9747                            price: existing?.price ?? this.formData.price ?? 0,
9748                            quantity: existing?.quantity ?? this.formData.quantity ?? 0,
9749                            sku,
9750                            optionValueIds,
9751                        },
9752                        // Preserve the seller's existing drag-and-drop order; brand-new
9753                        // combinations keep their cartesian order and fall after existing ones.
9754                        sortKey: existingOrder.has(key) ? existingOrder.get(key) : this.variations.length + index,
9755                    };
9756                });
9757
9758                return built.sort((a, b) => a.sortKey - b.sortKey).map((entry) => entry.variation);
9759            },
9760            generateVariationSku(combo, usedSkus) {
9761                const slugify = window.AdminFormUtils?.slugify ?? ((text) => (text || '').toString().trim().toLowerCase().replace(/\s+/g, '-'));
9762                const base = (this.formData.sku || '').trim() || slugify(this.formData.name).slice(0, 40) || 'VAR';
9763                const comboSlug = slugify(combo.map((value) => value.name).join('-'));
9764                let sku = `${base}-${comboSlug}`.slice(0, 150);
9765
9766                // Combination slugs are stable across reorders; only true collisions
9767                // (e.g. same value names after slugification) need a numeric suffix.
9768                let attempt = 2;
9769                while (usedSkus.has(sku.toLowerCase())) {
9770                    sku = `${`${base}-${comboSlug}`.slice(0, 145)}-${attempt}`;
9771                    attempt++;
9772                }
9773                return sku;
9774            },
9775            getOptionImage(valueId) {
9776                for (const option of this.options) {
9777                    const value = option.values.find((v) => v.id.toString() === valueId.toString());
9778                    if (value?.imageUrl) return value.imageUrl;
9779                }
9780                return null;
9781            },
9782            removeTag(type, index) {
9783                this.tags[type].splice(index, 1);
9784            },
9785            commitTag(type) {
9786                const draft = this.tagDrafts[type].trim();
9787                if (!draft) return;
9788                const pieces = draft
9789                    .split(',')
9790                    .map((value) => value.trim())
9791                    .filter((value) => value.length > 0);
9792                pieces.forEach((piece) => {
9793                    if (!this.tags[type].includes(piece)) {
9794                        this.tags[type].push(piece);
9795                    }
9796                });
9797                this.tagDrafts[type] = '';
9798            },
9799            handleTagKeydown(event, type) {
9800                if (event.key === 'Enter' || event.key === ',') {
9801                    event.preventDefault();
9802                    this.commitTag(type);
9803                }
9804            },
9805            async searchCategories() {
9806                this.isCategoryLoading = true;
9807                try {
9808                    const query = encodeURIComponent(this.categorySearch || '');
9809                    const response = await fetch(`/api/categories/choices?searchTerm=${query}`);
9810                    if (!response.ok) throw new Error('Search failed');
9811                    const data = await response.json();
9812                    this.categoryChoices = (data.choices || []).map((c) => ({
9813                        id: c.id ?? c.Id,
9814                        label: c.label ?? c.Label,
9815                    }));
9816
9817                    if (this.formData.categoryId && !this.categoryChoices.some((c) => c.id === this.formData.categoryId)) {
9818                        this.categoryChoices.push({
9819                            id: this.formData.categoryId,
9820                            label: existingProduct?.categoryFullName || 'Selected category',
9821                        });
9822                    }
9823                } catch (error) {
9824                    console.warn(error);
9825                } finally {
9826                    this.isCategoryLoading = false;
9827                }
9828            },
9829            async searchColors() {
9830                this.isColorLoading = true;
9831                try {
9832                    const query = encodeURIComponent(this.colorSearch || '');
9833                    const response = await fetch(`/api/colors/choices?searchTerm=${query}`);
9834                    if (!response.ok) throw new Error('Search failed');
9835                    const data = await response.json();
9836                    this.colorChoices = (data.choices || []).map((c) => ({
9837                        id: c.id ?? c.Id,
9838                        label: c.label ?? c.Label,
9839                    }));
9840
9841                    if (this.formData.colorId && !this.colorChoices.some((c) => c.id === this.formData.colorId)) {
9842                        this.colorChoices.push({
9843                            id: this.formData.colorId,
9844                            label: existingProduct?.colorFullName || 'Selected color',
9845                        });
9846                    }
9847                } catch (error) {
9848                    console.warn(error);
9849                } finally {
9850                    this.isColorLoading = false;
9851                }
9852            },
9853            selectCategory(category) {
9854                this.formData.categoryId = category.id;
9855            },
9856            selectColor(color) {
9857                this.formData.colorId = color.id;
9858            },
9859            submitWithIntent(intent) {
9860                this.submitIntent = intent;
9861                this.handleSubmit();
9862            },
9863            async handleSubmit() {
9864                if (this.isSubmitting) return;
9865                this.isSubmitting = true;
9866                this.validationErrors = {};
9867
9868                try {
9869                    const payload = {
9870                        productId: this.productId,
9871                        externalId: this.externalId,
9872                        active: this.resolveActiveState(),
9873                        name: this.formData.name,
9874                        description: this.formData.description,
9875                        sku: this.formData.sku,
9876                        quantity: Number(this.formData.quantity ?? 0),
9877                        continueSellingWhenOutOfStock: !!this.formData.continueSellingWhenOutOfStock,
9878                        featuredListings: !!this.formData.featuredListings,
9879                        price: Number(this.formData.price ?? 0),
9880                        msrpPrice: this.formData.msrpPrice ? Number(this.formData.msrpPrice) : null,
9881                        personalization: !!this.formData.personalization,
9882                        personalizationNotes: this.formData.personalizationNotes,
9883                        domesticShippingCost: Number(this.formData.domesticShippingCost ?? 0),
9884                        additionalItemDomesticShippingCost: Number(this.formData.additionalItemDomesticShippingCost ?? 0),
9885                        length: toNumberOrNull(this.formData.length),
9886                        width: toNumberOrNull(this.formData.width),
9887                        height: toNumberOrNull(this.formData.height),
9888                        weight: toNumberOrNull(this.formData.weight),
9889                        unit: Number(this.formData.unit ?? 1),
9890                        weightUnit: Number(this.formData.weightUnit ?? 1),
9891                        volumeUnit: Number(this.formData.volumeUnit ?? 1),
9892                        isGiftWrapAvailable: !!this.formData.isGiftWrapAvailable,
9893                        isGiftMessageAvailable: !!this.formData.isGiftMessageAvailable,
9894                        giftWrapPrice: this.formData.isGiftWrapAvailable ? Number(this.formData.giftWrapPrice ?? 0) : 0,
9895                        isLocalPickupAvailable:
9896                            Number(this.formData.productType ?? 99) === 3 ? false : !!this.formData.isLocalPickupAvailable,
9897                        material: this.tags.materials.join(', '),
9898                        hashTags: this.tags.hashTags.join(', '),
9899                        categoryId: Number(this.formData.categoryId ?? 0),
9900                        colorId: this.formData.colorId ? Number(this.formData.colorId) : null,
9901                        productType: Number(this.formData.productType ?? 99),
9902                        requiresDateNeeded: !!this.formData.requiresDateNeeded,
9903                        minimumLeadTimeDays: Number(this.formData.minimumLeadTimeDays ?? 0),
9904                        dateNeededHelpText: this.formData.dateNeededHelpText,
9905                        imageIds: this.images.map((image) => image.id),
9906                        videoId: this.video?.id ?? null,
9907                    };
9908
9909                    const response = await fetch(config.productsApiUrl, {
9910                        method: 'PUT',
9911                        headers: getApiHeaders(),
9912                        body: JSON.stringify(payload),
9913                    });
9914
9915                    if (response.status === 401) {
9916                        navigateTo('/account/login');
9917                        return;
9918                    }
9919
9920                    if (response.status === 400) {
9921                        const data = await response.json();
9922                        this.validationErrors = window.AdminFormUtils.mapServerErrors(data?.errors);
9923                        this.notify('error', data?.message || 'Please fix the highlighted errors.');
9924                        return;
9925                    }
9926
9927                    if (!response.ok) {
9928                        throw new Error('Unable to save product');
9929                    }
9930
9931                    const data = await response.json();
9932                    const savedProductId = data?.productId ?? this.productId;
9933                    this.productId = savedProductId;
9934                    if (this.video) {
9935                        this.video.productId = savedProductId;
9936                        this.savedVideo = cloneVideo(this.video);
9937                    } else {
9938                        this.savedVideo = null;
9939                    }
9940                    this.removedSavedVideo = false;
9941
9942                    // Sync when there is anything to save OR anything previously saved
9943                    // (removing the last variation must still reach the server).
9944                    if (this.variations.length || this.options.length || this.hasPersistedVariations) {
9945                        const variationsResponse = await fetch(`/api/products/${savedProductId}/variations`, {
9946                            method: 'PUT',
9947                            headers: getApiHeaders(),
9948                            body: JSON.stringify({
9949                                variations: this.variations.map((variation) => ({
9950                                    id: variation.id,
9951                                    name: variation.name,
9952                                    price: Number(variation.price ?? 0),
9953                                    quantity: Number(variation.quantity ?? 0),
9954                                    sku: variation.sku,
9955                                    active: variation.active ?? true,
9956                                    optionValueIds: variation.optionValueIds,
9957                                })),
9958                                options: this.options.map((option) => ({
9959                                    id: option.id,
9960                                    name: option.name,
9961                                    values: option.values.map((value) => ({
9962                                        id: value.id,
9963                                        name: value.name,
9964                                        imageUrl: value.imageUrl,
9965                                    })),
9966                                })),
9967                            }),
9968                        });
9969
9970                        if (variationsResponse.status === 400) {
9971                            let messages = [];
9972                            try {
9973                                const errorData = await variationsResponse.json();
9974                                messages = Object.values(errorData?.errors || {}).flat();
9975                            } catch {
9976                                // Ignore response parsing errors
9977                            }
9978                            this.variationErrors = messages.length ? messages : ['Please review your variations and try again.'];
9979                            this.notify('error', 'Your product was saved, but the variations were not. Fix the variation errors and save again.');
9980                            this.scrollToSection('variations');
9981                            return;
9982                        }
9983
9984                        if (!variationsResponse.ok) {
9985                            this.notify('error', 'Your product was saved, but there was an error saving your variations. Please try again.');
9986                            return;
9987                        }
9988
9989                        this.variationErrors = [];
9990                        this.hasPersistedVariations = this.variations.length > 0 || this.options.length > 0;
9991
9992                        try {
9993                            const variationsResult = await variationsResponse.json();
9994                            if (variationsResult?.deactivatedVariationCount > 0) {
9995                                // Shown on the products list after the redirect below;
9996                                // a toast here would be destroyed by the navigation.
9997                                sessionStorage.setItem(
9998                                    'adminProductsFlash',
9999                                    JSON.stringify({
10000                                        type: 'warning',
10001                                        message:
10002                                            'Some removed variations were kept (inactive) because they are referenced by orders, carts, or gift registries.',
10003                                    })
10004                                );
10005                            }
10006                        } catch {
10007                            // Ignore response parsing errors
10008                        }
10009                    }
10010
10011                    const isDigitalAssets = this.formData.productType === 3;
10012                    const uploadFiles = isDigitalAssets
10013                        ? (this.$refs.digitalAssetsInput?.files || [])
10014                        : (this.$refs.approvalResourcesInput?.files || []);
10015                    if (uploadFiles.length > 0) {
10016                        const approvalFormData = new FormData();
10017                        for (const file of uploadFiles) {
10018                            approvalFormData.append('files', file);
10019                        }
10020
10021                        const uploadUrl = isDigitalAssets
10022                            ? `/api/products/${savedProductId}/digital-download-assets`
10023                            : `/api/products/${savedProductId}/approval-resources`;
10024                        const approvalResponse = await fetch(uploadUrl, {
10025                            method: 'POST',
10026                            headers: getApiHeaders({ isJson: false }),
10027                            body: approvalFormData,
10028                        });
10029
10030                        if (!approvalResponse.ok) {
10031                            const label = isDigitalAssets ? 'digital assets' : 'approval resources';
10032                            this.notify('error', `Unable to upload ${label}.`);
10033                            return;
10034                        }
10035                    }
10036
10037                    this.notify('success', this.isEditMode ? 'Listing Updated' : 'Listing Created');
10038                    navigateTo('/admin/products');
10039                } catch (error) {
10040                    console.warn(error);
10041                    this.notify('error', 'Unable to save listing.');
10042                } finally {
10043                    this.isSubmitting = false;
10044                    this.submitIntent = null;
10045                }
10046            },
10047            resolveActiveState() {
10048                if (this.submitIntent === 'draft') return false;
10049                if (!this.isEditMode && this.submitIntent === 'publish') return true;
10050                return this.isActive;
10051            },
10052            setupScrollSpy() {
10053                const sectionIds = ['about', 'price-and-inventory', 'variations', 'details', 'digital-assets', 'shipping'];
10054                const observer = new IntersectionObserver(
10055                    (entries) => {
10056                        entries.forEach((entry) => {
10057                            if (entry.isIntersecting) {
10058                                this.activeSection = entry.target.id;
10059                            }
10060                        });
10061                    },
10062                    { rootMargin: '-20% 0px -70% 0px' }
10063                );
10064
10065                sectionIds.forEach((id) => {
10066                    const section = document.getElementById(id);
10067                    if (section) observer.observe(section);
10068                });
10069                onUnmount(this, () => observer.disconnect());
10070            },
10071            scrollToSection(sectionId) {
10072                const section = document.getElementById(sectionId);
10073                if (!section) return;
10074                section.scrollIntoView({ behavior: 'smooth', block: 'start' });
10075                this.activeSection = sectionId;
10076            },
10077            openImageLightbox(imageUrl) {
10078                if (!imageUrl) return;
10079                this.lightboxTrigger = document.activeElement;
10080                this.previousBodyOverflow = document.body.style.overflow;
10081                this.lightboxImage = imageUrl;
10082                this.lightboxOpen = true;
10083                document.body.style.overflow = 'hidden';
10084                this.$nextTick(() => {
10085                    this.$refs.lightboxCloseBtn?.focus();
10086                });
10087            },
10088            closeLightbox() {
10089                this.lightboxOpen = false;
10090                this.lightboxImage = '';
10091                document.body.style.overflow = this.previousBodyOverflow ?? '';
10092                this.previousBodyOverflow = null;
10093                this.lightboxTrigger?.focus();
10094                this.lightboxTrigger = null;
10095            },
10096        };
10097    };
10098})();
10099
10100// ========================================================================
10101// Admin CSV import (formerly inline in Pages/Admin/Products/AiCsvImport.cshtml)
10102// ========================================================================
10103
10104// Reads window.pageConfig ({ previewApiUrl, confirmApiUrl, jobStatusApiBaseUrl, maxFileSize,
10105// targetFields, antiforgeryToken, antiforgeryHeaderName }), written by
10106// Components/Pages/Admin/Products/AiCsvImport.razor. A running import's job id is kept in
10107// localStorage so the page resumes polling after a reload.
10108const adminCsvImportPage = (() => {
10109    const ACTIVE_JOB_STORAGE_KEY = 'csvImportActiveJobId';
10110
10111    return function adminCsvImportPage() {
10112        return {
10113            selectedFile: null,
10114            uploading: false,
10115            progress: 0,
10116            results: null,
10117            preview: null,
10118            jobId: null,
10119            phase: 'upload',
10120            _abortController: null,
10121            dragover: false,
10122            existingProductHandling: 'create_new',
10123            uploadError: null,
10124            mappingSelections: {},
10125            originalMappings: {},
10126            mappingGroups: { required: [], mapped: [], unmapped: [] },
10127
10128            init() {
10129                this._abortController = new AbortController();
10130                this.resumeActiveJobIfAny();
10131            },
10132
10133            destroy() {
10134                this._abortController?.abort();
10135            },
10136
10137            resumeActiveJobIfAny() {
10138                let savedJobId = null;
10139                try { savedJobId = localStorage.getItem(ACTIVE_JOB_STORAGE_KEY); } catch { /* storage unavailable */ }
10140
10141                if (!savedJobId || !/^[a-f0-9]{32}$/i.test(savedJobId)) return;
10142
10143                this.jobId = savedJobId;
10144                this.phase = 'importing';
10145                this.uploading = true;
10146                this.progress = 5;
10147                this.pollJobStatus({ resumed: true });
10148            },
10149
10150            saveActiveJob() {
10151                try { localStorage.setItem(ACTIVE_JOB_STORAGE_KEY, this.jobId); } catch { /* storage unavailable */ }
10152            },
10153
10154            clearActiveJob() {
10155                try { localStorage.removeItem(ACTIVE_JOB_STORAGE_KEY); } catch { /* storage unavailable */ }
10156            },
10157
10158            handleFileSelect(event) {
10159                const file = event.target.files[0];
10160                this.validateAndSetFile(file);
10161            },
10162
10163            handleDrop(event) {
10164                this.dragover = false;
10165                const file = event.dataTransfer.files[0];
10166                this.validateAndSetFile(file);
10167            },
10168
10169            validateAndSetFile(file) {
10170                if (!file) return;
10171
10172                this.uploadError = null;
10173
10174                if (!file.name.toLowerCase().endsWith('.csv')) {
10175                    this.uploadError = 'Please select a CSV file.';
10176                    return;
10177                }
10178
10179                if (file.size > window.pageConfig.maxFileSize) {
10180                    this.uploadError = 'File size must be less than 10MB.';
10181                    return;
10182                }
10183
10184                this.selectedFile = file;
10185                this.results = null;
10186                this.preview = null;
10187                this.jobId = null;
10188                this.phase = 'upload';
10189            },
10190
10191            async readJson(response) {
10192                try { return await response.json(); } catch { return null; }
10193            },
10194
10195            extractErrorMessage(data, response, fallback) {
10196                if (data?.message) return data.message;
10197
10198                // ValidationProblemDetails: { errors: { field: [messages] } }
10199                if (data?.errors) {
10200                    const first = Object.values(data.errors).flat()[0];
10201                    if (first) return first;
10202                }
10203
10204                if (response?.status === 400 && !data) {
10205                    return 'Your session may have expired. Please refresh the page and try again.';
10206                }
10207
10208                if (response?.status === 429) {
10209                    return 'Too many attempts. Please wait a few minutes and try again.';
10210                }
10211
10212                return fallback;
10213            },
10214
10215            buildMappingState(preview) {
10216                const selections = {};
10217                const originals = {};
10218
10219                (preview.mappings || []).forEach(mapping => {
10220                    selections[mapping.targetField] = mapping.sourceColumn;
10221                    originals[mapping.targetField] = mapping;
10222                });
10223
10224                this.mappingSelections = selections;
10225                this.originalMappings = originals;
10226
10227                const fields = window.pageConfig.targetFields || [];
10228                this.mappingGroups = {
10229                    required: fields.filter(f => f.required),
10230                    mapped: fields.filter(f => !f.required && selections[f.value]),
10231                    unmapped: fields.filter(f => !f.required && !selections[f.value]),
10232                };
10233            },
10234
10235            get missingRequiredFields() {
10236                return (window.pageConfig.targetFields || [])
10237                    .filter(f => f.required && !this.mappingSelections[f.value]);
10238            },
10239
10240            get mappingOverrides() {
10241                const overrides = [];
10242
10243                for (const field of (window.pageConfig.targetFields || [])) {
10244                    const current = this.mappingSelections[field.value] || '';
10245                    const original = this.originalMappings[field.value]?.sourceColumn || '';
10246                    if (current !== original) {
10247                        overrides.push({ targetField: field.value, sourceColumn: current });
10248                    }
10249                }
10250
10251                return overrides;
10252            },
10253
10254            get unusedColumns() {
10255                const used = new Set(
10256                    Object.values(this.mappingSelections)
10257                        .filter(Boolean)
10258                        .map(column => column.toLowerCase())
10259                );
10260                return (this.preview?.detectedHeaders || []).filter(header => !used.has(header.toLowerCase()));
10261            },
10262
10263            isColumnTaken(fieldValue, header) {
10264                // A CSV column may feed only one product field; disable columns already
10265                // selected by another field so the selects cannot create duplicates.
10266                for (const [target, selected] of Object.entries(this.mappingSelections)) {
10267                    if (target !== fieldValue && selected && selected.toLowerCase() === header.toLowerCase()) {
10268                        return true;
10269                    }
10270                }
10271                return false;
10272            },
10273
10274            confidenceLabel(fieldValue) {
10275                const current = this.mappingSelections[fieldValue] || '';
10276                if (!current) return '';
10277
10278                const original = this.originalMappings[fieldValue];
10279                if (!original || original.sourceColumn !== current) return 'manual';
10280
10281                return `${Math.round(original.confidence * 100)}%`;
10282            },
10283
10284            issueText(issue) {
10285                // Row numbers <= 1 indicate file-level notices rather than data rows.
10286                return issue.rowNumber > 1 ? `Row ${issue.rowNumber}: ${issue.message}` : issue.message;
10287            },
10288
10289            async uploadFile() {
10290                if (!this.selectedFile) {
10291                    this.uploadError = 'Please select a file first.';
10292                    return;
10293                }
10294
10295                this.uploading = true;
10296                this.uploadError = null;
10297                this.progress = 0;
10298                this.results = null;
10299                this.preview = null;
10300                this.phase = 'previewing';
10301
10302                const formData = new FormData();
10303                formData.append('CsvFile', this.selectedFile);
10304
10305                const progressInterval = setInterval(() => {
10306                    if (this.progress < 90) {
10307                        this.progress += 5;
10308                    }
10309                }, 500);
10310
10311                try {
10312                    const response = await fetch(window.pageConfig.previewApiUrl, {
10313                        method: 'POST',
10314                        headers: {
10315                            [window.pageConfig.antiforgeryHeaderName]: window.pageConfig.antiforgeryToken
10316                        },
10317                        body: formData,
10318                        signal: this._abortController.signal
10319                    });
10320
10321                    this.progress = 100;
10322
10323                    const data = await this.readJson(response);
10324
10325                    if (!response.ok) {
10326                        throw new Error(this.extractErrorMessage(data, response, 'We could not analyze this file. Please check it is a valid CSV and try again.'));
10327                    }
10328
10329                    this.preview = data;
10330                    this.buildMappingState(data);
10331                    this.phase = 'preview';
10332
10333                } catch (error) {
10334                    if (error.name === 'AbortError') return;
10335                    console.error('Upload error:', error);
10336                    this.uploadError = error.message || 'An error occurred while analyzing the file.';
10337                    this.phase = 'upload';
10338                } finally {
10339                    clearInterval(progressInterval);
10340                    this.uploading = false;
10341                }
10342            },
10343
10344            async confirmImport() {
10345                if (!this.preview?.importSessionId) {
10346                    this.uploadError = 'No preview session found. Please upload the CSV again.';
10347                    this.phase = 'upload';
10348                    return;
10349                }
10350
10351                if (this.missingRequiredFields.length > 0) return;
10352
10353                this.uploading = true;
10354                this.uploadError = null;
10355                this.progress = 0;
10356                this.phase = 'importing';
10357                this.results = null;
10358
10359                try {
10360                    const response = await fetch(window.pageConfig.confirmApiUrl, {
10361                        method: 'POST',
10362                        headers: {
10363                            'Content-Type': 'application/json',
10364                            [window.pageConfig.antiforgeryHeaderName]: window.pageConfig.antiforgeryToken
10365                        },
10366                        body: JSON.stringify({
10367                            importSessionId: this.preview.importSessionId,
10368                            mappingOverrides: this.mappingOverrides,
10369                            existingProductHandling: this.existingProductHandling
10370                        }),
10371                        signal: this._abortController.signal
10372                    });
10373
10374                    const data = await this.readJson(response);
10375
10376                    if (!response.ok) {
10377                        throw new Error(this.extractErrorMessage(data, response, 'Unable to start import.'));
10378                    }
10379
10380                    this.jobId = data.jobId;
10381                    this.saveActiveJob();
10382                } catch (error) {
10383                    if (error.name === 'AbortError') return;
10384                    this.uploading = false;
10385                    this.phase = 'done';
10386                    this.results = {
10387                        success: false,
10388                        errors: [error.message || 'Failed to start import'],
10389                        warnings: [],
10390                        productsImported: 0,
10391                        productsUpdated: 0,
10392                        productsFailed: 0,
10393                        status: 'NotStarted'
10394                    };
10395                    return;
10396                }
10397
10398                await this.pollJobStatus();
10399            },
10400
10401            finishWithError(message, { keepActiveJob = false } = {}) {
10402                this.results = {
10403                    success: false,
10404                    errors: [message],
10405                    warnings: [],
10406                    productsImported: 0,
10407                    productsUpdated: 0,
10408                    productsFailed: 0,
10409                    status: 'Unknown'
10410                };
10411                this.uploading = false;
10412                this.phase = 'done';
10413                if (!keepActiveJob) this.clearActiveJob();
10414            },
10415
10416            async pollJobStatus({ resumed = false } = {}) {
10417                if (!this.jobId) return;
10418
10419                // Capture the signal now: reset() swaps in a fresh controller, and this loop
10420                // must keep honoring the controller that was active when polling started.
10421                const signal = this._abortController.signal;
10422                const jobId = this.jobId;
10423                const startedAt = Date.now();
10424                const maxDurationMs = 4 * 60 * 60 * 1000; // safety cap; large imports can take a while
10425                let delayMs = 1200;
10426                let consecutiveErrors = 0;
10427
10428                while (Date.now() - startedAt < maxDurationMs) {
10429                    if (signal.aborted) return;
10430
10431                    try {
10432                        const response = await fetch(`${window.pageConfig.jobStatusApiBaseUrl}/${jobId}`, { signal });
10433
10434                        if (response.status === 404) {
10435                            if (resumed) {
10436                                // Job from a previous visit that has since expired.
10437                                this.clearActiveJob();
10438                                this.uploading = false;
10439                                this.phase = 'upload';
10440                                this.jobId = null;
10441                                return;
10442                            }
10443                            throw new Error('Import job not found');
10444                        }
10445
10446                        if (!response.ok) throw new Error(`Status request failed (${response.status})`);
10447
10448                        consecutiveErrors = 0;
10449                        const data = await response.json();
10450
10451                        const total = data.totalRows || 1;
10452                        const processed = data.processedRows || 0;
10453                        this.progress = Math.max(5, Math.min(100, Math.round((processed / total) * 100)));
10454
10455                        const terminalStates = ['Completed', 'CompletedWithWarnings', 'Failed', 'Canceled'];
10456                        if (terminalStates.includes(data.status)) {
10457                            this.results = {
10458                                success: data.status === 'Completed' || data.status === 'CompletedWithWarnings',
10459                                errors: (data.rowFailures || []).map(f => `Row ${f.rowNumber}: ${f.message}`),
10460                                warnings: data.warnings || [],
10461                                productsImported: data.productsImported || 0,
10462                                productsUpdated: data.productsUpdated || 0,
10463                                productsFailed: data.productsFailed || 0,
10464                                status: data.status
10465                            };
10466
10467                            if (data.status === 'Failed' && this.results.errors.length === 0 && this.results.warnings.length > 0) {
10468                                // Job-level failures arrive via warnings; surface them as errors.
10469                                this.results.errors = this.results.warnings;
10470                                this.results.warnings = [];
10471                            }
10472
10473                            this.uploading = false;
10474                            this.phase = 'done';
10475                            this.progress = 100;
10476                            this.clearActiveJob();
10477                            return;
10478                        }
10479                    } catch (error) {
10480                        if (error.name === 'AbortError' || signal.aborted) return;
10481
10482                        consecutiveErrors++;
10483                        if (consecutiveErrors >= 8) {
10484                            // Keep the stored job id: the import continues server-side and
10485                            // reloading this page resumes tracking it.
10486                            this.finishWithError(
10487                                'Lost connection while tracking the import. The import is still running in the background — reload this page to resume tracking, or check your products list later.',
10488                                { keepActiveJob: true }
10489                            );
10490                            return;
10491                        }
10492                    }
10493
10494                    // Back off to a gentler cadence after the first minute.
10495                    if (Date.now() - startedAt > 60000) delayMs = 5000;
10496                    await new Promise(resolve => setTimeout(resolve, delayMs));
10497                }
10498
10499                this.finishWithError(
10500                    'Import status tracking timed out. Your import may still be running — reload this page to resume tracking, or check your products list.',
10501                    { keepActiveJob: true }
10502                );
10503            },
10504
10505            reset() {
10506                this._abortController?.abort();
10507                this._abortController = new AbortController();
10508                this.selectedFile = null;
10509                this.uploading = false;
10510                this.progress = 0;
10511                this.results = null;
10512                this.preview = null;
10513                this.jobId = null;
10514                this.phase = 'upload';
10515                this.existingProductHandling = 'create_new';
10516                this.uploadError = null;
10517                this.mappingSelections = {};
10518                this.originalMappings = {};
10519                this.mappingGroups = { required: [], mapped: [], unmapped: [] };
10520                this.clearActiveJob();
10521                this.$refs.fileInput.value = '';
10522            }
10523        };
10524    };
10525})();
10526
10527document.addEventListener('alpine:init', () => {
10528    // The admin drawer (AdminLayout), shared by the header, backdrop and sidebar. Closed whenever
10529    // an enhanced navigation starts.
10530    Alpine.store('adminShell', { mobileMenuOpen: false });
10531
10532    Alpine.data('searchAutocomplete', searchAutocomplete);
10533    Alpine.data('cartBadge', cartBadge);
10534    Alpine.data('newsletter', newsletter);
10535    Alpine.data('accountDropdown', accountDropdown);
10536    Alpine.data('productsPage', productsPage);
10537    Alpine.data('productCard', productCard);
10538
10539    Alpine.data('productDetail', productDetail);
10540    Alpine.data('productGallery', productGallery);
10541    Alpine.data('pdpLightbox', pdpLightbox);
10542    Alpine.data('contactSellerModal', contactSellerModal);
10543    Alpine.data('productReviews', productReviews);
10544    Alpine.data('registryModal', registryModal);
10545    Alpine.data('toastManager', toastManager);
10546    Alpine.data('relatedProductsSection', relatedProductsSection);
10547
10548    Alpine.data('messenger', messenger);
10549
10550    Alpine.data('adminDropshippingPage', adminDropshippingPage);
10551    Alpine.data('adminDropshippingProductPage', adminDropshippingProductPage);
10552
10553    Alpine.data('blogsIndexPage', blogsIndexPage);
10554    Alpine.data('blogDetailPage', blogDetailPage);
10555    Alpine.data('universityIndexPage', universityIndexPage);
10556    Alpine.data('universityDetailPage', universityDetailPage);
10557    Alpine.data('contactPage', contactPage);
10558
10559    Alpine.data('businessDetailPage', businessDetailPage);
10560    Alpine.data('expandableText', expandableText);
10561
10562    Alpine.data('cartPage', cartPage);
10563    Alpine.data('wishlistPage', wishlistPage);
10564    Alpine.data('settingsPage', settingsPage);
10565    Alpine.data('profilePage', profilePage);
10566    Alpine.data('ordersReviewsPage', ordersReviewsPage);
10567    Alpine.data('businessSetupPage', businessSetupPage);
10568    Alpine.data('businessSetupPlansPage', businessSetupPlansPage);
10569
10570    Alpine.data('adminDashboardStats', adminDashboardStats);
10571    Alpine.data('adminProductsPage', adminProductsPage);
10572    Alpine.data('adminMarketSalesPage', adminMarketSalesPage);
10573    Alpine.data('marketSaleFormPage', marketSaleFormPage);
10574    Alpine.data('adminMessagesPage', adminMessagesPage);
10575    Alpine.data('adminCustomersPage', adminCustomersPage);
10576    Alpine.data('adminMarketsPage', adminMarketsPage);
10577    Alpine.data('adminUnsubscribedSellersPage', adminUnsubscribedSellersPage);
10578    Alpine.data('adminReviewsPage', adminReviewsPage);
10579    Alpine.data('adminReviewDetails', adminReviewDetails);
10580    Alpine.data('adminCategoriesPage', adminCategoriesPage);
10581    Alpine.data('adminCategoryDetailsPage', adminCategoryDetailsPage);
10582    Alpine.data('adminCategoryFormPage', adminCategoryFormPage);
10583    Alpine.data('adminCategoryEmbeddingsPage', adminCategoryEmbeddingsPage);
10584    Alpine.data('adminListingScoresPage', adminListingScoresPage);
10585    Alpine.data('adminOrdersPage', adminOrdersPage);
10586    Alpine.data('adminOrderRefundPage', adminOrderRefundPage);
10587    Alpine.data('adminShippingFormPage', adminShippingFormPage);
10588    Alpine.data('adminShippingRatesPage', adminShippingRatesPage);
10589    Alpine.data('adminShippingLabelsPage', adminShippingLabelsPage);
10590    Alpine.data('adminMarketEditPage', adminMarketEditPage);
10591    Alpine.data('adminProductFormPage', adminProductFormPage);
10592    Alpine.data('adminCsvImportPage', adminCsvImportPage);
10593});

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.