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.