1(() => { 2 const appInitStartedAt = performance.now(); 3 let appInitCompletedMs = null; 4 const E = (id) => document.getElementById(id); 5 const explorerMode = document.body?.dataset.mvpMode === "explorer"; 6 const routerMode = document.body?.dataset.mvpMode === "router"; 7 const t = (key, variables, fallback = key) => window.FlowScrapeI18n?.t(key, variables) || fallback; 8 const log = (event, payload = {}) => { 9 console.info(`[webconsole][app-mvp] ${event}`, payload); 10 }; 11 const warn = (event, payload = {}) => { 12 if (typeof console.warn === "function") { 13 console.warn(`[webconsole][app-mvp] ${event}`, payload); 14 return; 15 } 16 log(event, payload); 17 }; 18 19 const htmlInput = E("mvpHtmlInput"); 20 const fallbackPanel = E("mvpFallbackPanel"); 21 const loadingPanel = E("mvpLoadingPanel"); 22 const successPanel = E("mvpSuccessPanel"); 23 const previewDetails = E("mvpPreviewDetails"); 24 const discoveryPreviewSlot = E("mvpDiscoveryPreviewSlot"); 25 const reviewPreviewSlot = E("mvpReviewPreviewSlot"); 26 const previewWrap = E("mvpPreviewWrap"); 27 const previewSummary = E("mvpPreviewSummary"); 28 const previewMore = E("mvpPreviewMore"); 29 const previewAccountGate = E("mvpPreviewAccountGate"); 30 const previewSearch = E("mvpPreviewSearch"); 31 const previewEmpty = E("mvpPreviewEmpty"); 32 const previewTable = E("mvpPreviewTable"); 33 const previewBody = E("mvpPreviewBody"); 34 const previewProductSelect = E("mvpPreviewProductSelect"); 35 const detailsLog = E("mvpDetailsLog"); 36 const stateTitle = E("mvpResultsTitle"); 37 const resultSource = E("mvpResultSource"); 38 const resultStatus = E("mvpResultStatus"); 39 const resultBanner = E("mvpResultBanner"); 40 const stateMessage = E("mvpStateMessage"); 41 const stateHint = E("mvpStateHint"); 42 const discoveryCoveragePanel = E("mvpDiscoveryCoverage"); 43 const continueScanButton = E("mvpContinueScanBtn"); 44 const stopDiscoveryButton = E("mvpStopDiscoveryBtn"); 45 const continueScanStatus = E("mvpContinueScanStatus"); 46 const continueScanLoading = E("mvpContinueScanLoading"); 47 const continueScanProgress = E("mvpContinueScanProgress"); 48 const continueScanLoadingStatus = E("mvpContinueScanLoadingStatus"); 49 const datasetActions = E("mvpDatasetActions"); 50 const datasetRecommendation = E("mvpDatasetRecommendation"); 51 const overviewEnrichment = E("mvpOverviewEnrichment"); 52 const productReviewSection = E("mvpProductReviewSection"); 53 const technicalSection = E("mvpResultDetails"); 54 const technicalContent = E("mvpTechnicalContent"); 55 const currentDatasetState = E("mvpCurrentDatasetState"); 56 const downloadsPanel = E("mvpDownloadsPanel"); 57 const freeFilesPanel = E("mvpFreeFiles"); 58 const continuationGate = E("mvpContinuationGate"); 59 const downloadsTitle = E("mvpDownloadsTitle"); 60 const currentDraftExport = E("mvpCurrentDraftExport"); 61 const cleanupPanel = E("mvpCleanupPanel"); 62 const recommendedAction = E("mvpNextActions"); 63 const completedSignupCta = E("mvpCompletedSignupCta"); 64 const reviewBlockingIssues = E("mvpReviewBlockingIssues"); 65 const enrichProductsButton = E("mvpEnrichProductsBtn"); 66 const enrichmentAction = E("mvpEnrichmentAction"); 67 const enrichmentSummary = E("mvpEnrichmentSummary"); 68 const enrichmentStatusPanel = E("mvpEnrichmentStatusPanel"); 69 const enrichmentStatusState = E("mvpEnrichmentStatusState"); 70 const enrichmentProgress = E("mvpEnrichmentProgress"); 71 const enrichmentStatus = E("mvpEnrichmentStatus"); 72 const marketingContentStep = E("mvpMarketingContentStep"); 73 const openMarketingContentButton = E("mvpOpenMarketingContent"); 74 const marketingContentAuthSupport = E("mvpMarketingContentAuthSupport"); 75 const marketingContentWorkspace = E("mvpMarketingContentWorkspace"); 76 const marketingProductSelect = E("mvpMarketingProductSelect"); 77 const marketingContextFields = E("mvpMarketingContextFields"); 78 const marketingImages = E("mvpMarketingImages"); 79 const marketingName = E("mvpMarketingName"); 80 const marketingPrice = E("mvpMarketingPrice"); 81 const marketingBrand = E("mvpMarketingBrand"); 82 const marketingSku = E("mvpMarketingSku"); 83 const marketingDescription = E("mvpMarketingDescription"); 84 const marketingHeadline = E("mvpMarketingHeadline"); 85 const marketingCta = E("mvpMarketingCta"); 86 const prepareMarketingRequestButton = E("mvpPrepareMarketingRequest"); 87 const marketingContentStatus = E("mvpMarketingContentStatus"); 88 const marketingTemplatePreview = E("mvpMarketingTemplatePreview"); 89 const marketingDownload = E("mvpMarketingDownload"); 90 const marketingCustomize = E("mvpMarketingCustomize"); 91 const loadingTitle = E("mvpLoadingTitle"); 92 const loadingMessage = E("mvpLoadingMessage"); 93 const loadingReassurance = E("mvpLoadingReassurance"); 94 const loadingBar = E("mvpLoadingBar"); 95 const startAnotherScanButton = E("mvpStartAnotherScan"); 96 const scanReassurance = E("mvpScanReassurance"); 97 const cleanupRequestLink = E("mvpCleanupRequestLink"); 98 const cleanupTitle = E("mvpCleanupTitle"); 99 const cleanupDescription = E("mvpCleanupDescription"); 100 const cleanupStatus = E("mvpCleanupStatus"); 101 const cleanupDownload = E("mvpCleanupDownload"); 102 const cleanupPreview = E("mvpCleanupPreview"); 103 104 // Keep diagnostic and advanced controls behind the single customer-facing 105 // Technical details disclosure without changing their existing behavior. 106 // Keep completed-result sections in their authored hierarchy. The Shopify 107 // preparation control belongs with the next steps, not technical details. 108 if (recommendedAction && cleanupPanel) recommendedAction.appendChild(cleanupPanel); 109 if (productReviewSection) productReviewSection.hidden = true; 110 const catalogPotentialPanel = E("mvpCatalogPotential"); 111 if (successPanel && catalogPotentialPanel) successPanel.appendChild(catalogPotentialPanel); 112 113 const issueSummaryTitle = E("mvpIssueSummaryTitle"); 114 const noReportMessage = E("mvpNoReportMessage"); 115 const freeRunsStatus = E("mvpFreeRunsStatus"); 116 const signedInFreeRuns = E("mvpSignedInFreeRuns"); 117 const paidRunsStatus = E("mvpPaidRunsStatus"); 118 const runButton = E("mvpRunBtn"); 119 const explorerTabs = E("mvpExplorerTabs"); 120 const explorerPages = E("mvpExplorerPages"); 121 const accountHeading = E("mvpAccountHeading"); 122 const accountPanel = E("mvpAccountPanel"); 123 const accountCloseButton = E("mvpAccountClose"); 124 const navSignedOut = E("mvpNavSignedOut"); 125 const navSignedIn = E("mvpNavSignedIn"); 126 const navSignInButton = E("mvpNavSignIn"); 127 const navCreateAccountButton = E("mvpNavCreateAccount"); 128 const navAccountButton = E("mvpNavAccount"); 129 const navAccountLabel = E("mvpNavAccountLabel"); 130 const navSignOutButton = E("mvpNavSignOut"); 131 const authState = E("mvpAuthState"); 132 const authMessage = E("mvpAuthMessage"); 133 const checkoutMessage = E("mvpCheckoutMessage"); 134 const authEmail = E("mvpAuthEmail"); 135 const authEmailField = E("mvpAuthEmailField"); 136 const authPassword = E("mvpAuthPassword"); 137 const authPasswordField = E("mvpAuthPasswordField"); 138 const authConfirmPassword = E("mvpAuthConfirmPassword"); 139 const authConfirmPasswordField = E("mvpAuthConfirmPasswordField"); 140 const authPasswordHint = E("mvpAuthPasswordHint");
141 const signInButton = E("mvpSignInBtn"); 142 const signUpButton = E("mvpSignUpBtn"); 143 const forgotPasswordButton = E("mvpForgotPasswordBtn"); 144 const signOutButton = E("mvpSignOutBtn"); 145 const checkoutButton = E("mvpCheckoutBtn"); 146 const checkoutButtons = Array.from(document.querySelectorAll("[data-mvp-checkout-package]")); 147 const checkoutRetentionMessage = E("mvpCheckoutRetentionMessage"); 148 const lowBalanceMessage = E("mvpLowBalanceMessage"); 149 const addRunsTrigger = E("mvpAddRunsTrigger"); 150 const addRunsLabel = E("mvpAddRunsLabel"); 151 const addRunsContent = E("mvpAddRunsContent"); 152 const paymentSuccess = E("mvpPaymentSuccess"); 153 const authForm = E("mvpAuthForm"); 154 const accountSummary = E("mvpAccountSummary"); 155 const myRunsButton = E("mvpMyRunsBtn"); 156 const runHistory = E("mvpRunHistory"); 157 const runHistoryClose = E("mvpRunHistoryClose"); 158 const runHistoryStatus = E("mvpRunHistoryStatus"); 159 const runHistoryList = E("mvpRunHistoryList"); 160 const uploadTab = E("mvpUploadTab"); 161 const urlTab = E("mvpUrlTab"); 162 const uploadPanel = E("mvpUploadPanel"); 163 const urlPanel = E("mvpUrlPanel"); 164 const urlActionRow = E("mvpUrlActionRow"); 165 const csvFileInput = E("mvpCsvFileInput"); 166 const urlInput = E("mvpUrlInput"); 167 const intentInputs = Array.from(document.querySelectorAll('input[name="mvpIntent"]')); 168 const inspectFileButton = E("mvpInspectFileBtn"); 169 const discoveryNotify = E("mvpDiscoveryNotify"); 170 const discoveryEmailField = E("mvpDiscoveryEmailField"); 171 const discoveryEmail = E("mvpDiscoveryEmail"); 172 const discoveryNotificationStatus = E("mvpDiscoveryNotificationStatus"); 173 const AUTHENTICATED_EXHAUSTED_MESSAGE = "Youâre out of credits. Add credits to run this action."; 174 const CHECKOUT_CANCELLED_MESSAGE = "Checkout cancelled. No credits were added."; 175 const CHECKOUT_SIGN_IN_MESSAGE = "Sign in to finish adding your credits."; 176 const SIGNUPS_DISABLED_MESSAGE = "Signups are not enabled yet. Please try signing in or contact support."; 177 const RECOVERY_INVALID_MESSAGE = "This password reset link is invalid or has expired. Request a new one."; 178 const RESET_REQUESTED_MESSAGE = "If an account exists for that email, we sent a password reset link."; 179 const MIN_PASSWORD_LENGTH = 8; 180 const ADD_RUNS_PREFERENCE_KEY = "fsMvpAddRunsPreference"; 181 const ACTIVE_DISCOVERY_STORAGE_KEY = "fsMvpActiveDiscovery"; 182 const ANONYMOUS_COMPLETED_RUN_STORAGE_KEY = "fsMvpAnonymousCompletedRun"; 183 const RECOVERY_POLL_MAX_MS = 2 * 60 * 1000; 184 const recoveryPanel = E("mvpRecoveryPanel"); 185 const recoveryTitle = E("mvpRecoveryTitle"); 186 const recoveryProgress = E("mvpRecoveryProgress"); 187 const recoveryStatus = E("mvpRecoveryStatus"); 188 const recoveryContinue = E("mvpRecoveryContinue"); 189 const recoveryStartNew = E("mvpRecoveryStartNew"); 190 let paymentSuccessTimer = null; 191 let completedSignupCtaEligible = false; 192 let accountPanelOpener = null; 193 const AUTH_BUTTON_LABELS = { 194 signin: "Sign in", 195 signup: "Create account", 196 signout: "Sign out", 197 }; 198 const supabaseState = { 199 client: null, 200 session: null, 201 configured: false, 202 authBusy: null, 203 checkoutBusy: false, 204 checkoutPackageId: "", 205 pendingCheckoutSessionId: "", 206 checkoutCompleteStarted: false, 207 }; 208 let activeInitialScanJobId = ""; 209 let activePreScanLoading = null; 210 let activeDiscoveryJobId = ""; 211 let activeRunRecoveryPending = true; 212 let resolveActiveRunRecovery; 213 const activeRunRecoveryReady = new Promise((resolve) => { resolveActiveRunRecovery = resolve; }); 214 const activeRunObservers = new Map(); 215 const activeSubmissionUrls = new Set(); 216 let recoveryCandidate = null; 217 let cachedRunHistory = []; 218 let runHistoryLoaded = false; 219 const DOWNLOAD_LINKS = [ 220 ["mvpShopifyDownload", "shopifyCsv", "mvpShopifyDownloadItem"], 221 ["mvpBigcommerceDownload", "bigcommerceCsv", "mvpBigcommerceDownloadItem"], 222 ["mvpCsvDownload", "exportCsv", "mvpCsvDownloadItem"], 223 ["mvpCatalogMapDownload", "catalogMapCsv", "mvpCatalogMapDownloadItem"], 224 ["mvpImportReadinessReportPdfDownload", "importReadinessReportPdf", "mvpImportReadinessReportPdfDownloadItem"], 225 ["mvpImportReadinessReportDownload", "importReadinessReport", "mvpImportReadinessReportDownloadItem"], 226 ["mvpIssuesDownload", "issuesCsv", "mvpIssuesDownloadItem"], 227 ["mvpSummaryDownload", "summaryJson", "mvpSummaryDownloadItem"], 228 ["mvpLlmDownload", "llmReadyJson", "mvpLlmDownloadItem"], 229 ["mvpEnrichmentDownload", "enrichment", "mvpEnrichmentDownloadItem"], 230 ]; 231 const ANONYMOUS_PRODUCT_LIMIT = 10; 232 233 const STATE_COPY = { 234 success: { 235 title: "Product dataset ready for review", 236 message: "", 237 banner: "0 products collected. 0 are structurally importable. 0 are available for review or enrichment.", 238 hint: "", 239 }, 240 warning: { 241 title: "Product dataset ready for review", 242 message: "", 243 banner: "0 products collected. 0 are structurally importable. 0 are available for review or enrichment.", 244 hint: "", 245 }, 246 failure: { 247 title: "No usable products found", 248 message: "Try a category or collection page, wait until products load, then upload the saved page.", 249 banner: "No usable import file created.", 250 hint: "", 251 }, 252 capture: { 253 title: "Capture page after it loads", 254 message: "Automatic extraction needs the saved page to continue.", 255 banner: "No usable import file created.", 256 hint: "Open the product listing page, wait until products are visible, then save and upload the complete page.", 257 }, 258 }; 259 const VISIBLE_LISTING_DESCRIPTION_NOTE = "Descriptions are limited because this run used listing/category page data.";
260 let inputMode = "url"; 261 let authMode = "signin"; 262 let claimInProgress = false; 263 let claimedToken = ""; 264 let previewedToken = ""; 265 let anonymousCompletedRunId = ""; 266 let anonymousClaimPromise = null; 267 268 const claimTokenFromLocation = () => { 269 try { 270 return String(new URL(window.location.href).searchParams.get("claim") || "").trim(); 271 } catch { 272 return ""; 273 } 274 }; 275 let lastUsage = null; 276 let lastMvpResult = null; 277 let resultWorkflowState = null; 278 let enrichmentInProgress = false; 279 let lastDiscoveryModel = null; 280 let lastMvpResultViewModel = null; 281 let selectedMvpIntent = "extraction"; 282 let continuationScanActive = false; 283 284 const explorerPopulatedFieldCount = (product) => { 285 const context = normalizeMarketingProductContext(product); 286 return [context.name, context.sku, context.price, context.productUrl, context.description, context.brand, context.category, ...context.images].filter(Boolean).length; 287 }; 288 289 if (routerMode) { 290 const input = E("mvpRouterUrl"); 291 const check = E("mvpRouterCheck"); 292 const loading = E("mvpRouterLoading"); 293 const result = E("mvpRouterResult"); 294 const escapeHtml = (value) => String(value || "").replace(/[&<>'"]/g, (char) => ({ "&": "&", "<": "<", ">": ">", "'": "'", '"': """ })[char]); 295 const renderInspection = (inspection, submittedUrl) => { 296 const pageLabel = ({ product: "Product page", category: "Category page", homepage: "Homepage" })[inspection.pageType] || "Page type not confirmed"; 297 const routeLabel = ({ "direct-html": "â Direct HTML", browser: "â Browser", "protected-browser": "â Enhanced retrieval", "fallback-input": "Fallback input" })[inspection.route.strategy]; 298 const strategy = inspection.route.strategy; 299 const tasks = strategy === "fallback-input" ? [] : inspection.pageType === "product" 300 ? [["instagram-story", "Instagram Story"], ["product-pdf", "Product PDF"], ["ai-json", "AI-ready JSON"]] 301 : inspection.pageType === "category" ? [["shopify", "Shopify CSV"], ["ai-json", "AI-ready JSON"]] 302 : ["browser", "protected-browser"].includes(strategy) ? [["ai-json", "AI-ready JSON"]] : []; 303 result.innerHTML = `<section class="mvp-router-site"><h2>${pageLabel}</h2><p>${routeLabel}</p></section> 304 ${tasks.length ? `<fieldset><legend>Create</legend>${tasks.map(([id, label]) => `<a class="button" data-task="${id}" href="/app-mvp2?${escapeHtml(new URLSearchParams({ url: submittedUrl || "", strategy, task: id }).toString())}"${["instagram-story", "product-pdf", "ai-json"].includes(id) ? ' target="_blank" rel="noopener noreferrer"' : ""}>${label}</a>`).join(" ")}</fieldset>` : strategy === "fallback-input" ? `<a class="button button--primary" href="/app-mvp-legacy?url=${encodeURIComponent(submittedUrl || "")}">Open legacy workspace</a>` : ""} 305 <details class="mvp-router-technical"><summary>Technical details</summary><pre>${escapeHtml(JSON.stringify(inspection, null, 2))}</pre></details>`; 306 }; 307 check?.addEventListener("click", async () => { 308 const url = String(input?.value || "").trim(); 309 if (!url) { result.hidden = false; result.textContent = "Paste a website URL to check."; return; } 310 loading.hidden = false; result.hidden = true; check.disabled = true; 311 try { 312 const response = await fetch("/api/mvp/inspect-site", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url }) }); 313 const data = await response.json(); 314 if (!response.ok || !data?.ok) throw new Error(data?.error || "Website check failed."); 315 if (String(input?.value || "").trim() !== url) return; 316 renderInspection(data.inspection, url); result.hidden = false; 317 } catch (error) { result.textContent = error?.message || "Website check failed."; result.hidden = false; } 318 finally { loading.hidden = true; check.disabled = false; } 319 }); 320 input?.addEventListener("input", () => { result.hidden = true; result.replaceChildren(); }); 321 input?.addEventListener("keydown", (event) => { if (event.key === "Enter") { event.preventDefault(); check?.click(); } }); 322 return; 323 } 324 const renderExplorerPages = (discovery) => { 325 if (!explorerMode || !explorerPages) return; 326 const body = explorerPages.querySelector("tbody"); 327 if (!body) return; 328 body.replaceChildren(); 329 const pages = Array.isArray(discovery?.pages) ? discovery.pages : []; 330 for (const page of pages) {
331 const row = document.createElement("tr"); 332 const url = document.createElement("td"); 333 const link = document.createElement("a"); 334 link.href = String(page?.url || ""); link.target = "_blank"; link.rel = "noopener noreferrer"; link.textContent = String(page?.url || "â"); 335 url.append(link); row.append(url); 336 [discoveryLabel(page?.pageType || "unknown"), String(finiteCount(page?.productsFound)), discoveryLabel(page?.status || "unknown")].forEach((value) => { const cell = document.createElement("td"); cell.textContent = value; row.append(cell); }); 337 body.append(row); 338 } 339 }; 340 const setExplorerTab = (tab) => { 341 if (!explorerMode) return; 342 const preview = E("mvpReviewPreviewSlot"); 343 const structure = E("mvpDiscoveryCoverage"); 344 if (preview) preview.hidden = tab !== "products"; 345 if (structure) structure.hidden = tab !== "structure"; 346 if (explorerPages) explorerPages.hidden = tab !== "pages" || !explorerPages.querySelector("tbody")?.children.length; 347 explorerTabs?.querySelectorAll("[data-mvp-explorer-tab]").forEach((button) => button.classList.toggle("is-active", button.dataset.mvpExplorerTab === tab)); 348 }; 349 let foregroundGeneration = 0; 350 const isForeground = (meta) => meta?.foregroundGeneration == null || meta.foregroundGeneration === foregroundGeneration; 351 const assertForeground = (meta) => { 352 if (!isForeground(meta)) throw new Error("Scan view detached"); 353 }; 354 let scanActive = false; 355 const blockAuthDuringScan = () => { 356 if (!scanActive || isSignedIn()) return false; 357 const message = E("mvpScanAuthMessage"); 358 if (message) { 359 message.textContent = t("mvp.auth.scanRunning", null, "Your scan is still running. Wait for it to finish before signing in so you can keep these results."); 360 message.hidden = false; 361 } 362 return true; 363 }; 364 const setScanActive = (active, { keepIntentHidden = active } = {}) => { 365 scanActive = active; 366 if (active) { 367 completedSignupCtaEligible = false; 368 if (completedSignupCta) completedSignupCta.hidden = true; 369 } 370 setAuthBusy(supabaseState.authBusy); 371 const intentSelector = E("mvpIntentSelector"); 372 if (intentSelector) intentSelector.hidden = keepIntentHidden; 373 if (startAnotherScanButton) startAnotherScanButton.hidden = !active; 374 }; 375 const claimPreviewOnlyFromLocation = () => { 376 try { 377 return new URL(window.location.href).searchParams.get("preview") === "1"; 378 } catch { 379 return false; 380 } 381 }; 382 const prefillUrlFromLocation = () => { 383 try { 384 const value = String(new URL(window.location.href).searchParams.get("url") || "").trim(); 385 if (value && urlInput) urlInput.value = value; 386 } catch {} 387 }; 388 389 startAnotherScanButton?.addEventListener("click", () => { 390 const url = new URL(window.location.href); 391 url.pathname = "/app-mvp"; 392 for (const param of ["claim", "preview", "url", "run", "resume", "expires"]) { 393 url.searchParams.delete(param); 394 } 395 window.location.replace(url.href); 396 }); 397 398 const discoveryNotificationPayload = () => { 399 if (!discoveryNotify?.checked) return { notifyByEmail: false }; 400 const accountEmail = String(lastUsage?.email || supabaseState.session?.user?.email || "").trim(); 401 return { notifyByEmail: true, notificationEmail: accountEmail || String(discoveryEmail?.value || "").trim() }; 402 }; 403 discoveryNotify?.addEventListener("change", () => { 404 const hasAccountEmail = Boolean(String(lastUsage?.email || supabaseState.session?.user?.email || "").trim()); 405 if (discoveryEmailField) discoveryEmailField.hidden = !discoveryNotify.checked || hasAccountEmail; 406 }); 407 let lastCompletedPackage = null; 408 const marketingContentState = { 409 sourceData: null, 410 products: [], 411 productsComplete: false, 412 selectedIndex: 0, 413 creativeOverrides: null, 414 selectedImage: "", 415 preparedRequest: null, 416 primary: false, 417 productsLoadPromise: null, 418 }; 419 420 const entitlementState = (usage = lastUsage) => { 421 const authenticated = usage?.authenticated === true || isSignedIn(); 422 return { 423 canStartUrlExtraction: true, 424 canContinueScan: authenticated, 425 }; 426 }; 427 428 const productDatasetContent = E("mvpProductDatasetContent"); 429 const recommendedEnrichmentSlot = E("mvpRecommendedEnrichmentSlot"); 430 const renderFreeRunStatus = () => { 431 if (!freeRunsStatus) return; 432 const authenticated = lastUsage?.authenticated === true || isSignedIn(); 433 freeRunsStatus.textContent = inputMode === "upload" ? "Scanning and viewing results are free." : ""; 434 freeRunsStatus.hidden = authenticated || inputMode !== "upload"; 435 if (signedInFreeRuns) { 436 signedInFreeRuns.textContent = "Scanning and viewing results are free."; 437 signedInFreeRuns.hidden = true; 438 } 439 }; 440 441 const setInputMode = (mode) => { 442 const uploadActive = mode === "upload"; 443 inputMode = uploadActive ? "upload" : "url"; 444 uploadTab?.classList.toggle("is-active", uploadActive); 445 urlTab?.classList.toggle("is-active", !uploadActive); 446 uploadTab?.setAttribute("aria-selected", uploadActive ? "true" : "false"); 447 urlTab?.setAttribute("aria-selected", uploadActive ? "false" : "true"); 448 if (uploadPanel) uploadPanel.hidden = !uploadActive; 449 if (urlPanel) urlPanel.hidden = uploadActive; 450 if (fallbackPanel) fallbackPanel.hidden = uploadActive; 451 if (urlActionRow) urlActionRow.hidden = uploadActive; 452 if (uploadActive) renderDiscoveryCoverage(null); 453 renderFreeRunStatus(); 454 renderCheckoutRetentionMessage(); 455 }; 456 457 const hasCurrentScan = () => Boolean( 458 String(urlInput?.value || "").trim() 459 || csvFileInput?.files?.[0] 460 || lastCompletedPackage 461 ); 462 463 function renderCheckoutRetentionMessage() { 464 if (checkoutRetentionMessage) checkoutRetentionMessage.hidden = !hasCurrentScan(); 465 } 466 467 const setResultSourceLabel = (requestMeta = null) => { 468 if (!requestMeta) renderCompletedScanSource(null); 469 if (!resultSource) return; 470 if (!requestMeta) { 471 resultSource.hidden = true; 472 return; 473 }
474 resultSource.textContent = requestMeta?.sourceType === "upload" 475 ? "Product dataset ready from uploaded CSV" 476 : requestMeta?.url 477 ? `Scan results for: ${requestMeta.url}` 478 : "Product dataset ready for review"; 479 resultSource.hidden = false; 480 }; 481 482 const FRIENDLY_REASON = { 483 invalid_url: "Use a full product listing URL that starts with http or https.", 484 fetch_failed: "We could not open this page automatically.", 485 fetch_timeout: "The page took too long to open automatically.", 486 http_error: "The page did not allow automatic access.", 487 non_html_response: "The URL did not return a product listing page.", 488 empty_html: "The page returned no usable content.", 489 too_few_products: "Automatic extraction found too few products.", 490 low_usable_row_rate: "Automatic extraction found product rows, but too many are missing links or images.", 491 }; 492 493 const setDetails = (value) => { 494 if (detailsLog) detailsLog.textContent = value; 495 }; 496 497 const LOADING_COPY = { 498 url: { 499 title: explorerMode ? "Starting" : t("mvp.loading.urlTitle", null, "Starting scanâ¦"), 500 message: explorerMode ? "Exploring website" : t("mvp.loading.urlMessage", null, "Preparing the scan requestâ¦"), 501 label: "Building supplier product dataset", 502 }, 503 upload: { 504 title: "Building product dataset from supplier CSV...", 505 message: "Reading supplier rows, organizing available product data, and preparing reviewable outputs...", 506 label: "Building product dataset from supplier CSV", 507 }, 508 }; 509 510 const setPackageLoading = (isLoading, mode = "url") => { 511 if (!loadingPanel) return; 512 const copy = LOADING_COPY[mode] || LOADING_COPY.url; 513 if (loadingTitle) loadingTitle.textContent = copy.title; 514 if (loadingMessage) loadingMessage.textContent = copy.message; 515 if (loadingBar) loadingBar.setAttribute("aria-label", copy.label); 516 if (loadingBar) loadingBar.hidden = mode === "url" || !isLoading; 517 if (loadingReassurance) { 518 loadingReassurance.textContent = ""; 519 loadingReassurance.hidden = true; 520 } 521 loadingPanel.hidden = !isLoading; 522 loadingPanel.setAttribute("aria-busy", isLoading ? "true" : "false"); 523 }; 524 525 const setScanProgress = (pagesScanned = 0, pagesDiscovered = 0) => { 526 if (!continueScanProgress) return; 527 const progress = Math.max(0, Math.min(100, pagesDiscovered > 0 ? (pagesScanned / pagesDiscovered) * 100 : 0)); 528 continueScanProgress.setAttribute("aria-valuemin", "0"); 529 continueScanProgress.setAttribute("aria-valuemax", "100"); 530 continueScanProgress.setAttribute("aria-valuenow", String(Math.round(progress))); 531 const progressBar = continueScanProgress.querySelector("span"); 532 if (progressBar) progressBar.style.width = `${progress}%`; 533 }; 534 535 const setUrlGenerationLoading = (isLoading) => { 536 setPackageLoading(false, "url"); 537 if (successPanel && isLoading) successPanel.style.display = "grid"; 538 if (continueScanLoading) continueScanLoading.hidden = !isLoading; 539 if (continueScanProgress) continueScanProgress.hidden = !isLoading; 540 if (continueScanButton && isLoading) continueScanButton.hidden = true; 541 if (datasetActions && isLoading) datasetActions.hidden = true; 542 if (isLoading) { 543 setScanProgress(); 544 if (continueScanLoadingStatus) continueScanLoadingStatus.textContent = "Preparing the scan requestâ¦"; 545 } else { 546 setScanProgress(1, 1); 547 if (continueScanLoading) continueScanLoading.hidden = true; 548 if (continueScanProgress) continueScanProgress.hidden = true; 549 if (scanReassurance) scanReassurance.hidden = true; 550 } 551 }; 552 553 const startInitialScanLoading = (startedAt) => { 554 const configuredSlowMs = Number(globalThis.__MVP_SLOW_START_DELAY_MS__); 555 const configuredLaterMs = Number(globalThis.__MVP_LONG_START_DELAY_MS__); 556 const slowMs = Number.isFinite(configuredSlowMs) && configuredSlowMs >= 0 ? configuredSlowMs : 10_000; 557 const laterMs = Number.isFinite(configuredLaterMs) && configuredLaterMs >
557= 0 ? configuredLaterMs : 30_000; 558 const configuredStageMs = Number(globalThis.__MVP_PRE_SCAN_STAGE_DELAY_MS__); 559 const stageMs = Number.isFinite(configuredStageMs) && configuredStageMs >= 0 ? configuredStageMs : 2_000; 560 const stages = [ 561 { progress: 2, message: explorerMode ? "Starting" : "Starting catalog scanâ¦" }, 562 { progress: 6, message: explorerMode ? "Exploring website" : "Preparing website scanâ¦" }, 563 { progress: 10, message: explorerMode ? "Exploring website" : "Understanding website structureâ¦" }, 564 { progress: 14, message: explorerMode ? "Finding product pages" : "Building catalog mapâ¦" }, 565 { progress: 18, message: explorerMode ? "Finding product pages" : "Looking for product pagesâ¦" }, 566 { progress: 20, message: explorerMode ? "Collecting product data" : "Scanning catalog pagesâ¦" }, 567 ]; 568 let jobStatus = "queued"; 569 let stageIndex = -1; 570 let displayedProgress = 0; 571 let usingRealProgress = false; 572 let timer = null; 573 574 const showProgress = (progress) => { 575 displayedProgress = Math.max(displayedProgress, Math.max(0, Math.min(100, progress))); 576 setScanProgress(displayedProgress, 100); 577 }; 578 579 const render = () => { 580 const running = jobStatus === "running"; 581 const complete = ["completed", "succeeded"].includes(jobStatus); 582 if (loadingTitle) loadingTitle.textContent = complete ? (explorerMode ? "Ready" : "Catalog discovery complete") : running ? (explorerMode ? "Collecting product data" : "Scanning catalog") : (explorerMode ? "Starting" : "Catalog scan queued"); 583 if (loadingMessage) loadingMessage.textContent = complete 584 ? "Loading the completed catalog results." 585 : running 586 ? "Scanning catalog pages and collecting products." 587 : "Waiting for a scanner to become available."; 588 if (continueScanLoadingStatus && !usingRealProgress) { 589 continueScanLoadingStatus.textContent = complete 590 ? "Loading the completed catalog results." 591 : stages[stageIndex].message; 592 } 593 if (!loadingReassurance) return; 594 const elapsedMs = Math.max(0, Date.now() - startedAt); 595 const reassurance = elapsedMs >= Math.min(slowMs, laterMs) ? "You can safely leave this page." : ""; 596 loadingReassurance.textContent = reassurance; 597 loadingReassurance.hidden = true; 598 if (scanReassurance) { 599 scanReassurance.textContent = reassurance; 600 scanReassurance.hidden = !reassurance; 601 } 602 }; 603 604 const advancePreparation = () => { 605 if (usingRealProgress) return; 606 if (stageIndex < stages.length - 1) stageIndex += 1; 607 showProgress(stages[stageIndex].progress); 608 render(); 609 }; 610 advancePreparation(); 611 timer = setInterval(advancePreparation, stageMs); 612 return { 613 updateStatus(status) { 614 jobStatus = String(status || "queued").toLowerCase(); 615 render(); 616 }, 617 updateDiscovery(job = {}, payload = {}) { 618 const sources = [job, job?.progress, job?.discoveryJob, payload, payload?.progress, payload?.discoveryJob]; 619 const firstCount = (keys) => { 620 for (const source of sources) { 621 for (const key of keys) { 622 const value = Number(source?.[key]); 623 if (Number.isFinite(value) && value >= 0) return value; 624 } 625 } 626 return null; 627 }; 628 const productsFound = firstCount(["productsFound", "products_found"]); 629 const pagesAttempted = firstCount(["pagesAttempted", "pages_attempted", "pagesScanned", "pages_scanned"]); 630 const pagesDiscovered = firstCount(["pagesDiscovered", "pages_discovered", "candidatePagesFound", "candidate_pages_found"]); 631 if (!(productsFound > 0 || pagesAttempted !== null || pagesDiscovered !== null)) return; 632 usingRealProgress = true; 633 if (timer !== null) clearInterval(timer); 634 timer = null; 635 if (productsFound !== null) { 636 const metricProducts = E("mvpMetricProducts"); 637 if (metricProducts) metricProducts.textContent = String(productsFound); 638 setText("mvpOverviewProducts", productsFound); 639 const progressSummary = E("mvpCatalogProgressSummary"); 640 if (progressSummary) progressSummary.textContent = `${productsFound} ${productsFound === 1 ? "product" : "products"}`; 641 } 642 const realProgress = pagesDiscovered > 0 && pagesAttempted !== null 643 ? (pagesAttempted / pagesDiscovered) * 100 644 : 0; 645 showProgress(realProgress); 646 if (continueScanLoadingStatus) continueScanLoadingStatus.textContent = "Scanning catalog pagesâ¦"; 647 }, 648 stop() { 649 if (timer !== null) clearInterval(timer); 650 timer = null; 651 }, 652 }; 653 }; 654 655 const setAuthMessage = (message, isError = false) => { 656 if (!authMessage) return; 657 authMessage.textContent = message; 658 authMessage.hidden = !message; 659 authMessage.classList.toggle("mvp-auth-message--error", Boolean(isError)); 660 }; 661 662 const setCheckoutMessage = (message, isError = false) => { 663 if (!checkoutMessage) return; 664 checkoutMessage.textContent = message; 665 checkoutMessage.hidden = !message; 666 checkoutMessage.classList.toggle("mvp-checkout-message--error", Boolean(isError)); 667 }; 668 669 const accessToken = () => String(supabaseState.session?.access_token || "").trim(); 670 671 const mergeAuthHeaders = (headers = {}, token = "") => { 672 const merged = new Headers(headers || {}); 673 if (token) merged.set("Authorization", `Bearer ${token}`); 674 return merged; 675 }; 676 677 const readCurrentSession = async () => { 678 if (!supabaseState.client) return supabaseState.session; 679 try { 680 const { data, error } = await supabaseState.client.auth.getSession(); 681 if (!error) supabaseState.session = data?.session || null; 682 } catch {} 683 return supabaseState.session; 684 }; 685 686 const refreshCurrentSession = async () => { 687 if (!supabaseState.client) return readCurrentSession(); 688 try { 689 const refreshed = await supabaseState.client.auth.refreshSession(); 690 if (!refreshed?.error) supabaseState.session = refreshed?.data?.session || null; 691 } catch {} 692 return supabaseState.session; 693 }; 694 695 const authenticatedFetch = async (url, options = {}, metadata = {}) => { 696 const requestOptions = { ...options }; 697 const makeRequest = async (refresh = false) => { 698 const session = refresh ? await refreshCurrentSession() : await readCurrentSession(); 699 const token = String(session?.access_token || "").trim(); 700 const headers = mergeAuthHeaders(options.headers, token); 701 return fetch(url, { ...requestOptions, headers }); 702 }; 703
704 let response = await makeRequest(false); 705 if (response.status === 401) response = await makeRequest(true); 706 return response; 707 }; 708 709 const authHeaders = (headers = {}) => mergeAuthHeaders(headers, accessToken()); 710 711 const parseResponsePayload = async (response) => { 712 const text = await response.text().catch(() => ""); 713 if (!text) return null; 714 try { 715 return JSON.parse(text); 716 } catch { 717 return { error: text }; 718 } 719 }; 720 721 const responseError = (response, payload, fallback, metadata = {}) => { 722 const authenticationFailure = response?.status === 401; 723 const message = authenticationFailure 724 ? (metadata.authenticatedRequest 725 ? "Your session expired. Sign in again to load this import package." 726 : "This import package is not available in this browser.") 727 : String(payload?.error || payload?.message || fallback || `Request failed (${response?.status || 0}).`); 728 const error = new Error(message); 729 error.code = payload?.code || metadata.reasonCode || (authenticationFailure 730 ? (metadata.authenticatedRequest ? "session_expired" : "result_access_denied") 731 : ""); 732 error.reasonCode = payload?.reasonCode || metadata.reasonCode || ""; 733 error.httpStatus = response?.status || 0; 734 error.requestId = payload?.requestId || metadata.requestId || null; 735 error.runId = payload?.runId || metadata.runId || null; 736 error.jobId = payload?.jobId || metadata.jobId || null; 737 error.endpointType = metadata.endpointType || ""; 738 error.authenticatedRequest = metadata.authenticatedRequest === true; 739 if (authenticationFailure && metadata.authenticatedRequest) setAuthMessage(message, true); 740 return error; 741 }; 742 743 const signedInEmail = () => String(supabaseState.session?.user?.email || "").trim(); 744 745 const isSignedIn = () => Boolean(signedInEmail() || supabaseState.session?.user?.id); 746 747 const setAccountPanelOpen = (open, { restoreFocus = false } = {}) => { 748 if (!accountPanel || (open && scanActive && !isSignedIn())) return; 749 accountPanel.hidden = !open; 750 [navSignInButton, navCreateAccountButton, navAccountButton].forEach((button) => { 751 button?.setAttribute("aria-expanded", open ? "true" : "false"); 752 }); 753 if (open) { 754 if (!accountPanelOpener) accountPanelOpener = document.activeElement instanceof HTMLElement ? document.activeElement : null; 755 return; 756 } 757 if (runHistory && !runHistory.hidden) closeRunHistory({ restoreFocus: false }); 758 if (restoreFocus) accountPanelOpener?.focus?.(); 759 accountPanelOpener = null; 760 }; 761 762 const openAccountPanel = (opener) => { 763 if (blockAuthDuringScan()) return; 764 if (opener) accountPanelOpener = opener; 765 setAccountPanelOpen(true); 766 (isSignedIn() && authMode !== "recovery" ? accountCloseButton : (authMode === "recovery" ? authPassword : authEmail))?.focus?.(); 767 }; 768 769 const isSignupsDisabledError = (message) => { 770 const normalized = String(message || "").trim().toLowerCase(); 771 return ( 772 normalized.includes("signups not allowed") 773 || normalized.includes("signups are not allowed") 774 || normalized.includes("signup is disabled") 775 || normalized.includes("signups disabled") 776 || normalized.includes("signups are disabled") 777 || normalized.includes("not allowed for this instance") 778 ); 779 }; 780 781 const authErrorMessageFor = (mode, error) => { 782 const rawMessage = String(error?.message || "").trim(); 783 if (mode === "signup" && isSignupsDisabledError(rawMessage)) { 784 warn("mvp_signup_disabled", { 785 provider: "supabase", 786 reasonCode: "signups_disabled", 787 supabaseMessage: rawMessage, 788 operatorAction: 789 "Enable Supabase Email provider and public signups. For MVP testing, Confirm email can be OFF.", 790 }); 791 return SIGNUPS_DISABLED_MESSAGE; 792 } 793 const normalized = rawMessage.toLowerCase(); 794 if (normalized.includes("invalid login credentials") || normalized.includes("email not confirmed")) { 795 return "The email or password is incorrect, or the email has not been confirmed yet."; 796 } 797 if (normalized.includes("password should be at least") || normalized.includes("weak password")) { 798 return `Use at least ${MIN_PASSWORD_LENGTH} characters.`; 799 } 800 if (normalized.includes("rate limit") || normalized.includes("too many requests")) { 801 return "Too many attempts. Please wait a moment and try again."; 802 } 803 if (normalized.includes("same password") || normalized.includes("different from the old password")) { 804 return "Choose a password you have not used for this account."; 805 } 806 const fallbacks = { 807 signup: "Unable to create your account. Please try again.", 808 reset: "Unable to send a reset link. Please try again.", 809 recovery: "Unable to update your password. Please try again.", 810 signin: "Unable to sign in. Please try again.", 811 }; 812 return fallbacks[mode] || "Unable to complete that request. Please try again."; 813 }; 814 815 const setAuthBusy = (mode = null) => { 816 supabaseState.authBusy = mode; 817 const busy = Boolean(mode) || (scanActive && !isSignedIn()); 818 const signedIn = isSignedIn(); 819 const entryBusy = Boolean(mode); 820 const scanNote = E("mvpScanAuthNote"); 821 if (scanNote) { 822 scanNote.hidden = !scanActive || signedIn; 823 scanNote.textContent = t("mvp.auth.scanNote", null, "Scan in progress â keep this page open. You can sign in when the scan finishes to save and continue with your results."); 824 } 825 if (!scanActive || signedIn) { 826 const message = E("mvpScanAuthMessage"); 827 if (message) message.hidden = true; 828 } 829 if (authEmail) authEmail.disabled = busy || !supabaseState.configured; 830 if (authPassword) authPassword.disabled = busy || !supabaseState.configured; 831 if (authConfirmPassword) authConfirmPassword.disabled = busy || !supabaseState.configured; 832 if (signInButton) { 833 signInButton.disabled = busy || !supabaseState.configured || (signedIn && authMode !== "recovery"); 834 signInButton.textContent = mode === "signin" 835 ? "Signing in..." 836 : mode === "signup" 837 ? "Creating account..." 838 : mode === "reset" 839 ? "Sending reset link..." 840 : mode === "recovery"
841 ? "Updating password..." 842 : authMode === "reset" 843 ? "Send reset link" 844 : authMode === "recovery" 845 ? "Set new password" 846 : AUTH_BUTTON_LABELS[authMode]; 847 signInButton.classList.toggle("button--primary", true); 848 signInButton.classList.toggle("secondary", false); 849 } 850 if (signUpButton) { 851 signUpButton.disabled = busy || !supabaseState.configured || (signedIn && authMode !== "recovery"); 852 signUpButton.textContent = authMode === "signin" 853 ? AUTH_BUTTON_LABELS.signup 854 : authMode === "signup" 855 ? "Already have an account? Sign in" 856 : "Back to sign in"; 857 signUpButton.classList.toggle("button--primary", false); 858 signUpButton.classList.toggle("secondary", true); 859 } 860 if (forgotPasswordButton) forgotPasswordButton.disabled = busy || !supabaseState.configured; 861 if (signOutButton) { 862 signOutButton.disabled = busy || !supabaseState.configured || !signedIn; 863 signOutButton.textContent = mode === "signout" ? "Signing out..." : AUTH_BUTTON_LABELS.signout; 864 } 865 if (navSignInButton) navSignInButton.disabled = entryBusy || !supabaseState.configured || signedIn; 866 if (navCreateAccountButton) navCreateAccountButton.disabled = entryBusy || !supabaseState.configured || signedIn; 867 for (const id of ["mvpPreviewCreateAccount", "mvpPreviewSignIn", "mvpCompletedSignupCreate", "mvpCompletedSignupSignIn", "mvpCreateAccountEnrich", "mvpSignInEnrich"]) { 868 const button = E(id); 869 if (button) button.disabled = entryBusy || !supabaseState.configured; 870 } 871 if (navAccountButton) navAccountButton.disabled = busy || !supabaseState.configured || !signedIn; 872 if (navSignOutButton) { 873 navSignOutButton.disabled = busy || !supabaseState.configured || !signedIn; 874 navSignOutButton.textContent = mode === "signout" ? "Signing out..." : AUTH_BUTTON_LABELS.signout; 875 } 876 for (const button of checkoutButtons) { 877 button.disabled = supabaseState.checkoutBusy || !supabaseState.configured || !signedIn; 878 const isStarting = supabaseState.checkoutBusy 879 && button.dataset.mvpCheckoutPackage === supabaseState.checkoutPackageId; 880 button.textContent = isStarting ? "Starting checkout..." : button.dataset.mvpCheckoutLabel; 881 } 882 }; 883 884 const renderAuthState = () => { 885 const email = signedInEmail(); 886 const signedIn = isSignedIn(); 887 if (accountHeading) { 888 accountHeading.textContent = authMode === "signup" ? "Create your account" : "Your account"; 889 accountHeading.hidden = signedIn && authMode !== "recovery"; 890 } 891 renderFreeRunStatus(); 892 if (authState) { 893 authState.textContent = signedIn ? (email || "Account") : "Not signed in"; 894 } 895 if (navSignedOut) navSignedOut.hidden = signedIn; 896 if (navSignedIn) navSignedIn.hidden = !signedIn; 897 if (navAccountLabel) navAccountLabel.textContent = signedIn ? (email || t("mvp.auth.account", null, "Account")) : t("mvp.auth.account", null, "Account"); 898 const recovering = authMode === "recovery"; 899 if (authForm) authForm.hidden = signedIn && !recovering; 900 if (accountSummary) accountSummary.hidden = !signedIn || recovering; 901 if (!signedIn && runHistory) runHistory.hidden = true; 902 if (myRunsButton && !signedIn) myRunsButton.setAttribute("aria-expanded", "false"); 903 if (authEmailField) authEmailField.hidden = recovering; 904 if (authPasswordField) authPasswordField.hidden = authMode === "reset"; 905 if (authConfirmPasswordField) authConfirmPasswordField.hidden = authMode !== "signup" && !recovering; 906 if (authPasswordHint) authPasswordHint.hidden = authMode !== "signup" && !recovering; 907 if (authEmail) authEmail.required = !recovering; 908 if (authPassword) { 909 authPassword.required = authMode !== "reset"; 910 authPassword.setAttribute("autocomplete", authMode === "signin" ? "current-password" : "new-password"); 911 } 912 if (authConfirmPassword) authConfirmPassword.required = authMode === "signup" || recovering; 913 if (forgotPasswordButton) forgotPasswordButton.hidden = authMode !== "signin"; 914 setAuthBusy(supabaseState.authBusy); 915 renderMarketingContentAuthState(); 916 if (lastCompletedPackage) renderResultAccessState(isSignedIn()); 917 }; 918 919 const readAddRunsPreference = () => { 920 try { 921 const preference = window.sessionStorage?.getItem(ADD_RUNS_PREFERENCE_KEY); 922 return preference === "expanded" || preference === "collapsed" ? preference : null; 923 } catch { 924 return null; 925 } 926 }; 927 928 const shouldExpandAddRuns = ({ paidRunsRemaining, sessionPreference }) => { 929 if (paidRunsRemaining <= 0) return true; 930 return sessionPreference === "expanded"; 931 }; 932 933 const setAddRunsExpanded = (expanded, { remember = false } = {}) => { 934 if (addRunsTrigger) addRunsTrigger.setAttribute("aria-expanded", String(expanded)); 935 if (addRunsContent) addRunsContent.hidden = !expanded; 936 if (!remember) return; 937 try { 938 window.sessionStorage?.setItem(ADD_RUNS_PREFERENCE_KEY, expanded ? "expanded" : "collapsed"); 939 } catch {} 940 }; 941 942 const renderAddRunsState = (paidRunsRemaining) => { 943 const paidRuns = Math.max(0, Number(paidRunsRemaining || 0)); 944 const label = paidRuns === 0 945 ? "Add credits · No credits remaining" 946 : paidRuns <= 5 947 ? `Add credits · ${paidRuns} remaining` 948 : "Add credits"; 949 if (addRunsLabel) addRunsLabel.textContent = label; 950 setAddRunsExpanded(shouldExpandAddRuns({ 951 paidRunsRemaining: paidRuns, 952 sessionPreference: readAddRunsPreference(), 953 })); 954 }; 955 956 if (addRunsTrigger) { 957 addRunsTrigger.addEventListener("click", () => { 958 const expanded = addRunsTrigger.getAttribute("aria-expanded") === "true"; 959 setAddRunsExpanded(!expanded, { remember: true }); 960 }); 961 } 962 963 const renderUsage = (usage) => { 964 if (!usage) return; 965 lastUsage = usage; 966 const paidCredits = Math.max(0, Number(usage?.paidCreditsRemaining ?? 0)); 967 const authenticated = usage?.authenticated === true || isSignedIn(); 968 const { canStartUrlExtraction } = entitlementState(usage); 969 renderFreeRunStatus(); 970 if (paidRunsStatus) { 971 paidRunsStatus.textContent = `${paidCredits} ${paidCredits === 1 ? "credit" : "credits"} remaining`; 972 paidRunsStatus.hidden = !authenticated; 973 } 974 if (checkoutButton) { 975 checkoutButton.hidden = !authenticated; 976 } 977 if (lowBalanceMessage) lowBalanceMessage.hidden = !authenticated || paidCredits > 5; 978 if (authenticated) renderAddRunsState(paidCredits); 979 if (runButton) { 980 runButton.disabled = !canStartUrlExtraction; 981 } 982 if (authenticated && !canStartUrlExtraction) { 983 const hasPendingCheckout = Boolean(supabaseState.pendingCheckoutSessionId); 984 const statusEl = E("mvpStatus"); 985 if (statusEl) { 986 statusEl.textContent = ""; 987 statusEl.hidden = true; 988 } 989 if (!hasPendingCheckout) { 990 setCheckoutMessage(AUTHENTICATED_EXHAUSTED_MESSAGE); 991 } 992 } else if ( 993 !supabaseState.pendingCheckoutSessionId 994 && /out of credits/i.test(String(checkoutMessage?.textContent || "")) 995 ) { 996 setCheckoutMessage(""); 997 } 998 setAuthBusy(supabaseState.authBusy); 999 renderCheckoutRetentionMessage(); 1000 if (lastMvpResultViewModel && lastMvpResult) { 1001 renderMvpResultViewModel(lastMvpResultViewModel, lastMvpResult); 1002 } 1003 renderActionAccessGates(); 1004 }; 1005 1006 const loadUsage = async () => { 1007 try { 1008 const res = isSignedIn() 1009 ? await authenticatedFetch("/api/mvp/usage", {}, { endpointType: "usage" }) 1010 : await fetch("/api/mvp/usage"); 1011 const usage = await parseResponsePayload(res); 1012 if (res.ok) { 1013 renderUsage(usage); 1014 return usage; 1015 } 1016 if (res.status === 401) setAuthMessage("Session expired. Sign in again.", true); 1017 } catch (err) { 1018 log("mvp_usage_load_failed", { error: err?.message || String(err) }); 1019 } 1020 return null; 1021 }; 1022 1023 const clearAuthFragment = () => { 1024 try { 1025 const url = new URL(window.location.href); 1026 const fragment = new URLSearchParams(url.hash.replace(/^#/, "")); 1027 if (!fragment.has("access_token") && !fragment.has("refresh_token")) return; 1028 [ 1029 "access_token", 1030 "refresh_token", 1031 "expires_at", 1032 "expires_in", 1033 "provider_token", 1034 "provider_refresh_token", 1035 "token_type", 1036 "type", 1037 ].forEach((key) => fragment.delete(key)); 1038 const remainingFragment = fragment.toString(); 1039 window.history.replaceState( 1040 {}, 1041 document.title, 1042 `${url.pathname}${url.search}${remainingFragment ? `#${remainingFragment}` : ""}` 1043 ); 1044 } catch {} 1045 }; 1046 1047 const initAuth = async () => { 1048 const supabaseUrl = window.__SUPABASE_URL__; 1049 const supabaseKey = window.__SUPABASE_PUBLISHABLE_KEY__; 1050 const createClient = window.supabase?.createClient; 1051 supabaseState.configured = Boolean(supabaseUrl && supabaseKey && typeof createClient === "function"); 1052 if (!supabaseState.configured) { 1053 renderAuthState(); 1054 setAuthMessage("Sign-in is not configured.", true); 1055 return; 1056 } 1057 supabaseState.client = createClient(supabaseUrl, supabaseKey, { 1058 auth: { 1059 persistSession: true, 1060 autoRefreshToken: true, 1061 detectSessionInUrl: true, 1062 }, 1063 }); 1064 const { data } = await supabaseState.client.auth.getSession(); 1065 supabaseState.session = data?.session || null; 1066 if (supabaseState.session) clearAuthFragment(); 1067 const authParam = new URL(window.location.href).searchParams.get("auth"); 1068 let preserveAuthMessage = false;
1069 if (authParam === "recovery") { 1070 preserveAuthMessage = true; 1071 if (supabaseState.session) { 1072 authMode = "recovery"; 1073 setAuthMessage("Choose a new password for your account."); 1074 } else { 1075 authMode = "reset"; 1076 setAuthMessage(RECOVERY_INVALID_MESSAGE, true); 1077 } 1078 } else { 1079 try { 1080 const callbackMessage = window.sessionStorage?.getItem("fsMvpAuthMessage"); 1081 if (callbackMessage) { 1082 preserveAuthMessage = true; 1083 window.sessionStorage.removeItem("fsMvpAuthMessage"); 1084 setAuthMessage(callbackMessage, true); 1085 } 1086 } catch {} 1087 } 1088 renderAuthState(); 1089 if (!preserveAuthMessage) { 1090 setAuthMessage(claimTokenFromLocation() && !isSignedIn() 1091 ? "A supplier dataset is ready. Sign in to open it." 1092 : isSignedIn() ? "" : "Sign in to save your checks and use credits."); 1093 } 1094 if (authParam === "recovery" || (claimTokenFromLocation() && !isSignedIn())) openAccountPanel(); 1095 supabaseState.client.auth.onAuthStateChange((event, session) => { 1096 supabaseState.session = session || null; 1097 if (supabaseState.session) clearAuthFragment(); 1098 if (event === "PASSWORD_RECOVERY") authMode = "recovery"; 1099 renderAuthState(); 1100 if (event === "PASSWORD_RECOVERY") { 1101 setAuthMessage("Choose a new password for your account."); 1102 } else if (isSignedIn() && authMode !== "recovery") { 1103 setAuthMessage(""); 1104 } else if (event === "SIGNED_OUT") { 1105 setAuthMessage("Signed out."); 1106 } 1107 void handleAuthenticatedTransition(); 1108 }); 1109 }; 1110 1111 const clearCheckoutParams = () => { 1112 try { 1113 const url = new URL(window.location.href); 1114 url.searchParams.delete("checkout"); 1115 url.searchParams.delete("session_id"); 1116 window.history.replaceState({}, document.title, `${url.pathname}${url.search}${url.hash}`); 1117 } catch {} 1118 }; 1119 1120 const redirectToCheckout = (url) => { 1121 if (typeof window.__MVP_NAVIGATE__ === "function") { 1122 window.__MVP_NAVIGATE__(url); 1123 return; 1124 } 1125 window.location.assign(url); 1126 }; 1127 1128 const completePendingCheckout = async () => { 1129 const sessionId = String(supabaseState.pendingCheckoutSessionId || "").trim(); 1130 if (!sessionId || supabaseState.checkoutCompleteStarted) return; 1131 if (!accessToken()) { 1132 setCheckoutMessage(CHECKOUT_SIGN_IN_MESSAGE, true); 1133 setAuthMessage(CHECKOUT_SIGN_IN_MESSAGE, true); 1134 openAccountPanel(); 1135 return; 1136 } 1137 supabaseState.checkoutCompleteStarted = true; 1138 setCheckoutMessage("Finishing checkout..."); 1139 try { 1140 const res = await authenticatedFetch(`/api/mvp/checkout/complete?session_id=${encodeURIComponent(sessionId)}`, {}, { endpointType: "checkout_complete" }); 1141 const data = await parseResponsePayload(res); 1142 if (!res.ok || data?.ok === false) { 1143 throw new Error(data?.error || "Unable to finish checkout."); 1144 } 1145 const creditsPurchased = Math.max(0, Number(data?.creditsPurchased ?? data?.creditsGranted ?? 0)); 1146 supabaseState.pendingCheckoutSessionId = ""; 1147 clearCheckoutParams(); 1148 const refreshedUsage = await loadUsage(); 1149 const refreshedBalance = Math.max(0, Number(refreshedUsage?.paidCreditsRemaining ?? data?.paidCreditsRemaining ?? 0)); 1150 setAddRunsExpanded(false, { remember: true }); 1151 setCheckoutMessage(""); 1152 if (paymentSuccess) { 1153 paymentSuccess.textContent = `${creditsPurchased} credits added. New balance: ${refreshedBalance}.`; 1154 paymentSuccess.hidden = false; 1155 if (paymentSuccessTimer) window.clearTimeout(paymentSuccessTimer); 1156 paymentSuccessTimer = window.setTimeout(() => { 1157 paymentSuccess.hidden = true; 1158 paymentSuccess.textContent = ""; 1159 }, 6000); 1160 } 1161 } catch (err) { 1162 setCheckoutMessage(err?.message || "Unable to finish checkout.", true); 1163 } finally { 1164 supabaseState.checkoutCompleteStarted = false; 1165 } 1166 }; 1167 1168 const handleCheckoutReturn = async () => { 1169 let params; 1170 try { 1171 params = new URL(window.location.href).searchParams; 1172 } catch { 1173 return; 1174 } 1175 const checkout = params.get("checkout"); 1176 if (checkout === "cancelled") { 1177 setCheckoutMessage(CHECKOUT_CANCELLED_MESSAGE, true); 1178 clearCheckoutParams(); 1179 return; 1180 } 1181 if (checkout !== "success") return; 1182 const sessionId = String(params.get("session_id") || "").trim(); 1183 if (!sessionId) { 1184 setCheckoutMessage("Missing checkout session. No credits were added.", true); 1185 clearCheckoutParams(); 1186 return; 1187 } 1188 supabaseState.pendingCheckoutSessionId = sessionId; 1189 if (!isSignedIn()) { 1190 setCheckoutMessage(CHECKOUT_SIGN_IN_MESSAGE, true); 1191 setAuthMessage(CHECKOUT_SIGN_IN_MESSAGE, true); 1192 openAccountPanel(); 1193 return; 1194 } 1195 await completePendingCheckout(); 1196 }; 1197 1198 const preserveCurrentScanForCheckout = () => { 1199 try { 1200 const completedData = lastCompletedPackage?.data; 1201 const customerSummary = completedData?.customerSummary; 1202 const compactCompletedPackage = completedData ? { 1203 data: { 1204 requestId: completedData.requestId,
1205 runId: completedData.runId, 1206 status: completedData.status, 1207 summary: completedData.summary, 1208 customerSummary: customerSummary && typeof customerSummary === "object" 1209 ? { ...customerSummary, discovery_coverage: undefined } 1210 : customerSummary, 1211 preview: Array.isArray(completedData.preview) ? completedData.preview.slice(0, 25) : [], 1212 artifacts: completedData.artifacts, 1213 report: completedData.report, 1214 message: completedData.message, 1215 catalogPotential: completedData.catalogPotential, 1216 }, 1217 requestMeta: lastCompletedPackage.requestMeta, 1218 } : null; 1219 window.sessionStorage?.setItem("fsMvpCheckoutScan", JSON.stringify({ 1220 url: String(urlInput?.value || ""), 1221 inputMode, 1222 intent: selectedMvpIntent, 1223 completedPackage: compactCompletedPackage, 1224 })); 1225 } catch {} 1226 }; 1227 1228 const startCheckout = async (packageId) => { 1229 if (supabaseState.checkoutBusy) return; 1230 if (!supabaseState.client || !accessToken()) { 1231 setCheckoutMessage("Sign in to buy more credits.", true); 1232 setAuthMessage("Sign in to buy more credits.", true); 1233 return; 1234 } 1235 supabaseState.checkoutBusy = true; 1236 supabaseState.checkoutPackageId = packageId; 1237 setAuthBusy(supabaseState.authBusy); 1238 setCheckoutMessage("Starting checkout..."); 1239 try { 1240 const res = await authenticatedFetch("/api/mvp/checkout", { 1241 method: "POST", 1242 headers: { "Content-Type": "application/json" }, 1243 body: JSON.stringify({ packageId }), 1244 }, { endpointType: "checkout" }); 1245 const data = await parseResponsePayload(res); 1246 if (!res.ok || data?.ok === false) { 1247 const message = String(data?.error || "Unable to start checkout."); 1248 const clearMessage = message.toLowerCase().includes("stripe") 1249 ? "Checkout is not configured yet. Please contact support." 1250 : message; 1251 throw new Error(clearMessage); 1252 } 1253 if (!data?.url) throw new Error("Checkout did not return a payment link."); 1254 preserveCurrentScanForCheckout(); 1255 redirectToCheckout(data.url); 1256 } catch (err) { 1257 setCheckoutMessage(err?.message || "Unable to start checkout.", true); 1258 } finally { 1259 supabaseState.checkoutBusy = false; 1260 supabaseState.checkoutPackageId = ""; 1261 setAuthBusy(supabaseState.authBusy); 1262 } 1263 }; 1264 1265 for (const button of checkoutButtons) { 1266 button.addEventListener("click", () => { 1267 void startCheckout(button.dataset.mvpCheckoutPackage || ""); 1268 }); 1269 } 1270 1271 const authCredentials = () => ({ 1272 email: String(authEmail?.value || "").trim(), 1273 password: String(authPassword?.value || ""), 1274 }); 1275 1276 const runAuthAction = async (mode) => { 1277 if (supabaseState.authBusy || (scanActive && !isSignedIn())) return; 1278 if (!supabaseState.client) { 1279 setAuthMessage("Sign-in is not configured.", true); 1280 return; 1281 } 1282 const credentials = authCredentials(); 1283 let focusPasswordAfterSignup = false; 1284 if (mode === "reset") { 1285 if (!credentials.email) { 1286 setAuthMessage("Enter your email address.", true); 1287 return; 1288 } 1289 } else if (mode === "recovery") { 1290 const confirmPassword = String(authConfirmPassword?.value || ""); 1291 if (credentials.password.length < MIN_PASSWORD_LENGTH) { 1292 setAuthMessage(`Use at least ${MIN_PASSWORD_LENGTH} characters.`, true); 1293 return; 1294 } 1295 if (credentials.password !== confirmPassword) { 1296 setAuthMessage("Passwords do not match.", true); 1297 return; 1298 } 1299 } else if (!credentials.email || !credentials.password) { 1300 setAuthMessage("Enter email and password.", true); 1301 return; 1302 } else if (mode === "signup" && credentials.password.length < MIN_PASSWORD_LENGTH) { 1303 setAuthMessage(`Use at least ${MIN_PASSWORD_LENGTH} characters.`, true); 1304 return; 1305 } else if (mode === "signup" && credentials.password !== String(authConfirmPassword?.value || "")) { 1306 setAuthMessage("Passwords do not match.", true); 1307 return; 1308 } 1309 setAuthBusy(mode); 1310 setAuthMessage( 1311 mode === "signup" 1312 ? "Creating account..." 1313 : mode === "reset" 1314 ? "Sending reset link..." 1315 : mode === "recovery"
1316 ? "Updating password..." 1317 : "Signing in..." 1318 ); 1319 try { 1320 if (mode === "reset") { 1321 const result = await supabaseState.client.auth.resetPasswordForEmail(credentials.email, { 1322 redirectTo: `${window.location.origin}/auth/accept?mode=recovery`, 1323 }); 1324 if (result.error) { 1325 setAuthMessage(authErrorMessageFor(mode, result.error), true); 1326 return; 1327 } 1328 setAuthMessage(RESET_REQUESTED_MESSAGE); 1329 return; 1330 } 1331 if (mode === "recovery") { 1332 const result = await supabaseState.client.auth.updateUser({ password: credentials.password }); 1333 if (result.error) { 1334 setAuthMessage(authErrorMessageFor(mode, result.error), true); 1335 return; 1336 } 1337 authMode = "signin"; 1338 try { 1339 const url = new URL(window.location.href); 1340 url.searchParams.delete("auth"); 1341 window.history.replaceState({}, document.title, `${url.pathname}${url.search}${url.hash}`); 1342 } catch {} 1343 renderAuthState(); 1344 setAuthMessage("Password updated successfully."); 1345 await loadUsage(); 1346 return; 1347 } 1348 const result = mode === "signup" 1349 ? await supabaseState.client.auth.signUp({ 1350 email: credentials.email, 1351 password: credentials.password, 1352 options: { 1353 emailRedirectTo: (() => { 1354 const redirect = new URL("/app-mvp", window.location.origin); 1355 const claim = claimTokenFromLocation(); 1356 if (claim) redirect.searchParams.set("claim", claim); 1357 return redirect.toString(); 1358 })(), 1359 }, 1360 }) 1361 : await supabaseState.client.auth.signInWithPassword(credentials); 1362 if (result.error) { 1363 setAuthMessage(authErrorMessageFor(mode, result.error), true); 1364 return; 1365 } 1366 const session = result.data?.session || null; 1367 if (session) { 1368 supabaseState.session = session; 1369 renderAuthState(); 1370 setAuthMessage(""); 1371 await handleAuthenticatedTransition(); 1372 return; 1373 } 1374 supabaseState.session = null; 1375 if (mode === "signup") { 1376 if (authPassword) authPassword.value = ""; 1377 if (authConfirmPassword) authConfirmPassword.value = ""; 1378 authMode = "signin"; 1379 } 1380 renderAuthState(); 1381 setAuthMessage( 1382 mode === "signup" 1383 ? "Account created. Confirm your email, then return here and sign in to buy credits." 1384 : "Sign in did not return a session. Check your email confirmation status and try again.", 1385 mode !== "signup" 1386 ); 1387 if (mode === "signup") focusPasswordAfterSignup = true; 1388 await loadUsage(); 1389 } catch (err) { 1390 setAuthMessage(err?.message || "Sign in failed.", true); 1391 } finally { 1392 setAuthBusy(null); 1393 if (focusPasswordAfterSignup) authPassword?.focus?.(); 1394 } 1395 }; 1396 1397 const setAuthMode = (mode) => { 1398 authMode = ["signup", "reset", "recovery"].includes(mode) ? mode : "signin"; 1399 renderAuthState(); 1400 setAuthMessage( 1401 authMode === "signup" 1402 ? "Create an account to purchase credits and save your scan history." 1403 : authMode === "reset" 1404 ? "Enter your email and weâll send a password reset link." 1405 : authMode === "recovery" 1406 ? "Choose a new password for your account." 1407 : "" 1408 ); 1409 (authMode === "recovery" ? authPassword : authEmail)?.focus?.(); 1410 }; 1411 1412 authForm?.addEventListener("submit", (event) => { 1413 event.preventDefault(); 1414 if (typeof authForm.reportValidity === "function" && !authForm.reportValidity()) return; 1415 void runAuthAction(authMode); 1416 }); 1417 1418 signUpButton?.addEventListener("click", () => { 1419 setAuthMode(authMode === "signin" ? "signup" : "signin"); 1420 }); 1421 1422 urlInput?.addEventListener("input", renderCheckoutRetentionMessage); 1423 csvFileInput?.addEventListener("change", renderCheckoutRetentionMessage); 1424 1425 forgotPasswordButton?.addEventListener("click", () => { 1426 setAuthMode("reset"); 1427 }); 1428 1429 for (const id of ["mvpNavSignIn", "mvpNavCreateAccount", "mvpPreviewCreateAccount", "mvpPreviewSignIn", "mvpCompletedSignupCreate", "mvpCompletedSignupSignIn", "mvpCreateAccountEnrich", "mvpSignInEnrich"]) { 1430 E(id)?.addEventListener("click", (event) => { 1431 if (!blockAuthDuringScan()) return; 1432 event.preventDefault(); 1433 event.stopImmediatePropagation(); 1434 }, true); 1435 } 1436 1437 navSignInButton?.addEventListener("click", () => { 1438 setAuthMode("signin"); 1439 openAccountPanel(navSignInButton); 1440 }); 1441 1442 navCreateAccountButton?.addEventListener("click", () => { 1443 setAuthMode("signup"); 1444 openAccountPanel(navCreateAccountButton); 1445 }); 1446 1447 navAccountButton?.addEventListener("click", () => { 1448 openAccountPanel(navAccountButton); 1449 }); 1450 1451 navSignOutButton?.addEventListener("click", () => { 1452 signOutButton?.click(); 1453 }); 1454 1455 accountCloseButton?.addEventListener("click", () => { 1456 setAccountPanelOpen(false, { restoreFocus: true }); 1457 }); 1458 1459 accountPanel?.addEventListener("click", (event) => { 1460 if (event.target === accountPanel) setAccountPanelOpen(false, { restoreFocus: true }); 1461 }); 1462 1463 accountPanel?.addEventListener("keydown", (event) => { 1464 if (event.key !== "Escape" || (runHistory && !runHistory.hidden)) return; 1465 event.preventDefault(); 1466 setAccountPanelOpen(false, { restoreFocus: true }); 1467 }); 1468 1469 signOutButton?.addEventListener("click", async () => { 1470 if (supabaseState.authBusy) return; 1471 if (!supabaseState.client) { 1472 setAuthMessage("Sign-in is not configured.", true); 1473 return; 1474 } 1475 setAuthBusy("signout"); 1476 setAuthMessage("Signing out..."); 1477 try { 1478 const result = await supabaseState.client.auth.signOut(); 1479 if (result?.error) { 1480 setAuthMessage(result.error.message || "Sign out failed.", true); 1481 return; 1482 } 1483 supabaseState.session = null; 1484 renderAuthState(); 1485 setAuthMessage("Signed out."); 1486 await loadUsage(); 1487 } catch (err) { 1488 setAuthMessage(err?.message || "Sign out failed.", true); 1489 } finally { 1490 setAuthBusy(null); 1491 } 1492 }); 1493 1494 const runStatusLabel = (run) => { 1495 const status = String(run?.status || "").toLowerCase(); 1496 if (["queued", "running"].includes(status) && run?.jobType === "mvp-continue-scan") return "Discovering full catalog"; 1497 if (status === "completed" && Number(run?.pagesRemaining || 0) > 0) return "Stopped at safety limit"; 1498 if (status === "completed") return "Complete"; 1499 if (status === "failed") return "Failed"; 1500 if (status === "cancelled") return "Cancelled"; 1501 if (status === "running") return "Running"; 1502 return "Queued"; 1503 }; 1504 1505 const closeRunHistory = ({ restoreFocus = false } = {}) => { 1506 if (runHistory) runHistory.hidden = true; 1507 myRunsButton?.setAttribute("aria-expanded", "false"); 1508 if (restoreFocus) myRunsButton?.focus?.(); 1509 }; 1510 1511 const openHistoryRun = async (run, button) => { 1512 const originalLabel = button.textContent; 1513 button.disabled = true; 1514 button.textContent = "Openingâ¦"; 1515 if (runHistoryStatus) { 1516 runHistoryStatus.hidden = false;
1517 runHistoryStatus.textContent = "Loading this import packageâ¦"; 1518 } 1519 try { 1520 const res = await authenticatedFetch(`/api/mvp/runs/${encodeURIComponent(run.runId)}/result`, { 1521 credentials: "same-origin", 1522 }, { endpointType: "run_result", runId: run.runId }); 1523 const data = await parseResponsePayload(res); 1524 if (!res.ok || data?.ok === false) throw responseError(res, data, "Unable to open this run.", { endpointType: "run_result", runId: run.runId }); 1525 renderCompletedPackage( 1526 data, 1527 { sourceType: "url", url: run.sourceUrl, host: run.host }, 1528 Date.now(), 1529 "mvp_history_run_opened" 1530 ); 1531 closeRunHistory(); 1532 successPanel?.scrollIntoView?.({ behavior: "smooth", block: "start" }); 1533 return true; 1534 } catch (err) { 1535 if (runHistoryStatus) { 1536 runHistoryStatus.textContent = err?.message || "Unable to open this run."; 1537 runHistoryStatus.hidden = false; 1538 } 1539 return false; 1540 } finally { 1541 button.disabled = false; 1542 button.textContent = originalLabel; 1543 } 1544 }; 1545 1546 const renderRunHistory = (runs, { showAll = false } = {}) => { 1547 if (!runHistoryList || !runHistoryStatus) return; 1548 runHistoryList.replaceChildren(); 1549 if (!Array.isArray(runs) || !runs.length) { 1550 runHistoryStatus.textContent = "No authenticated URL runs yet."; 1551 runHistoryStatus.hidden = false; 1552 return; 1553 } 1554 runHistoryStatus.hidden = true; 1555 const visibleRuns = showAll ? runs : runs.slice(0, 5); 1556 for (const run of visibleRuns) { 1557 const item = document.createElement("article"); 1558 item.className = "mvp-run-history-item"; 1559 const body = document.createElement("div"); 1560 const title = document.createElement("p"); 1561 title.className = "mvp-run-history-item__title"; 1562 title.textContent = run.host || "URL run"; 1563 const meta = document.createElement("p"); 1564 meta.className = "mvp-run-history-item__meta"; 1565 const created = Number.isFinite(Date.parse(run.createdAt)) ? new Date(run.createdAt).toLocaleString() : ""; 1566 meta.textContent = `${runStatusLabel(run)} · ${Number(run.productCount || 0)} products${created ? ` · ${created}` : ""}`; 1567 body.append(title, meta); 1568 if (run.failureMessage) { 1569 const failure = document.createElement("p"); 1570 failure.className = "mvp-run-history-item__error"; 1571 failure.textContent = run.failureMessage; 1572 body.appendChild(failure); 1573 } 1574 item.appendChild(body); 1575 if (run.canOpenResult === true) { 1576 const openButton = document.createElement("button"); 1577 openButton.className = "button secondary"; 1578 openButton.type = "button"; 1579 openButton.textContent = "Open result"; 1580 openButton.addEventListener("click", () => void openHistoryRun(run, openButton)); 1581 item.appendChild(openButton); 1582 } 1583 runHistoryList.appendChild(item); 1584 } 1585 if (!showAll && runs.length > 5) { 1586 const viewAll = document.createElement("button"); 1587 viewAll.id = "mvpRunHistoryViewAll"; 1588 viewAll.className = "button secondary mvp-run-history__view-all"; 1589 viewAll.type = "button"; 1590 viewAll.textContent = `View all runs (${runs.length})`; 1591 viewAll.addEventListener("click", () => renderRunHistory(runs, { showAll: true })); 1592 runHistoryList.appendChild(viewAll); 1593 } 1594 }; 1595 1596 const loadRunHistory = async () => { 1597 if (!runHistoryStatus) return; 1598 runHistoryStatus.textContent = "Loading your runsâ¦"; 1599 runHistoryStatus.hidden = false; 1600 try { 1601 const res = await authenticatedFetch("/api/mvp/runs?limit=10", { credentials: "same-origin" }, { endpointType: "run_history" }); 1602 const data = await parseResponsePayload(res); 1603 if (!res.ok || data?.ok === false) throw responseError(res, data, "Unable to load your runs.", { endpointType: "run_history" }); 1604 renderRunHistory(data?.runs); 1605 cachedRunHistory = Array.isArray(data?.runs) ? data.runs : []; 1606 runHistoryLoaded = true; 1607 return cachedRunHistory; 1608 } catch (err) { 1609 runHistoryStatus.textContent = err?.message || "Unable to load your runs."; 1610 return []; 1611 } 1612 }; 1613 1614 const clearClaimParam = () => { 1615 try { 1616 const url = new URL(window.location.href); 1617 url.searchParams.delete("claim"); 1618 url.searchParams.delete("preview"); 1619 window.history.replaceState({}, document.title, `${url.pathname}${url.search}${url.hash}`); 1620 } catch {} 1621 }; 1622 1623 const claimErrorMessage = (payload, status) => { 1624 const code = String(payload?.code || ""); 1625 if (code === "claim_expired" || status === 410) return "This customer link has expired. Ask for a new link."; 1626 if (code === "claim_invalid" || code === "claim_unknown" || [400, 404].includes(status)) return "This customer link is invali
1626d."; 1627 if (code === "claim_unavailable" || code === "claim_source_owner_changed" || status === 409) return "This customer link has already been used or is unavailable."; 1628 return "The supplier dataset could not be opened yet. Please try again."; 1629 }; 1630 1631 const claimSalesAgentRun = async () => { 1632 const token = claimTokenFromLocation(); 1633 if (!token || claimInProgress || claimedToken === token || !isSignedIn()) return; 1634 if (!/^[A-Za-z0-9_-]{43}$/.test(token)) { 1635 setAuthMessage("This customer link is invalid.", true); 1636 return; 1637 } 1638 claimInProgress = true; 1639 setAuthMessage("Opening your supplier datasetâ¦"); 1640 try { 1641 const res = await authenticatedFetch(`/api/mvp/sales-agent-claims/${encodeURIComponent(token)}/claim`, { 1642 method: "POST", 1643 credentials: "same-origin", 1644 }, { endpointType: "sales_agent_claim" }); 1645 const data = await parseResponsePayload(res); 1646 if (!res.ok || data?.ok === false || !data?.runId) throw new Error(claimErrorMessage(data, res.status)); 1647 claimedToken = token; 1648 clearClaimParam(); 1649 const runs = await loadRunHistory(); 1650 const run = runs.find((candidate) => candidate?.runId === data.runId); 1651 if (!run) throw new Error("The dataset was claimed, but it is not visible in My runs yet. Refresh and open it from My runs."); 1652 const button = document.createElement("button"); 1653 button.textContent = "Open result"; 1654 const opened = await openHistoryRun(run, button); 1655 if (!opened) throw new Error("The dataset is in your account, but its result could not be opened yet. Try again from My runs."); 1656 setAuthMessage(data.alreadyClaimed ? "This supplier dataset is already in your account." : "Supplier dataset added to your account."); 1657 } catch (err) { 1658 setAuthMessage(err?.message || "The supplier dataset could not be opened yet. Please try again.", true); 1659 } finally { 1660 claimInProgress = false; 1661 } 1662 }; 1663 const perfPhase = (phase, durationMs, payload = {}) => log("mvp_perf_phase", { 1664 phase, 1665 durationMs: Math.max(0, Math.round(Number(durationMs) || 0)), 1666 ...payload, 1667 }); 1668 window.addEventListener("load", () => setTimeout(() => { 1669 const navigation = performance.getEntriesByType?.("navigation")?.[0]; 1670 const resources = performance.getEntriesByType?.("resource") || []; 1671 const resource = (fragment) => resources.find((entry) => String(entry.name || "").includes(fragment)); 1672 const appScript = resource("/app/mvpApp.js"); 1673 const supabaseScript = resource("@supabase/supabase-js"); 1674 const css = resources.filter((entry) => String(entry.name || "").endsWith(".css")); 1675 log("mvp_page_load_perf", { 1676 domContentLoadedMs: navigation ? Math.round(navigation.domContentLoadedEventEnd) : null, 1677 windowLoadMs: navigation ? Math.round(navigation.loadEventEnd) : null, 1678 jsInitializationMs: appInitCompletedMs, 1679 mvpAppDownloadMs: appScript ? Math.round(appScript.duration) : null, 1680 mvpAppTransferBytes: appScript?.transferSize || null, 1681 cssRequestCount: css.length, 1682 cssDurationMs: Math.round(css.reduce((total, entry) => total + entry.duration, 0)), 1683 cssTransferBytes: css.reduce((total, entry) => total + (entry.transferSize || 0), 0), 1684 supabaseCdnMs: supabaseScript ? Math.round(supabaseScript.duration) : null, 1685 supabaseTransferBytes: supabaseScript?.transferSize || null, 1686 totalRequestCount: resources.length + 1, 1687 }); 1688 }, 0), { once: true }); 1689 1690 const previewSalesAgentRun = async () => { 1691 const token = claimTokenFromLocation(); 1692 if (!token || previewedToken === token || !/^[A-Za-z0-9_-]{43}$/.test(token)) return; 1693 try { 1694 const res = await fetch(`/api/mvp/sales-agent-claims/${encodeURIComponent(token)}/preview`, { credentials: "same-origin" }); 1695 const data = await parseResponsePayload(res); 1696 if (!res.ok || data?.ok === false) throw new Error(claimErrorMessage(data, res.status)); 1697 previewedToken = token; 1698 renderCompletedPackage(data, { sourceType: "url", url: data.sourceUrl, host: data.host, salesAgentClaimPreview: true }, Date.now(), "sales_agent_claim_previewed"); 1699 if (!isSignedIn()) setAuthMessage("Previewing a prepared supplier dataset. Sign in to add it to your account and unlock paid continuation or enrichment."); 1700 successPanel?.scrollIntoView?.({ behavior: "smooth", block: "start" }); 1701 } catch (err) { 1702 setAuthMessage(err?.message || "The supplier dataset preview could not be opened yet.", true); 1703 } 1704 }; 1705 1706 myRunsButton?.addEventListener("click", () => { 1707 if (!runHistory) return; 1708 const opening = runHistory.hidden; 1709 runHistory.hidden = !opening; 1710 myRunsButton.setAttribute("aria-expanded", opening ? "true" : "false"); 1711 if (opening) { 1712 void loadRunHistory(); 1713 runHistory.focus?.(); 1714 } 1715 }); 1716 1717 runHistoryClose?.addEventListener("click", () => { 1718 closeRunHistory({ restoreFocus: true }); 1719 }); 1720 1721 runHistory?.addEventListener("keydown", (event) => { 1722 if (event.key !== "Escape") return; 1723 event.preventDefault(); 1724 closeRunHistory({ restoreFocus: true }); 1725 }); 1726 1727 const setStateCopy = (state) => { 1728 const copy = STATE_COPY[state] || STATE_COPY.success; 1729 if (stateTitle) stateTitle.textContent = copy.title; 1730 if (resultBanner) { 1731 resultBanner.textContent = copy.banner; 1732 resultBanner.hidden = !["failure", "capture"].includes(state); 1733 } 1734 if (stateMessage) { 1735 stateMessage.textContent = copy.message; 1736 stateMessage.hidden = !copy.message; 1737 } 1738 if (stateHint) { 1739 stateHint.textContent = copy.hint; 1740 stateHint.hidden = !copy.hint; 1741 } 1742 }; 1743 1744 const URL_WARNING_ISSUES = new Set(["missing_sku", "missing_price", "missing_image", "local_image_path", "weak_description"]); 1745 const UPLOAD_WARNING_ISSUES = new Set(["missing_image", "missing_description", "missing_category", "missing_tags"]); 1746 1747 const plural = (count, singular, pluralText = `${singular}s`) => `${count} ${count === 1 ? singular : pluralText}`; 1748 1749 const rowsNeedingReviewFromProducts = (products, totalProducts, sourceType) => { 1750 if (!Array.isArray(products) || !products.length) return null; 1751 const total = Number(totalProducts || 0); 1752 if (total > products.length) return null; 1753 const warningIssues = sourceType === "upload" ? UPLOAD_WARNING_ISSUES :
1753URL_WARNING_ISSUES; 1754 return products.filter((product) => 1755 Array.isArray(product?.issues) && product.issues.some((issue) => warningIssues.has(String(issue || ""))) 1756 ).length; 1757 }; 1758 1759 const cappedCount = (value, max) => { 1760 const count = Number(value); 1761 if (!Number.isFinite(count)) return null; 1762 return Math.max(0, Math.min(max, count)); 1763 }; 1764 1765 const rowsNeedingReviewCount = ({ productsFound, summary, customerSummary, preview, sourceType }) => { 1766 const products = Math.max(0, Number(productsFound || 0)); 1767 if (!products) return 0; 1768 const summaryReviewRows = cappedCount(summary?.reviewRows, products); 1769 if (summaryReviewRows !== null) return summaryReviewRows; 1770 if (Object.prototype.hasOwnProperty.call(customerSummary || {}, "review_rows")) { 1771 const customerReviewRows = cappedCount(customerSummary?.review_rows, products); 1772 if (customerReviewRows !== null) return customerReviewRows; 1773 } 1774 const fromProducts = rowsNeedingReviewFromProducts(preview, products, sourceType); 1775 if (Number.isFinite(fromProducts)) return Math.max(0, Math.min(products, fromProducts)); 1776 return 0; 1777 }; 1778 1779 const importabilityText = (importableRows, totalRows) => { 1780 const total = Number(totalRows || 0); 1781 const importable = Number(importableRows || 0); 1782 if (!Number.isFinite(total) || total <= 0) return "0%"; 1783 return `${Math.round((Math.max(0, importable) / total) * 100)}%`; 1784 }; 1785 1786 const dataQualityStatus = ({ warningCount, blockingIssueCount, blockedProducts }) => { 1787 const warnings = Number(warningCount || 0); 1788 const blockers = Number(blockingIssueCount || 0); 1789 const blocked = Number(blockedProducts || 0); 1790 if (blockers > 0 || blocked > 0) return "Blocked issues found"; 1791 if (warnings > 0) return "Manual review recommended"; 1792 return "Good"; 1793 }; 1794 1795 const setImportQualityMetrics = ({ productsFound, importReadyProducts, warningCount, blockingIssueCount, blockedProducts }) => { 1796 const importability = importabilityText(importReadyProducts, productsFound); 1797 const dataQuality = dataQualityStatus({ warningCount, blockingIssueCount, blockedProducts }); 1798 const metricImportability = E("mvpMetricImportability"); 1799 const summaryMetricImportability = E("mvpSummaryMetricImportability"); 1800 const metricDataQuality = E("mvpMetricDataQuality"); 1801 const summaryMetricDataQuality = E("mvpSummaryMetricDataQuality"); 1802 if (metricImportability) metricImportability.textContent = importability; 1803 if (summaryMetricImportability) summaryMetricImportability.textContent = importability; 1804 if (metricDataQuality) metricDataQuality.textContent = dataQuality; 1805 if (summaryMetricDataQuality) summaryMetricDataQuality.textContent = dataQuality; 1806 }; 1807 1808 const hideDownloadLinks = () => { 1809 DOWNLOAD_LINKS.forEach(([id, _key, itemId]) => { 1810 const el = E(id); 1811 const item = E(itemId); 1812 if (!el) return; 1813 if (item) item.style.display = "none"; 1814 el.href = "#"; 1815 }); 1816 if (freeFilesPanel) freeFilesPanel.hidden = true; 1817 }; 1818 1819 const setNoReportMessageVisible = (visible) => { 1820 if (noReportMessage) noReportMessage.hidden = !visible; 1821 }; 1822 1823 const mainIssuePriority = (issue) => { 1824 const text = String(issue || "").toLowerCase(); 1825 return /filter|navigation|product title|product name|product url|add a sku|add a price|numeric price|duplicate sku|duplicate product title|duplicate title|duplicate handle/.test(text) 1826 ? 0 1827 : 1; 1828 }; 1829 1830 const mainIssueTextFor = (mainIssues, fallback = "") => { 1831 const issues = Array.isArray(mainIssues) 1832 ? mainIssues.map((issue) => String(issue || "").trim()).filter(Boolean) 1833 : []; 1834 const uniqueIssues = []; 1835 const prioritizedIssues = issues 1836 .map((issue, index) => ({ issue, index, priority: mainIssuePriority(issue) })) 1837 .sort((a, b) => a.priority - b.priority || a.index - b.index) 1838 .map((item) => item.issue); 1839 for (const issue of prioritizedIssues) { 1840 if (!uniqueIssues.includes(issue)) uniqueIssues.push(issue); 1841 if (uniqueIssues.length >= 5) break; 1842 } 1843 const displayIssues = uniqueIssues.map((issue) => issue 1844 .replace(/^\d+\s+row\(s\):\s*/i, "") 1845 .replace(/\s+such as 19\.99\.?$/i, "") 1846 .replace(/\.+$/g, "") 1847 .trim()
1848 ).filter(Boolean); 1849 if (displayIssues.length > 1) return `Main issues: ${displayIssues.join("; ")}.`; 1850 if (displayIssues.length === 1) return `Main issue: ${displayIssues[0]}.`; 1851 return String(fallback || "").trim(); 1852 }; 1853 1854 const hasCleanupArtifacts = (result = lastMvpResult) => { 1855 const artifacts = result?.artifacts; 1856 return Boolean(!result?.readOnly && artifacts && typeof artifacts === "object" && artifacts.llmReadyJson); 1857 }; 1858 1859 const resetCleanupAction = (message = "Generate a report first to unlock this review action.") => { 1860 if (cleanupTitle) cleanupTitle.textContent = "Prepare Shopify import draft"; 1861 if (cleanupDescription) cleanupDescription.hidden = false; 1862 if (cleanupRequestLink) cleanupRequestLink.disabled = true; 1863 if (cleanupRequestLink) cleanupRequestLink.hidden = false; 1864 if (cleanupStatus) { 1865 cleanupStatus.textContent = message; 1866 cleanupStatus.hidden = false; 1867 } 1868 if (cleanupDownload) { 1869 cleanupDownload.hidden = true; 1870 cleanupDownload.removeAttribute("href"); 1871 } 1872 if (cleanupPreview) cleanupPreview.hidden = true; 1873 }; 1874 1875 const storeLastMvpResult = (data, requestMeta = {}) => { 1876 lastMvpResult = { 1877 requestId: data?.requestId || null, 1878 runId: data?.runId || null, 1879 readOnly: data?.readOnly === true, 1880 sourceType: requestMeta?.sourceType || "", 1881 url: requestMeta?.url || "", 1882 host: requestMeta?.host || data?.customerSummary?.supplier_context?.host || "", 1883 fileName: requestMeta?.fileName || "", 1884 summary: data?.summary || {}, 1885 customerSummary: data?.customerSummary || {}, 1886 preview: Array.isArray(data?.preview) ? data.preview : [], 1887 artifacts: data?.artifacts || {}, 1888 }; 1889 if (cleanupRequestLink) cleanupRequestLink.disabled = !hasCleanupArtifacts(lastMvpResult); 1890 if (cleanupRequestLink) cleanupRequestLink.hidden = false; 1891 if (cleanupTitle) cleanupTitle.textContent = "Prepare Shopify import draft"; 1892 if (cleanupDescription) cleanupDescription.hidden = false; 1893 if (cleanupStatus) { 1894 const readyForCleanup = hasCleanupArtifacts(lastMvpResult); 1895 cleanupStatus.textContent = readyForCleanup 1896 ? "" 1897 : "Cleaned CSV generation needs the enrichment-ready JSON artifact from a completed package."; 1898 cleanupStatus.hidden = readyForCleanup; 1899 } 1900 if (cleanupDownload) { 1901 cleanupDownload.hidden = true; 1902 cleanupDownload.removeAttribute("href"); 1903 } 1904 if (cleanupPreview) cleanupPreview.hidden = true; 1905 }; 1906 1907 resetCleanupAction(); 1908 1909 const authenticatedActionAllowed = () => isSignedIn(); 1910 function renderActionAccessGates() { 1911 const allowed = authenticatedActionAllowed(); 1912 const message = isSignedIn() 1913 ? t("mvp.paid.add", null, "Add credits to use this action") 1914 : t("mvp.paid.signIn", null, "Sign in to use this action"); 1915 const balance = isSignedIn() ? Math.max(0, Number(lastUsage?.paidCreditsRemaining || 0)) : 0; 1916 if (!allowed && marketingTemplatePreview) { 1917 marketingTemplatePreview.textContent = ""; 1918 marketingTemplatePreview.hidden = true; 1919 } 1920 const ids = [...DOWNLOAD_LINKS.map(([id]) => id), "mvpMainIssuesDownload", "mvpExportCurrentProducts", 1921 "mvpEnrichProductsBtn", "mvpOpenMarketingContent", "mvpPrepareMarketingRequest", "mvpMarketingDownload", "mvpCleanupRequestLink", "mvpCleanupDownload"]; 1922 ids.forEach((id) => { 1923 const el = E(id); 1924 if (!el) return; 1925 const insufficientEnrichmentCredits = id === "mvpEnrichProductsBtn" && balance < Number(el.dataset.creditsRequired || 0); 1926 if (id === "mvpEnrichProductsBtn") el.dataset.requiresRuns = String(isSignedIn() && insufficientEnrichmentCredits); 1927 const unavailable = id === "mvpEnrichProductsBtn" ? enrichmentInProgress || insufficientEnrichmentCredits 1928 : el === cleanupRequestLink ? !hasCleanupArtifacts(lastMvpResult) 1929 : el === marketingDownload ? !el.getAttribute("href") : false; 1930 el.dataset.mvpPaidAction = "true"; 1931 el.disabled = !allowed || unavailable; 1932 el.setAttribute("aria-disabled", String(el.disabled)); 1933 if (el.tagName === "A") el.tabIndex = el.disabled ? -1 : 0; 1934 if (!allowed && el === marketingDownload) el.removeAttribute("href"); 1935 let support = E(`${id}PaidGate`); 1936 if (!support) { 1937 support = document.createElement("div"); 1938 support.id = `${id}PaidGate`; 1939 support.className = "status mvp-paid-gate"; 1940 el.after(support); 1941 } 1942 support.hidden = el.hidden || (allowed && !insufficientEnrichmentCredits); 1943 support.textContent = ""; 1944 const locked = !allowed || insufficientEnrichmentCredits; 1945 const cta = document.createElement("button"); 1946 cta.type = "button"; 1947 cta.className = "button secondary"; 1948 cta.textContent = message; 1949 // Reserve the CTA space so purchasing credits does not move the actions. 1950 cta.style.visibility = locked ? "visible" : "hidden"; 1951 cta.disabled = !locked; 1952 cta.setAttribute("aria-hidden", String(!locked)); 1953 cta.addEventListener("click", () => focusAccountFromGate("signin", "download")); 1954 support.appendChild(cta); 1955 }); 1956 } 1957 document.addEventListener("click", (event) => { 1958 const action = event.target instanceof Element ? event.target.closest('[data-mvp-paid-action][aria-disabled="true"]') : null; 1959 if (action) { event.preventDefault(); event.stopImmediatePropagation(); } 1960 }, true); 1961 1962 const renderDownloadLinks = (artifacts, authenticated = isSignedIn()) => { 1963 DOWNLOAD_LINKS.forEach(([id, key, itemId]) => { 1964 const el = E(id); 1965 const item = E(itemId); 1966 if (!el) return; 1967 const artifactPath = String(artifacts?.[key] || ""); 1968 if (!el.dataset.mvpDownloadLabel) el.dataset.mvpDownloadLabel = el.textContent || "Download file"; 1969 if (item) item.style.display = artifactPath ? "grid" : "none"; 1970 el.textContent = el.dataset.mvpDownloadLabel; 1971 el.dataset.mvpDownloadPath = artifactPath; 1972 el.removeAttribute("href"); 1973 let fileLabel = item?.querySelector("[data-mvp-file-label]"); 1974 if (item && artifactPath && !fileLabel) { 1975 fileLabel = document.createElement("p"); 1976 fileLabel.className = "status"; 1977 fileLabel.dataset.mvpFileLabel = "true"; 1978 item.appendChild(fileLabel); 1979 } 1980 if (fileLabel) fileLabel.textContent = `${el.dataset.mvpDownloadLabel} · ${artifactPath.split(/[\\/]/).pop() || artifactPath}`; 1981 }); 1982 if (freeFilesPanel) { 1983 const visiblePrimaryKeys = ["exportCsv", "shopifyCsv", "bigcommerceCsv", "summaryJson", "llmReadyJson", "issuesCsv", "catalogMapCsv", "importReadinessReport", "importReadinessReportPdf"]; 1984 freeFilesPanel.hidden = !visiblePrimaryKeys 1985 .some((key) => String(artifacts?.[key] || "")); 1986 } 1987 const mainIssuesDownload = E("mvpMainIssuesDownload"); 1988 if (mainIssuesDownload) { 1989 const issuesPath = String(artifacts?.issuesCsv || ""); 1990 if (!mainIssuesDownload.dataset.mvpDownloadLabel) mainIssuesDownload.dataset.mvpDownloadLabel = mainIssuesDownload.textContent || "Review issues"; 1991 mainIssuesDownload.textContent = mainIssuesDownload.dataset.mvpDownloadLabel; 1992 mainIssuesDownload.dataset.mvpDownloadPath = issuesPath; 1993 mainIssuesDownload.removeAttribute("href"); 1994 const item = mainIssuesDownload.closest(".mvp-download-item"); 1995 if (item) item.style.display = issuesPath ? "grid" : "none"; 1996 let fileLabel = item?.querySelector("[data-mvp-file-label]"); 1997 if (item && issuesPath && !fileLabel) { 1998 fileLabel = document.createElement("p"); 1999 fileLabel.className = "status"; 2000 fileLabel.dataset.mvpFileLabel = "true"; 2001 item.appendChild(fileLabel); 2002 } 2003 if (fileLabel) fileLabel.textContent = `${mainIssuesDownload.dataset.mvpDownloadLabel} · ${issuesPath.split(/[\\/]/).pop() || issuesPath}`; 2004 } 2005 }; 2006 2007 const currentAnonymousCompletedRun = () => { 2008 const displayed = lastCompletedPackage?.data; 2009 if (anonymousCompletedRunId && displayed?.runId === anonymousCompletedRunId) {
2010 return { runId: anonymousCompletedRunId, sourceUrl: lastCompletedPackage?.requestMeta?.url, host: lastCompletedPackage?.requestMeta?.host }; 2011 } 2012 try { 2013 const completed = JSON.parse(window.localStorage?.getItem(ANONYMOUS_COMPLETED_RUN_STORAGE_KEY) || "null"); 2014 if (completed?.runId) return completed; 2015 const persisted = JSON.parse(window.localStorage?.getItem(ACTIVE_DISCOVERY_STORAGE_KEY) || "null"); 2016 if (persisted?.runId && String(persisted.status || "").toLowerCase() === "completed") return persisted; 2017 } catch {} 2018 return null; 2019 }; 2020 2021 const claimCurrentAnonymousRun = async () => { 2022 const run = currentAnonymousCompletedRun(); 2023 if (!isSignedIn() || !run?.runId) return false; 2024 if (anonymousClaimPromise) return anonymousClaimPromise; 2025 anonymousClaimPromise = (async () => { 2026 const res = await authenticatedFetch(`/api/mvp/runs/${encodeURIComponent(run.runId)}/claim-anonymous`, { 2027 method: "POST", 2028 credentials: "same-origin", 2029 }, { endpointType: "anonymous_run_claim", runId: run.runId }); 2030 const claimed = await parseResponsePayload(res); 2031 if (!res.ok || claimed?.ok === false) throw responseError(res, claimed, "Unable to add this run to your account.", { endpointType: "anonymous_run_claim", runId: run.runId }); 2032 anonymousCompletedRunId = ""; 2033 try { window.localStorage?.removeItem(ANONYMOUS_COMPLETED_RUN_STORAGE_KEY); } catch {} 2034 const resultRes = await authenticatedFetch(`/api/mvp/runs/${encodeURIComponent(run.runId)}/result`, { 2035 credentials: "same-origin", 2036 }, { endpointType: "run_result", runId: run.runId }); 2037 const data = await parseResponsePayload(resultRes); 2038 if (!resultRes.ok || data?.ok === false) throw responseError(resultRes, data, "Unable to refresh this run.", { endpointType: "run_result", runId: run.runId }); 2039 renderCompletedPackage(data, { sourceType: "url", url: run.sourceUrl || data.sourceUrl, host: run.host || data.host }, Date.now(), "mvp_anonymous_run_claimed"); 2040 await loadRunHistory(); 2041 return true; 2042 })().finally(() => { anonymousClaimPromise = null; }); 2043 return anonymousClaimPromise; 2044 }; 2045 2046 const handleAuthenticatedTransition = async () => { 2047 if (isSignedIn()) { 2048 if (!claimPreviewOnlyFromLocation()) { 2049 try { await claimCurrentAnonymousRun(); } catch (err) { setAuthMessage(err?.message || "Unable to add this run to your account.", true); } 2050 } 2051 if (supabaseState.pendingCheckoutSessionId) return completePendingCheckout(); 2052 if (claimTokenFromLocation()) return claimSalesAgentRun(); 2053 } 2054 return loadUsage(); 2055 }; 2056 2057 const downloadArtifact = async (artifactPath) => { 2058 if (!authenticatedActionAllowed()) return; 2059 const response = await authenticatedFetch(`/api/mvp/download?path=${encodeURIComponent(artifactPath)}`, {}, { endpointType: "download" }); 2060 if (!response.ok) { 2061 const payload = await parseResponsePayload(response); 2062 throw responseError(response, payload, "Download failed.", { endpointType: "download", authenticatedRequest: true }); 2063 } 2064 const blob = await response.blob(); 2065 const disposition = String(response.headers.get("content-disposition") || ""); 2066 const fileName = disposition.match(/filename="?([^";]+)"?/i)?.[1] || artifactPath.split(/[\\/]/).pop() || "download"; 2067 const objectUrl = URL.createObjectURL(blob); 2068 const link = document.createElement("a"); 2069 link.href = objectUrl; 2070 link.download = fileName; 2071 document.body.appendChild(link); 2072 link.click(); 2073 link.remove(); 2074 window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0); 2075 }; 2076 2077 document.addEventListener("click", (event) => { 2078 const target = event.target instanceof Element ? event.target.closest("[data-mvp-download-path]") : null; 2079 if (!target) return; 2080 event.preventDefault(); 2081 if (!isSignedIn()) { 2082 focusAccountFromGate("signin", "download"); 2083 return; 2084 } 2085 const artifactPath = String(target.dataset.mvpDownloadPath || ""); 2086 if (!artifactPath) { 2087 if (isSignedIn()) focusAccountFromGate("signin"); 2088 else focusAccountFromGate("signin", "catalog"); 2089 return; 2090 } 2091 downloadArtifact(artifactPath).catch((error) => setCheckoutMessage(error?.message || "Download failed.", true)); 2092 }); 2093 2094 const setText = (id, value) => { 2095 const el = E(id); 2096 if (el) el.textContent = String(value ?? ""); 2097 }; 2098 2099 if (explorerMode) { 2100 setText("mvpTitle", "Explore a product website"); 2101 setText("mvpHeroDescription", "Discover products, categories, and product pages from a public website."); 2102 if (runButton) runButton.textContent = "Discover products"; 2103 if (noReportMessage) noReportMessage.textContent = "No catalog yet. Enter a public website URL to discover products."; 2104 explorerTabs?.querySelectorAll("[data-mvp-explorer-tab]").forEach((button) => button.addEventListener("click", () => setExplorerTab(button.dataset.mvpExplorerTab))); 2105 } 2106 2107 const numberText = (value) => { 2108 const number = Number(value); 2109 return Number.isFinite(number) ? String(number) : "0"; 2110 }; 2111 2112 const textOrDash = (value) => { 2113 const text = String(value ?? "").trim(); 2114 return text || "â"; 2115 }; 2116 2117 const finiteCount = (value, fallback = 0) => { 2118 const count = Number(value); 2119 return Number.isFinite(count) ? Math.max(0, count) : fallback; 2120 }; 2121 2122 const firstFiniteCount = (...values) => { 2123 const value = values.find((candidate) => candidate !== null && candidate !== undefined && candidate !== "" && Number.isFinite(Number(candidate))); 2124 return value === undefined ? null : finiteCount(value); 2125 }; 2126 2127 const discoveryLabel = (value) => { 2128 const raw = String(value ?? "").trim().toLowerCase();
2129 if (raw === "not_catalog_candidate") return "Not a product or collection page"; 2130 if (raw === "queued") return "Waiting to scan"; 2131 const text = raw.replace(/_/g, " "); 2132 return text ? text.charAt(0).toUpperCase() + text.slice(1) : "â"; 2133 }; 2134 2135 const discoveryStoppedReasonLabel = (value) => { 2136 const labels = { 2137 max_extract_pages: "Current scan batch completed", 2138 no_more_candidates: "All available pages checked", 2139 fallback_single_page: "Only the submitted page could be checked", 2140 error: "The catalog scan encountered an error", 2141 }; 2142 const reason = String(value ?? "").trim().toLowerCase(); 2143 return labels[reason] || discoveryLabel(reason); 2144 }; 2145 2146 const safeDiscoveryPageUrl = (value) => { 2147 try { 2148 const parsed = new URL(String(value || "")); 2149 return /^https?:$/.test(parsed.protocol) ? parsed.toString() : ""; 2150 } catch { 2151 return ""; 2152 } 2153 }; 2154 2155 const renderDiscoveryPageTable = (pages) => { 2156 const table = document.createElement("table"); 2157 table.className = "mvp-discovery-map-table"; 2158 const head = document.createElement("thead"); 2159 const headRow = document.createElement("tr"); 2160 ["Page", "URL", "Page type", "Status", t("mvp.details.candidates", null, "Product candidates detected"), "Reason"].forEach((label) => { 2161 const cell = document.createElement("th"); 2162 cell.scope = "col"; 2163 cell.textContent = label; 2164 headRow.append(cell); 2165 }); 2166 head.append(headRow); 2167 table.append(head); 2168 const body = document.createElement("tbody"); 2169 pages.forEach((page) => { 2170 const row = document.createElement("tr"); 2171 const values = [ 2172 page.title || "Page", 2173 page.url, 2174 discoveryLabel(page.pageType), 2175 discoveryLabel(page.status), 2176 page.status === "scanned" ? numberText(page.productsFound) : "â", 2177 page.reason ? discoveryLabel(page.reason) : "â", 2178 ]; 2179 values.forEach((value, index) => { 2180 const cell = document.createElement("td"); 2181 cell.dataset.label = ["Page", "URL", "Page type", "Status", t("mvp.details.candidates", null, "Product candidates detected"), "Reason"][index]; 2182 if (index === 1 && page.url) { 2183 const link = document.createElement("a"); 2184 link.href = page.url; 2185 link.target = "_blank"; 2186 link.rel = "noopener"; 2187 link.textContent = page.url; 2188 cell.append(link); 2189 } else { 2190 cell.textContent = String(value); 2191 } 2192 row.append(cell); 2193 }); 2194 body.append(row); 2195 }); 2196 table.append(body); 2197 return table; 2198 }; 2199 2200 const isProblemDiscoveryPage = (page) => { 2201 const status = String(page?.status || "").toLowerCase(); 2202 const reason = String(page?.reason || "").toLowerCase(); 2203 const pageType = String(page?.pageType || "").toLowerCase(); 2204 if (status === "scanned") { 2205 return finiteCount(page.productsFound) === 0 && ["listing_or_category", "product_detail"].includes(pageType); 2206 } 2207 if (status === "failed") return true; 2208 return status === "excluded" && ["classification_uncertain", "uncertain_catalog_relevance"].includes(reason); 2209 }; 2210 2211 const discoveryProblemPages = (discovery) => ( 2212 Array.isArray(discovery?.pages) ? discovery.pages.filter(isProblemDiscoveryPage) : [] 2213 ); 2214 2215 const renderDiscoveryMap = (discovery, { problemOnly = true, filter = "needs_review" } = {}) => { 2216 const container = E("mvpDiscoveryMapGroups"); 2217 if (!container) return; 2218 container.replaceChildren(); 2219 const allPages = Array.isArray(discovery?.pages) ? discovery.pages : []; 2220 const selectedFilter = problemOnly ? "needs_review" : filter; 2221 const pages = selectedFilter === "needs_review" 2222 ? allPages.filter(isProblemDiscoveryPage) 2223 : allPages.filter((page) => page.status === selectedFilter); 2224 const grouped = { scanned: [], queued: [], excluded: [], failed: [] }; 2225 pages.forEach((page) => { 2226 if (grouped[page.status]) grouped[page.status].push(page); 2227 }); 2228 setText("mvpCrawlStartingPage", textOrDash(discovery?.submittedUrl)); 2229 setText("mvpCrawlHost", textOrDash(discovery?.host)); 2230 setText("mvpCrawlCandidates", numberText(discovery?.candidatePagesFound)); 2231 setText("mvpCrawlScanned", numberText(discovery?.pagesScanned)); 2232 setText("mvpCrawlProductsReturned", numberText(discovery?.productsFound)); 2233 setText("mvpCrawlQueued", numberText(discovery?.pagesRemaining)); 2234 setText("mvpCrawlExcluded", numberText(discovery?.excludedPages)
2234); 2235 setText("mvpCrawlFailed", numberText(discovery?.failedPages)); 2236 const filterNotice = E("mvpDiscoveryProblemFilter"); 2237 if (filterNotice) filterNotice.hidden = selectedFilter !== "needs_review"; 2238 document.querySelectorAll("[data-mvp-page-filter]").forEach((button) => { 2239 button.setAttribute("aria-pressed", button.dataset.mvpPageFilter === selectedFilter ? "true" : "false"); 2240 const totals = { 2241 needs_review: discoveryProblemPages(discovery).length, 2242 scanned: discovery?.pagesScanned, 2243 queued: discovery?.pagesRemaining, 2244 excluded: discovery?.excludedPages, 2245 failed: discovery?.failedPages, 2246 }; 2247 const labels = { needs_review: "Needs review", scanned: "Scanned", queued: "Waiting to scan", excluded: "Excluded", failed: "Failed" }; 2248 const status = button.dataset.mvpPageFilter; 2249 button.textContent = `${labels[status] || discoveryLabel(status)} (${numberText(totals[status])})`; 2250 }); 2251 if (pages.length === 0) { 2252 const empty = document.createElement("p"); 2253 empty.className = "mvp-discovery-map-empty"; 2254 empty.textContent = t("mvp.details.emptyFilter", null, "No pages match this filter."); 2255 container.append(empty); 2256 return; 2257 } 2258 Object.entries(grouped).forEach(([status, groupPages]) => { 2259 if (!groupPages.length) return; 2260 const section = document.createElement("section"); 2261 section.className = `mvp-discovery-map-group mvp-discovery-map-group--${status}`; 2262 const heading = document.createElement("h4"); 2263 const canonicalQueued = finiteCount(discovery?.pagesRemaining); 2264 heading.textContent = status === "queued" && groupPages.length !== canonicalQueued 2265 ? `Waiting-to-scan routes shown (${groupPages.length} of ${canonicalQueued})` 2266 : `${discoveryLabel(status)} (${groupPages.length})`; 2267 section.append(heading); 2268 if (groupPages.length === 0) { 2269 const empty = document.createElement("p"); 2270 empty.className = "status mvp-discovery-map-empty"; 2271 empty.textContent = status === "queued" && canonicalQueued > 0 2272 ? `${plural(canonicalQueued, "page")} remain waiting to scan; route details are not included in this view.` 2273 : `No ${status === "queued" ? "waiting-to-scan" : status} pages.`; 2274 section.append(empty); 2275 } else { 2276 const previewLimit = ["queued", "excluded"].includes(status) ? 3 : 10; 2277 section.append(renderDiscoveryPageTable(groupPages.slice(0, previewLimit))); 2278 if (groupPages.length > previewLimit) { 2279 const remaining = document.createElement("details"); 2280 remaining.className = "mvp-discovery-map-more"; 2281 const summary = document.createElement("summary"); 2282 summary.textContent = `Show all ${groupPages.length} ${status} pages`; 2283 remaining.append(summary, renderDiscoveryPageTable(groupPages.slice(previewLimit))); 2284 section.append(remaining); 2285 } 2286 } 2287 container.append(section); 2288 }); 2289 }; 2290 2291 const discoveryModelFromResponse = (data) => { 2292 const coverage = data?.customerSummary?.discovery_coverage || data?.discoveryCoverage || null; 2293 if (!coverage || coverage.enabled !== true) return null; 2294 const selectedExtractUrls = Array.isArray(coverage.selected_extract_urls) 2295 ? coverage.selected_extract_urls 2296 : Array.isArray(coverage.selectedExtractUrls) ? coverage.selectedExtractUrls : []; 2297 const routesExtracted = Array.isArray(coverage.routes_extracted) 2298 ? coverage.routes_extracted 2299 : Array.isArray(coverage.routesExtracted) ? coverage.routesExtracted : []; 2300 const productsFound = finiteCount( 2301 coverage.products_after_dedupe 2302 ?? coverage.productsAfterDedupe 2303 ?? data?.summary?.productsFound 2304 ?? data?.customerSummary?.products_found, 2305 ); 2306 const hasPageStatuses = Array.isArray(coverage.pages) && coverage.pages.length > 0; 2307 const pages = (hasPageStatuses ? coverage.pages : []).flatMap((page) => { 2308 const url = safeDiscoveryPageUrl(page?.url); 2309 const status = String(page?.status || "").toLowerCase(); 2310 if (!url || !["scanned", "queued", "excluded", "failed"].includes(status)) return []; 2311 return [{ 2312 title: String(page?.title || "").trim(), 2313 url, 2314 pageType: page?.page_type ?? page?.pageType, 2315 status, 2316 productsFound: finiteCount(page?.products_found ?? page?.productsFound), 2317 reason: page?.reason, 2318 }]; 2319 }); 2320 const reportedFailedOrBlockedPages = Array.isArray(coverage.pages_blocked_or_failed) 2321 ? coverage.pages_blocked_or_failed 2322 : Array.isArray(coverage.pagesBlockedOrFailed) ? coverage.pagesBlockedOrFailed : []; 2323 const reportedCandidates = firstFiniteCount(coverage.candidate_pages_found, coverage.candidatePagesFound); 2324 const reportedAttempted = firstFiniteCount(coverage.pages_attempted, coverage.pagesAttempted); 2325 const reportedRemaining = firstFiniteCount(coverage.pages_remaining, coverage.pagesRemaining); 2326 const submittedUrl = safeDiscoveryPageUrl(coverage.submitted_url ?? coverage.submittedUrl);
2327 pages.forEach((page) => { page.isSubmitted = Boolean(submittedUrl && page.url === submittedUrl); }); 2328 if (submittedUrl && !pages.some((page) => page.isSubmitted)) { 2329 pages.unshift({ title: "Submitted page", url: submittedUrl, pageType: coverage.source_page_type ?? coverage.sourcePageType, status: "mapped", productsFound: null, reason: null, isSubmitted: true }); 2330 } 2331 const listedFailedPages = pages.filter((page) => page.status === "failed").length; 2332 const listedScanned = hasPageStatuses ? pages.filter((page) => page.status === "scanned").length : 0; 2333 const pagesScanned = Math.max(listedScanned, reportedAttempted ?? 0, selectedExtractUrls.length); 2334 const listedRemaining = pages.filter((page) => page.status === "queued").length; 2335 const pagesRemaining = reportedRemaining ?? (hasPageStatuses ? listedRemaining : 0); 2336 const candidatePagesFound = Math.max(pagesScanned + pagesRemaining, reportedCandidates ?? 0); 2337 return { 2338 enabled: true, 2339 submittedUrl, 2340 sourcePageType: coverage.source_page_type ?? coverage.sourcePageType, 2341 candidatePagesFound, 2342 pagesScanned, 2343 pagesRemaining, 2344 reportedPagesRemaining: reportedRemaining, 2345 pagesWithProducts: firstFiniteCount( 2346 coverage.pages_with_product_signals, 2347 coverage.pagesWithProductSignals, 2348 hasPageStatuses ? pages.filter((page) => page.status === "scanned" && finiteCount(page.productsFound) > 0).length : null, 2349 ) ?? Math.max(selectedExtractUrls.length, routesExtracted.length), 2350 routesExtracted, 2351 productsFound, 2352 stoppedReason: coverage.stopped_reason ?? coverage.stoppedReason, 2353 note: coverage.note, 2354 host: data?.customerSummary?.supplier_context?.host, 2355 pages, 2356 excludedPages: pages.filter((page) => page.status === "excluded").length, 2357 failedPages: pages.length ? listedFailedPages : reportedFailedOrBlockedPages.length, 2358 failedOrBlockedPages: [ 2359 ...reportedFailedOrBlockedPages, 2360 ...pages.filter((page) => page.status === "failed"), 2361 ], 2362 }; 2363 }; 2364 2365 const deriveMvpResultViewModel = (data, requestMeta = {}) => { 2366 const summary = data?.summary || {}; 2367 const customerSummary = data?.customerSummary || {}; 2368 const report = data?.report || {}; 2369 const discovery = discoveryModelFromResponse(data); 2370 const products = Array.isArray(data?.products) ? data.products : null; 2371 const productsCollected = finiteCount( 2372 products ? products.length : (customerSummary.discovery_coverage || data?.discoveryCoverage)?.products_after_dedupe ?? summary.productsFound ?? customerSummary.products_found ?? report.totalRows, 2373 ); 2374 const importableProducts = finiteCount(summary.importReadyProducts ?? customerSummary.import_ready_products ?? report.readyRows); 2375 const issueCounts = resultIssueCounts(data); 2376 const productsNeedingReview = rowsNeedingReviewCount({ 2377 productsFound: productsCollected, 2378 summary, 2379 customerSummary, 2380 preview: products || data?.preview, 2381 sourceType: requestMeta?.sourceType, 2382 }); 2383 const pages = Array.isArray(discovery?.pages) ? discovery.pages : []; 2384 const pagesScanned = finiteCount(discovery?.pagesScanned); 2385 const pagesRemaining = finiteCount(discovery?.pagesRemaining); 2386 const pagesProducingProducts = finiteCount(discovery?.pagesWithProducts); 2387 const reviewPages = pages.filter(isProblemDiscoveryPage); 2388 const pagesFailedOrBlocked = pages.filter((page) => page.status === "failed").length; 2389 const enrichment = data?.enrichment || summary.enrichment || customerSummary.enrichment || null; 2390 const eligibility = data?.enrichmentEligibility || {}; 2391 const productsRemainingForEnrichment = finiteCount(eligibility.productsEligible); 2392 const productsAttemptedForEnrichment = finiteCount(enrichment?.productsAttempted); 2393 const productsEnriched = finiteCount(enrichment?.productsEnriched); 2394 const productsChecked = finiteCount( 2395 enrichment?.productsChecked 2396 ?? enrichment?.completedUrls?.length 2397 ?? (finiteCount(enrichment?.productsEnriched) + finiteCount(enrichment?.productsPartial)), 2398 ); 2399 const productsImproved = finiteCount(enrichment?.productsImproved ?? enrichment?.productsEnriched); 2400 const productsEligibleForEnrichment = Math.min( 2401 productsCollected, 2402 Math.max(finiteCount(enrichment?.productsEligible), finiteCount(enrichment?.productsAttempted), productsChecked + productsRemainingForEnrichment), 2403 ); 2404 const productsIneligibleForEnrichment = Math.max(0, productsCollected - productsEligibleForEnrichment); 2405 const productsPartiallyEnriched = finiteCount(enrichment?.productsPartial); 2406 const productsFailedEnrichment = finiteCount(enrichment?.productsFailed); 2407 const enrichmentStatus = enrichmentInProgress ? "running" 2408 : enrichment ? String(enrichment.status || "completed").toLowerCase() 2409 : productsRemainingForEnrichment > 0 ? "available" : "not_started"; 2410 const runStatus = String(data?.status || "completed").toLowerCase() === "failed" ? "failed" : "completed"; 2411 const datasetStatus = runStatus === "failed" || productsCollected === 0 ? "failed" 2412 : pagesRemaining > 0 ? "partial" 2413 : productsNeedingReview > 0 || reviewPages.length > 0 || productsFailedEnrichment > 0 ? "needs_review" : "complete"; 2414 const recommendedAction = runStatus === "failed" ? "retry" 2415 : pagesRemaining > 0 ? "continue_scan" 2416 : productsRemainingForEnrichment > 0 ? "enrich" 2417 : "generate_files"; 2418 return { 2419 runStatus, 2420 datasetStatus, 2421 productsCollected, 2422 importableProducts, 2423 productsNeedingReview, 2424 productsWithoutImages: finiteCount(issueCounts.missing_image), 2425 productsWithoutSkus: finiteCount(issueCounts.missing_sku), 2426 pagesDiscovered: firstFiniteCount(discovery?.candidatePagesFound, pages.length) ?? 0, 2427 pagesScanned, 2428 pagesRemaining, 2429 pagesProducingProducts, 2430 pagesNeedingReview: reviewPages.length, 2431 pagesFailedOrBlocked, 2432 routesExtracted: discovery?.routesExtracted || [], 2433 enrichmentStatus, 2434 productsEligibleForEnrichment, 2435 productsRemainingForEnrichment, 2436 productsIneligibleForEnrichment, 2437 productsAttemptedForEnrichment, 2438 productsEnriched, 2439 productsChecked, 2440 productsImproved, 2441 productsPartiallyEnriched, 2442 productsFailedEnrichment, 2443 stoppedReason: discovery?.stoppedReason || null, 2444 recommendedAction, 2445 discovery, 2446 }; 2447 }; 2448 2449 const commerceReadinessFrom = (data) => { 2450 const potential = catalogPotentialFromResponse(data); 2451 return potential?.commerceReadiness || potential?.commerce_readiness || null; 2452 }; 2453 2454 const setMvpIntent = (intent, { renderResult = true } = {}) => { 2455 selectedMvpIntent = intent === "visibility" ? "visibility" : "extraction";
2456 intentInputs.forEach((input) => { 2457 input.checked = input.value === selectedMvpIntent; 2458 }); 2459 if (runButton) { 2460 runButton.textContent = selectedMvpIntent === "visibility" 2461 ? t("mvp.intent.visibilityCta", null, "Check product visibility") 2462 : t("mvp.intent.extractionCta", null, "Extract product catalog"); 2463 } 2464 if (renderResult && lastMvpResultViewModel && lastMvpResult) { 2465 renderMvpResultViewModel(lastMvpResultViewModel, lastMvpResult); 2466 } 2467 }; 2468 2469 const readinessDisplayLabel = (value) => { 2470 const normalized = String(value || "").toLowerCase(); 2471 return normalized ? normalized.charAt(0).toUpperCase() + normalized.slice(1) : t("mvp.findings.notAssessed", null, "Not assessed"); 2472 }; 2473 2474 const renderMachineVisibility = (viewModel, data, isUrlResult) => { 2475 const section = E("mvpMachineVisibility"); 2476 const visibility = data?.customerSummary?.machine_visibility || data?.machineVisibility || null; 2477 const fields = visibility?.fields; 2478 if (!section) return; 2479 const zeroExtractionResult = selectedMvpIntent === "extraction" && viewModel.productsCollected === 0; 2480 section.hidden = !isUrlResult || !fields || zeroExtractionResult; 2481 if (section.hidden) return; 2482 const machineVisibilityTitle = E("mvpMachineVisibilityTitle"); 2483 const machineVisibilitySupport = E("mvpMachineVisibilitySupport"); 2484 if (machineVisibilityTitle) machineVisibilityTitle.hidden = selectedMvpIntent === "extraction"; 2485 if (machineVisibilitySupport) machineVisibilitySupport.hidden = selectedMvpIntent === "extraction"; 2486 const countAndPercent = (field) => { 2487 const visible = finiteCount(field?.visible); 2488 const total = finiteCount(field?.total); 2489 const percent = Math.min(100, finiteCount(field?.percent)); 2490 return t( 2491 "mvp.visibility.countPercent", 2492 { visible, total, percent }, 2493 `${visible} of ${total} · ${percent}%`, 2494 ); 2495 }; 2496 setText("mvpMachineVisibilityName", countAndPercent(fields.name)); 2497 setText("mvpMachineVisibilityUrl", countAndPercent(fields.product_url || fields.productUrl)); 2498 setText("mvpMachineVisibilityPrice", countAndPercent(fields.price)); 2499 setText("mvpMachineVisibilityImage", countAndPercent(fields.image)); 2500 setText("mvpMachineVisibilitySku", countAndPercent(fields.sku)); 2501 setText("mvpMachineVisibilityDescription", countAndPercent(fields.description)); 2502 const setCollectedProductCoverage = (id, field, label, key) => { 2503 const visible = finiteCount(field?.visible); 2504 const total = finiteCount(field?.total); 2505 setText(id, t(key, { visible, total }, `${label} present for ${visible} of ${total} collected products.`)); 2506 }; 2507 setCollectedProductCoverage("mvpMachineVisibilityNameExplanation", fields.name, "Product name", "mvp.visibility.nameCollected"); 2508 setCollectedProductCoverage("mvpMachineVisibilityUrlExplanation", fields.product_url || fields.productUrl, "Product URL", "mvp.visibility.urlCollected"); 2509 setCollectedProductCoverage("mvpMachineVisibilityImageExplanation", fields.image, "Image", "mvp.visibility.imageCollected"); 2510 setCollectedProductCoverage("mvpMachineVisibilityPriceExplanation", fields.price, "Price", "mvp.visibility.priceCollected"); 2511 setCollectedProductCoverage("mvpMachineVisibilitySkuExplanation", fields.sku, "SKU", "mvp.visibility.skuCollected"); 2512 setCollectedProductCoverage("mvpMachineVisibilityDescriptionExplanation", fields.description, "Description", "mvp.visibility.descriptionCollected"); 2513 const products = finiteCount(visibility.products_analyzed ?? visibility.productsAnalyzed); 2514 const zeroVisibilityResult = selectedMvpIntent === "visibility" && viewModel.productsCollected === 0; 2515 section.classList.toggle("mvp-machine-visibility--zero", zeroVisibilityResult); 2516 const visibilityFields = E("mvpMachineVisibilityFields"); 2517 if (visibilityFields) visibilityFields.hidden = zeroVisibilityResult; 2518 const zeroVisibilityOffer = E("mvpZeroVisibilityOffer"); 2519 if (zeroVisibilityOffer) zeroVisibilityOffer.hidden = !zeroVisibilityResult; 2520 const pagesScanned = viewModel?.discovery 2521 ? firstFiniteCount(viewModel.pagesScanned, visibility.pages_scanned, visibility.pagesScanned) 2522 : firstFiniteCount(visibility.pages_scanned, visibility.pagesScanned); 2523 const pagesDiscovered = viewModel?.discovery 2524 ? firstFiniteCount( 2525 viewModel.pagesDiscovered, 2526 visibility.candidate_catalog_pages_discovered, 2527 visibility.candidateCatalogPagesDiscovered, 2528 ) 2529 : firstFiniteCount(visibility.candidate_catalog_pages_discovered, visibility.candidateCatalogPagesDiscovered); 2530 const coverage = String( 2531 visibility.scan_coverage 2532 || visibility.scanCoverage 2533 || (viewModel?.discovery ? (viewModel.pagesRemaining > 0 ? "partial" : "complete") : ""), 2534 ).toLowerCase(); 2535 const productScope = pagesScanned !== null 2536 ? t( 2537 "mvp.visibility.productsAcrossPages", 2538 { products, pages: pagesScanned }, 2539 `${products} products analyzed across ${pagesScanned}
2539 sampled catalog pages`, 2540 ) 2541 : t( 2542 "mvp.visibility.productsAnalyzed", 2543 { count: products }, 2544 `${products} products analyzed in the catalog sample`, 2545 ); 2546 const coverageScope = coverage === "partial" 2547 ? t("mvp.visibility.partialCoverage", null, "partial") 2548 : coverage === "complete" ? t("mvp.visibility.completeCoverage", null, "complete") : ""; 2549 setText("mvpMachineVisibilityScope", productScope); 2550 setText( 2551 "mvpMachineVisibilityCoverage", 2552 pagesDiscovered !== null && coverageScope 2553 ? t( 2554 "mvp.visibility.coverageContext", 2555 { discovered: pagesDiscovered, coverage: coverageScope }, 2556 `${pagesDiscovered} catalog pages discovered · ${coverageScope} catalog coverage`, 2557 ) 2558 : pagesDiscovered !== null 2559 ? t("mvp.visibility.routesDiscovered", { discovered: pagesDiscovered }, `${pagesDiscovered} catalog pages discovered`) 2560 : coverageScope, 2561 ); 2562 setText( 2563 "mvpMachineVisibilityNote", 2564 t( 2565 "mvp.visibility.note", 2566 null, 2567 "Not detected does not necessarily mean missing. Some information may exist deeper on product pages.", 2568 ), 2569 ); 2570 const hasEnrichmentEvidence = viewModel.productsAttemptedForEnrichment > 0 || viewModel.productsChecked > 0; 2571 setText( 2572 "mvpMachineVisibilityTitle", 2573 zeroVisibilityResult 2574 ? t("mvp.visibility.zeroTitle", null, "Bots may have trouble finding your products") 2575 : selectedMvpIntent === "extraction" 2576 ? t("mvp.visibility.collectedTitle", null, "Collected product data") 2577 : hasEnrichmentEvidence 2578 ? t("mvp.visibility.originalTitle", null, "What product information was easy to find before enrichment?") 2579 : t("mvp.visibility.title", null, "What product information is easy to find?"), 2580 ); 2581 setText( 2582 "mvpMachineVisibilitySupport", 2583 zeroVisibilityResult 2584 ? t("mvp.visibility.zeroBody", null, "ScrapingSmart found your catalog structure, but could not reliably detect product records on the public pages checked. Search engines, AI tools, shopping platforms, and other automated systems may have similar difficulty understanding or reaching your product catalog.") 2585 : selectedMvpIntent === "extraction" 2586 ? t("mvp.visibility.collectedSupport", null, "A compact summary of the product information collected so far.") 2587 : t("mvp.visibility.support", null, "See which product details were found on the catalog pages checked."), 2588 ); 2589 if (zeroVisibilityResult) { 2590 setText("mvpMachineVisibilityNote", t("mvp.visibility.zeroQualification", null, "This does not mean your products are missing. They may be loaded dynamically, buried deeper in the site, or exposed in ways that are difficult for automated systems to interpret.")); 2591 setText("mvpZeroVisibilityOfferTitle", t("mvp.visibility.zeroOfferTitle", null, "Create AI-ready product JSON")); 2592 setText("mvpZeroVisibilityOfferBody", t("mvp.visibility.zeroOfferBody", null, "Upload your existing product CSV or saved catalog HTML. ScrapingSmart will normalize the product data and generate structured JSON that AI tools, agents, automations, and integrations can use directly.")); 2593 setText("mvpCreateAiJson", t("mvp.visibility.zeroOfferCta", null, "Create AI-ready JSON")); 2594 setText("mvpZeroVisibilityCsvTitle", t("mvp.visibility.zeroCsvTitle", null, "Have a product CSV?")); 2595 setText("mvpZeroVisibilityCsvBody", t("mvp.visibility.zeroCsvBody", null, "Upload it and ScrapingSmart will normalize it into structured JSON.")); 2596 setText("mvpZeroVisibilityHtmlTitle", t("mvp.visibility.zeroHtmlTitle", null, "Products visible in your browser but not detected?")); 2597 setText("mvpZeroVisibilityHtmlBody", t("mvp.visibility.zeroHtmlBody", null, "Upload saved catalog HTML and ScrapingSmart will extract the visible product data and convert it to structured JSON.")); 2598 setText("mvpZeroVisibilityUsesTitle", t("mvp.visibility.zeroUsesTitle", null, "AI-ready JSON can be used for:")); 2599 [["Agents", "AI agents"], ["Automation", "automation workflows"], ["Search", "internal product search"], ["Rag", "RAG / knowledge bases"], ["Integrations", "catalog integrations"], ["Apis", "downstream APIs and data pipelines"]].forEac
2599h(([id, fallback]) => setText(`mvpZeroVisibilityUse${id}`, t(`mvp.visibility.zeroUse${id}`, null, fallback))); 2600 } 2601 const recovered = E("mvpRecoveredProductData"); 2602 if (hasEnrichmentEvidence) { 2603 const issueCounts = resultIssueCounts(data); 2604 const ownCount = (...keys) => { 2605 const key = keys.find((candidate) => Object.prototype.hasOwnProperty.call(issueCounts, candidate)); 2606 return key ? finiteCount(issueCounts[key]) : null; 2607 }; 2608 const available = (missing) => missing === null ? null : Math.max(0, viewModel.productsCollected - missing); 2609 const recoveredCount = (count) => t( 2610 "mvp.visibility.recoveredCount", 2611 { count, total: viewModel.productsCollected }, 2612 `${count} of ${viewModel.productsCollected}`, 2613 ); 2614 const metrics = [ 2615 ["mvpRecoveredSkuItem", "mvpRecoveredSku", available(ownCount("missing_sku"))], 2616 ["mvpRecoveredDescriptionItem", "mvpRecoveredDescription", available(ownCount("missing_description", "weak_description"))], 2617 ["mvpRecoveredPriceItem", "mvpRecoveredPrice", available(ownCount("missing_price"))], 2618 ]; 2619 metrics.forEach(([itemId, valueId, count]) => { 2620 const item = E(itemId); 2621 if (item) item.hidden = count === null; 2622 if (count !== null) setText(valueId, recoveredCount(count)); 2623 }); 2624 if (recovered) recovered.hidden = !metrics.some(([, , count]) => count !== null); 2625 } else if (recovered) { 2626 recovered.hidden = true; 2627 } 2628 }; 2629 2630 const catalogSourcesFrom = (data) => { 2631 const files = (data?.customerSummary?.discovery_coverage || data?.discoveryCoverage)?.catalog_files; 2632 return Array.isArray(files) ? files.filter((file) => Boolean(toSafeHttpUrl(file?.url))) : []; 2633 }; 2634 2635 // Apply after access gates too: those can restore download and cleanup controls. 2636 const renderCompletedResultLayout = (viewModel, data) => { 2637 if (["queued", "running", "discovering", "processing", "failed", "cancelled", "canceled"].includes(String(data?.discoveryJob?.state || "").toLowerCase()) || data?.status === "failed") return; 2638 const zero = viewModel.productsCollected === 0; 2639 const sources = catalogSourcesFrom(data); 2640 const hide = (id, value = true) => { const node = E(id); if (node) node.hidden = value; }; 2641 for (const id of ["mvpFindingsCoverage", "mvpFindingsCommerce", "mvpFindingsDataset", "mvpCatalogMapSummary", "mvpCatalogPageList", "mvpScanProgressSummary", "mvpScanProgressSubcopy", "mvpDiscoveryCompleteMessage", "mvpMainIssuesDownload", "mvpRecommendedActionMessage", "mvpResultBanner", "mvpDownloadsTitle", "mvpPreviewAccountGate", "mvpStateMessage", "mvpStateHint"]) hide(id); 2642 hide("mvpFreeFiles", false); 2643 hide("mvpNoProductExport", !zero); 2644 setText("mvpNoProductExport", t("mvp.results.noExport", null, "No product export available. Product exports become available when usable products are extracted.")); 2645 successPanel?.classList.toggle("mvp-results--zero", zero); 2646 E("mvpDiscoveryCoverage")?.querySelector(".mvp-discovery-heading")?.setAttribute("hidden", ""); 2647 E("mvpDiscoveryCoverage")?.querySelector(".mvp-discovery-details")?.setAttribute("hidden", ""); 2648 E("mvpCatalogPotential")?.querySelector(".mvp-catalog-potential-summary")?.querySelectorAll("div").forEach((node, index) => { node.hidden = index < 3; }); 2649 setText("mvpCrawlProductsReturned", viewModel.productsCollected); 2650 setText("mvpCrawlSignalPages", viewModel.pagesProducingProducts); 2651 const duplicateIssues = E("mvpMainIssuesDownload")?.parentElement; 2652 if (duplicateIssues) duplicateIssues.hidden = true; 2653 hide("mvpFindingsTitle", true); 2654 setText("mvpCrawlProductCandidates", finiteCount(viewModel.discovery?.productsFound)); 2655 setText("mvpCrawlPageType", discoveryLabel(viewModel.discovery?.sourcePageType)); 2656 for (const id of ["mvpCsvDownloadItem", "mvpShopifyDownloadItem", "mvpBigcommerceDownloadItem", "mvpSummaryDownloadItem", "mvpLlmDownloadItem", "mvpEnrichmentDownloadItem"]) hide(id, zero); 2657 if (zero) { 2658 for (const id of ["mvpExportStep", "mvpEnrichmentAction", "mvpProductReviewSection", "mvpReviewPreviewSlot", "mvpMachineVisibility", "mvpZeroVisibilityOffer", "mvpPrimaryRecommendation", "mvpMarketingContentStep", "mvpCleanupPanel"]) hide(id); 2659 if (!["queued", "running", "discovering", "processing"].includes(String(data?.discoveryJob?.state || "").toLowerCase())) { 2660 setText("mvpResultsTitle", t("mvp.results.zeroRetained", null, "No usable products extracted")); 2661 const candidates = finiteCount(viewModel.discovery?.productsFound); 2662 setText("mvpCatalogProgressSummary", t(viewModel.pagesScanned === 1 ? "mvp.results.retainedSummaryOne" : "mvp.results.retainedSummary", { pages: viewModel.pagesScanned, products: 0, sources: sources.length, sourceSummary: t(sources.length === 1 ? "mvp.results.sourceOne" : "mvp.results.sourceMany", { count: sources.length }, `${plural(sources.length, "catalog source")} found`) }, `${plural(viewModel.pagesScanned, "page")} scanned · 0 products retained · ${plural(sources.length, "catalog source")} found`) + (candidates > 0 ? ` · ${plural(candidates, "product candidate")} detected` : "")); 2663 } 2664 setText("mvpCommerceProductStructuredData", t("mvp.results.notAssessed", null, "Not assessed: no usable products retained")); 2665 } 2666 setText("mvpCompletedSignupTitle", t(zero ? "mvp.signup.saveContinue" : "mvp.signup.claim", null, zero ? "Save this scan and continue" : "Claim this scan")); 2667 setText("mvpCompletedSignupBody", t(zero ? "mvp.signup.catalogBody" : "mvp.signup.productBody", null, zero ? "Create an account to keep these catalog sources, reopen this
2667scan, and continue building the dataset." : "Create an account or sign in to save this scan and reopen it later.")); 2668 setText("mvpCompletedSignupCreate", t("mvp.signup.createFree", null, "Create free account")); 2669 completedSignupCta?.querySelector("ul")?.setAttribute("hidden", ""); 2670 const upsell = completedSignupCta?.querySelector(".mvp-completed-signup-upsell"); 2671 if (upsell) upsell.hidden = zero; 2672 setText("mvpCatalogSourcesGuidance", t("mvp.results.catalogNext", null, "Try one of these catalog sources or a product/category page to continue building the dataset.")); 2673 const guidance = E("mvpCatalogSourcesGuidance"); 2674 if (guidance) E("mvpCatalogSources")?.after(guidance); 2675 }; 2676 2677 const renderZeroExtractionDiagnostic = (viewModel, isUrlResult, data = {}) => { 2678 const diagnostic = E("mvpZeroExtractionDiagnostic"); 2679 if (!diagnostic) return; 2680 const files = (data?.customerSummary?.discovery_coverage || data?.discoveryCoverage)?.catalog_files; 2681 const catalogFiles = Array.isArray(files) ? files.filter((file) => /^https?:\/\//i.test(String(file?.url || ""))) : []; 2682 const showCatalogs = catalogFiles.length > 0 2683 && (data.status === "needs_capture" || data.reasonCode === "too_few_products" || viewModel.productsCollected === 0); 2684 const showDiagnostic = isUrlResult 2685 && (selectedMvpIntent === "extraction" || showCatalogs) 2686 && (viewModel.productsCollected === 0 || showCatalogs); 2687 diagnostic.hidden = !showDiagnostic; 2688 if (!showDiagnostic) return; 2689 const catalogList = E("mvpCatalogSources"); 2690 E("mvpCatalogSourcesGuidance").hidden = !showCatalogs; 2691 catalogList.replaceChildren(); 2692 catalogList.hidden = !showCatalogs; 2693 diagnostic.querySelector(".mvp-zero-extraction-actions").hidden = showCatalogs; 2694 diagnostic.querySelector(".mvp-dataset-actions").hidden = showCatalogs; 2695 if (showCatalogs) { 2696 setText("mvpZeroExtractionTitle", t("mvp.results.sourcesTitle", null, "Catalog sources detected")); 2697 setText("mvpZeroExtractionBody", t("mvp.results.sourcesBody", null, "ScrapingSmart found product catalog sources linked from this supplier.")); 2698 catalogFiles.forEach((file) => { 2699 if (!/^https?:\/\//i.test(file.url)) return; 2700 const item = document.createElement("li"); 2701 const link = document.createElement("a"); 2702 const title = String(file.title || "").trim(); 2703 const pathTitle = (new URL(file.url).pathname.split("/").filter(Boolean).pop() || "").replace(/\.[^.]+$/, "").replace(/[-_]+/g, " "); 2704 link.textContent = !title || /^catalog(ue)?s?$/i.test(title) ? pathTitle || new URL(file.url).hostname : title; 2705 link.href = file.url; 2706 link.target = "_blank"; 2707 link.rel = "noopener noreferrer"; 2708 item.append(link); 2709 catalogList.append(item); 2710 }); 2711 return; 2712 } 2713 setText("mvpZeroExtractionTitle", t("mvp.extraction.zeroTitle", null, "No products were found")); 2714 setText( 2715 "mvpZeroExtractionBody", 2716 t( 2717 "mvp.extraction.zeroBody", 2718 null, 2719 "Bots may also have trouble discovering products from this page. We couldnât find enough accessible product signals or catalog links to extract a product catalog during this scan.", 2720 ), 2721 ); 2722 setText("mvpZeroExtractionTryPage", t("mvp.extraction.zeroTryPage", null, "Try a category or collection page instead of the homepage.")); 2723 setText("mvpZeroExtractionHtml", t("mvp.extraction.zeroHtml", null, "Upload saved HTML when the site relies heavily on JavaScript or blocks automated access.")); 2724 setText("mvpZeroExtractionCsv", t("mvp.extraction.zeroCsv", null, "Upload CSV or product data if the website cannot be extracted reliably.")); 2725 setText("mvpZeroExtractionTryAnother", t("mvp.extraction.zeroTryAnother", null, "Try another page")); 2726 setText("mvpZeroExtractionUseHtml", t("mvp.extraction.zeroUseHtml", null, "Upload saved HTML")); 2727 setText("mvpZeroExtractionUseCsv", t("mvp.extraction.zeroUseCsv", null, "Upload product CSV")); 2728 const discoveredAction = E("mvpZeroExtractionScanDiscovered"); 2729 if (discoveredAction) { 2730 discoveredAction.textContent = t( 2731 viewModel.pagesRemaining === 1 2732 ? "mvp.extraction.zeroScanDiscoveredOne" 2733 : "mvp.extraction.zeroScanDiscovered",
2734 { count: viewModel.pagesRemaining }, 2735 `${plural(viewModel.pagesRemaining, "discovered catalog page")} ${viewModel.pagesRemaining === 1 ? "is" : "are"} available to scan.`, 2736 ); 2737 discoveredAction.hidden = viewModel.pagesRemaining <= 0; 2738 } 2739 }; 2740 2741 const renderFindings = (viewModel, data) => { 2742 const coverage = E("mvpFindingsCoverage"); 2743 if (coverage) coverage.hidden = !viewModel.discovery; 2744 if (viewModel.discovery) { 2745 const complete = viewModel.pagesRemaining <= 0 && viewModel.pagesFailedOrBlocked <= 0; 2746 setText( 2747 "mvpFindingsCoverageStatus", 2748 complete 2749 ? t("mvp.findings.coverageComplete", null, "Complete scan: all discovered catalog pages were scanned.") 2750 : t("mvp.findings.coveragePartial", { count: viewModel.pagesRemaining }, `Partial scan: ${plural(viewModel.pagesRemaining, "catalog page")} remain.`), 2751 ); 2752 setText("mvpFindingsPagesScanned", viewModel.pagesScanned); 2753 setText("mvpFindingsPagesRemaining", viewModel.pagesRemaining); 2754 setText("mvpFindingsPagesProducing", viewModel.pagesProducingProducts); 2755 setText("mvpFindingsPagesFailed", viewModel.pagesFailedOrBlocked); 2756 const failedItem = E("mvpFindingsFailedItem"); 2757 if (failedItem) failedItem.hidden = viewModel.pagesFailedOrBlocked <= 0; 2758 } 2759 2760 const readiness = commerceReadinessFrom(data); 2761 const commerce = E("mvpFindingsCommerce"); 2762 if (commerce) commerce.hidden = !readiness; 2763 if (readiness) { 2764 setText("mvpFindingsCatalogDiscoverability", readinessDisplayLabel(readiness.catalogDiscoverability || readiness.catalog_discoverability)); 2765 setText("mvpFindingsProductStructuredData", readinessDisplayLabel(readiness.productStructuredData || readiness.product_structured_data)); 2766 setText("mvpFindingsSeoFoundation", readinessDisplayLabel(readiness.seoFoundation || readiness.seo_foundation)); 2767 setText("mvpFindingsOverallReadiness", readinessDisplayLabel(readiness.status)); 2768 const observation = E("mvpFindingsCommerceObservation"); 2769 if (observation) { 2770 observation.textContent = readiness.primaryObservation || readiness.primary_observation || readiness.note || ""; 2771 observation.hidden = !observation.textContent; 2772 } 2773 } 2774 2775 setText( 2776 "mvpFindingsDatasetSummary", 2777 t( 2778 "mvp.findings.datasetSummary", 2779 { products: viewModel.productsCollected, importable: viewModel.importableProducts }, 2780 `${viewModel.productsCollected} products collected · ${viewModel.importableProducts} currently importable`, 2781 ), 2782 ); 2783 const hasEnrichmentEligibility = Number.isFinite(Number(data?.enrichmentEligibility?.productsEligible)); 2784 const hasEnrichmentEvidence = viewModel.productsAttemptedForEnrichment > 0 || viewModel.productsChecked > 0; 2785 setText( 2786 "mvpFindingsEnrichmentState", 2787 hasEnrichmentEligibility || hasEnrichmentEvidence 2788 ? t( 2789 "mvp.findings.enrichmentState", 2790 { checked: viewModel.productsChecked, improved: viewModel.productsImproved }, 2791 `${viewModel.productsChecked} product pages checked · ${viewModel.productsImproved} products improved`, 2792 ) 2793 : t("mvp.findings.notEnriched", null, "Product details have not been enriched yet."), 2794 ); 2795 const completeness = E("mvpFindingsProductCompleteness"); 2796 if (completeness) completeness.hidden = !hasEnrichmentEvidence; 2797 }; 2798 2799 const renderMvpResultViewModel = (viewModel, data) => { 2800 if (!viewModel) return; 2801 lastMvpResultViewModel = viewModel; 2802 const statusLabels = { 2803 complete: t("mvp.results.readyStatus", null, "Ready"), 2804 partial: t("mvp.results.available", null, "More products available"), 2805 needs_review: t("mvp.dynamic.readyWarnings", null, "Ready with warnings"), 2806 failed: t("mvp.results.noProducts", null, "No products found"), 2807 }; 2808 const isUrlResult = data?.customerSummary?.supplier_context?.source_type === "url" || Boolean(viewModel.discovery); 2809 const hasMachineVisibility = isUrlResult && Boolean(data?.customerSummary?.machine_visibility?.fields); 2810 const machineVisibilityPrimary = hasMachineVisibility && selectedMvpIntent === "visibility"; 2811 successPanel?.classList.toggle("mvp-results--url", isUrlResult); 2812 successPanel?.classList.toggle("mvp-results--machine-visibility", machineVisibilityPrimary); 2813 successPanel?.classList.toggle("mvp-results--intent-visibility", selectedMvpIntent === "visibility"); 2814 successPanel?.classList.toggle("mvp-results--intent-extraction", selectedMvpIntent === "extraction"); 2815 E("mvpMachineVisibility")?.classList.remove("mvp-machine-visibility--expanded"); 2816 setText("mvpDataQualitySummary", t("mvp.status.dataQuality", null, "Data quality")); 2817 const anonymousResult = data?.auth?.authenticated !== true && !isSignedIn(); 2818 const anonymousUrlContinuation = anonymousResult 2819 && isUrlResult 2820 && viewModel.productsCollected > 0 2821 && viewModel.pagesRemaining > 0; 2822 const paidContinuationAvailable = entitlementState().canContinueScan; 2823 const continuationActionAvailable = anonymousResult || paidContinuationAvailable; 2824 const backendScanState = String(data?.discoveryJob?.state || "").toLowerCase(); 2825 const scanIsRunning = ["queued", "running", "discovering", "processing"].includes(backendScanState); 2826 const scanIsWaiting = Boolean(viewModel.discovery) 2827 && viewModel.pagesRemaining > 0 2828 && !scanIsRunning 2829 && !["failed", "cancelled", "canceled"].includes(backendScanState); 2830 const scanIsComplete = Boolean(viewModel.discovery) 2831 && viewModel.pagesRemaining <= 0 2832 && !scanIsRunning 2833 && !["failed", "cancelled", "canceled"].includes(backendScanState); 2834 completedSignupCtaEligible = !scanIsRunning 2835 && !["failed", "cancelled", "canceled"].includes(backendScanState) 2836 && data?.ok !== false && data?.status !== "failed" 2837 && (viewModel.productsCollected > 0 || catalogSourcesFrom(data).length > 0); 2838 if (completedSignupCta) completedSignupCta.hidden = !completedSignupCtaEligible || !anonymousResult; 2839 if (stateTitle) { 2840 stateTitle.textContent = scanIsRunning 2841 ? `Scanning catalog â ${viewModel.pagesScanned} of ${viewModel.pagesDiscovered} pages complete` 2842 : scanIsWaiting 2843 ? `${viewModel.pagesRemaining} additional ${viewModel.pagesRemaining === 1 ? "page was" : "pages were"} discovered` 2844 : scanIsComplete 2845 ? "Catalog discovery complete"
2846 : viewModel.productsCollected > 0 2847 ? t("mvp.results.ready", null, "Your dataset is ready") 2848 : t("mvp.results.noProducts", null, "No products found"); 2849 stateTitle.hidden = false; 2850 } 2851 const resultIntro = E("mvpResultIntro"); 2852 if (resultIntro) resultIntro.hidden = true; 2853 setText("mvpResultStatus", statusLabels[viewModel.datasetStatus] || "Needs review"); 2854 if (resultStatus) resultStatus.dataset.state = viewModel.datasetStatus; 2855 setText("mvpOverviewProducts", viewModel.productsCollected); 2856 setText( 2857 "mvpOverviewProductsLabel", 2858 selectedMvpIntent === "extraction" && isUrlResult 2859 ? t("mvp.results.productsFoundSoFar", null, "products found so far") 2860 : t("mvp.results.productsFoundLower", null, "products found"), 2861 ); 2862 setText("mvpOverviewPagesScanned", viewModel.pagesScanned); 2863 setText("mvpOverviewPagesDiscovered", viewModel.pagesDiscovered); 2864 setText("mvpOverviewPagesRemaining", viewModel.pagesRemaining); 2865 setText( 2866 "mvpCatalogProgressSummary", 2867 viewModel.discovery 2868 ? t( 2869 "mvp.results.compactProgress", 2870 { 2871 products: viewModel.productsCollected, 2872 scanned: viewModel.pagesScanned, 2873 total: viewModel.pagesDiscovered, 2874 remaining: viewModel.pagesRemaining, 2875 }, 2876 scanIsComplete 2877 ? `${viewModel.productsCollected} products collected · ${viewModel.pagesDiscovered} routes evaluated · ${viewModel.routesExtracted.length} catalog pages extracted` 2878 : scanIsWaiting 2879 ? `${viewModel.productsCollected} products found so far · ${viewModel.pagesScanned} pages complete` 2880 : `${viewModel.productsCollected} products found so far · ${viewModel.pagesRemaining} pages remaining`, 2881 ) 2882 : t("mvp.results.compactProducts", { products: viewModel.productsCollected }, `${viewModel.productsCollected} products`), 2883 ); 2884 const overviewCoverage = E("mvpOverviewCoverage"); 2885 if (overviewCoverage) overviewCoverage.hidden = true; 2886 setText("mvpOverviewProductsEnriched", viewModel.productsChecked); 2887 setText("mvpOverviewProductsImproved", viewModel.productsImproved); 2888 setText("mvpOverviewProductsRemaining", viewModel.productsRemainingForEnrichment); 2889 setText("mvpOverviewProductsIneligible", viewModel.productsIneligibleForEnrichment); 2890 if (overviewEnrichment) overviewEnrichment.hidden = true; 2891 const overviewIneligible = E("mvpOverviewEnrichmentIneligible"); 2892 if (overviewIneligible) overviewIneligible.hidden = true; 2893 setText("mvpContinueProductsCollected", viewModel.productsCollected); 2894 setText("mvpContinuePagesScanned", viewModel.pagesScanned); 2895 setText("mvpContinuePagesRemaining", viewModel.pagesRemaining); 2896 setText("mvpMetricImportReady", viewModel.importableProducts); 2897 setText("mvpMetricReviewRows", viewModel.productsNeedingReview); 2898 setText("mvpSummaryWarnings", viewModel.productsNeedingReview); 2899 setText("mvpMetricMissingImages", viewModel.productsWithoutImages); 2900 setText("mvpMetricMissingSkus", viewModel.productsWithoutSkus); 2901 renderMachineVisibility(viewModel, data, isUrlResult); 2902 renderZeroExtractionDiagnostic(viewModel, isUrlResult, data); 2903 renderFindings(viewModel, data); 2904 const remainingCatalog = E("mvpOverviewRemainingCatalog"); 2905 if (remainingCatalog) { 2906 const remaining = Math.max(0, viewModel.pagesRemaining); 2907 remainingCatalog.textContent = remaining > 0 2908 ? t("mvp.results.pagesStillToScan", { count: remaining }, `${plural(remaining, "page")} still to scan`) 2909 : ""; 2910 remainingCatalog.hidden = true; 2911 } 2912 const remainingCatalogSupport = E("mvpOverviewRemainingCatalogSupport"); 2913 if (remainingCatalogSupport) remainingCatalogSupport.hidden = true; 2914 if (resultBanner) { 2915 resultBanner.textContent = viewModel.datasetStatus === "failed" ? STATE_COPY.failure.banner : ""; 2916 resultBanner.hidden = viewModel.datasetStatus !== "failed"; 2917 } 2918 if (stateMessage) { 2919 stateMessage.textContent = ""; 2920 stateMessage.hidden = true; 2921 } 2922 if (datasetRecommendation) datasetRecommendation.hidden = true; 2923 if (continueScanLoading) continueScanLoading.hidden = !scanIsRunning; 2924 if (continueScanLoadingStatus) continueScanLoadingStatus.textContent = scanIsRunning ? "Scanning discovered catalog pagesâ¦" : ""; 2925 if (scanReassurance) { 2926 scanReassurance.textContent = scanIsRunning ? "You can safely leave this page." : ""; 2927 scanReassurance.hidden = !scanIsRunning; 2928 } 2929 if (scanIsComplete) { 2930 if (continueScanLoading) continueScanLoading.hidden = true; 2931 if (continueScanLoadingStatus) continueScanLoadingStatus.textContent = ""; 2932 if (scanReassurance) scanReassurance.hidden = true; 2933 if (continueScanStatus) { 2934 continueScanStatus.textContent = ""; 2935 continueScanStatus.hidden = true; 2936 } 2937 } 2938 if (continueScanProgress) { 2939 continueScanProgress.hidden = !(scanIsRunning || scanIsWaiting) || scanIsComplete; 2940 const progress = viewModel.pagesDiscovered > 0 ? Math.min(100, (viewModel.pagesScanned / viewModel.pagesDiscovered) * 100) : 0; 2941 continueScanProgress.setAttribute("aria-valuemin", "0"); 2942 continueScanProgress.setAttribute("aria-valuemax", "100"); 2943 continueScanProgress.setAttribute("aria-valuenow", String(Math.round(progress))); 2944 const bar = continueScanProgress.querySelector("span"); 2945 if (bar) bar.style.width = `${progress}%`; 2946 } 2947 if (stopDiscoveryButton) { 2948 stopDiscoveryButton.hidden = viewModel.pagesRemaining <= 0 || !continuationActionAvailable; 2949 stopDiscoveryButton.textContent = t("mvp.next.downloadData", null, "Download data"); 2950 stopDiscoveryButton.classList.remove("button--primary"); 2951 stopDiscoveryButton.classList.add("mvp-secondary-download"); 2952 } 2953 if (datasetActions) datasetActions.hidden = true; 2954 if (recommendedAction) recommendedAction.hidden = viewModel.productsCollected <= 0; 2955 const nextActionsTitle = E("mvpNextActionsTitle"); 2956 if (nextActionsTitle) { 2957 nextActionsTitle.textContent = t("mvp.next.steps", null, "Next steps"); 2958 nextActionsTitle.hidden = viewModel.productsCollected <= 0; 2959 } 2960 const continuationStep = E("mvpContinuationStep"); 2961 const continuationStepTitle = E("mvpContinuationStepTitle"); 2962 const continuationStepCopy = E("mvpContinuationStepCopy"); 2963 if (continuationStep) continuationStep.hidden = true; 2964 if (continuationStepTitle) continuationStepTitle.hidden = true; 2965 if (continuationStepCopy) continuationStepCopy.hidden = true; 2966 const enrichmentTitle = E("mvpEnrichmentTitle"); 2967 const enrichmentSupport = E("mvpEnrichmentSupport"); 2968 if (enrichmentTitle) enrichmentTitle.textContent = machineVisibilityPrimary 2969 ? t("mvp.next.inspectTitle", null, "2. Inspect product pages") 2970 : t("mvp.next.deeperDetailsTitle", null, "2. Add deeper product details"); 2971 if (enrichmentSupport) enrichmentSupport.textContent = machineVisibilityPrimary 2972 ? t( 2973 "mvp.visibility.recoverSupport", 2974 null, 2975 "Check individual product pages for prices, descriptions, specifications, variants, and structured product data.", 2976 )
2977 : t("mvp.next.deeperDetailsCopy", { count: viewModel.productsCollected }, `Check the ${viewModel.productsCollected} products already found for descriptions, specifications, variants, prices, images, and other available product-page data.`); 2978 if (continuationGate) continuationGate.hidden = true; 2979 2980 const primary = E("mvpPrimaryRecommendation"); 2981 const message = E("mvpRecommendedActionMessage"); 2982 const exportCurrent = E("mvpExportCurrentProducts"); 2983 const exportStep = E("mvpExportStep"); 2984 const technicalStep = E("mvpTechnicalStep"); 2985 setText("mvpExportStepTitle", t("mvp.next.exportTitle", null, "Export your data")); 2986 if (exportStep) exportStep.hidden = machineVisibilityPrimary; 2987 if (technicalStep) technicalStep.hidden = !machineVisibilityPrimary; 2988 const actions = { 2989 enrich: [t("mvp.enrichment.action", null, "Enrich products"), t("mvp.next.enrichHelp", null, "The catalog scan is complete. Enrich eligible products from their product pages.")], 2990 review: [t("mvp.downloads.review", null, "Review issues"), t("mvp.dynamic.problem", null, "Problems were detected that need attention before the dataset is treated as complete.")], 2991 generate_files: [t("mvp.next.downloadCurrent", null, "Download current dataset"), t("mvp.next.complete", null, "The available scan and enrichment work is complete. Your dataset is ready to use.")], 2992 retry: [t("mvp.dynamic.retry", null, "Retry scan"), t("mvp.dynamic.scanFailed", null, "The scan failed before a usable dataset was created.")], 2993 }; 2994 const genericAction = ["generate_files", "retry", "review"].includes(viewModel.recommendedAction) 2995 ? viewModel.recommendedAction 2996 : null; 2997 const [label, copy] = actions[genericAction] || ["", ""]; 2998 if (primary) { 2999 primary.textContent = label; 3000 if (genericAction) primary.dataset.action = genericAction; 3001 else delete primary.dataset.action; 3002 primary.hidden = !["generate_files", "retry"].includes(genericAction); 3003 } 3004 if (message) { 3005 message.textContent = copy; 3006 message.hidden = !genericAction || anonymousUrlContinuation; 3007 } 3008 if (exportCurrent) { 3009 exportCurrent.hidden = machineVisibilityPrimary; 3010 exportCurrent.textContent = "Download products CSV"; 3011 } 3012 if (message && viewModel.productsCollected > 0) { 3013 const readiness = commerceReadinessFrom(data); 3014 const observation = readiness?.primaryObservation || readiness?.primary_observation || readiness?.note || ""; 3015 const scanState = viewModel.discovery 3016 ? viewModel.pagesRemaining > 0 3017 ? t("mvp.next.summaryPartial", { count: viewModel.pagesRemaining }, `${plural(viewModel.pagesRemaining, "catalog page")} remain to scan.`) 3018 : t("mvp.next.summaryComplete", null, "The catalog scan is complete.") 3019 : ""; 3020 const enrichmentState = viewModel.productsChecked > 0 3021 ? t("mvp.next.summaryEnriched", { checked: viewModel.productsChecked, improved: viewModel.productsImproved }, `${viewModel.productsChecked} product pages checked · ${viewModel.productsImproved} products improved.`) 3022 : t("mvp.findings.notEnriched", null, "Product details have not been enriched yet."); 3023 message.textContent = ""; 3024 message.hidden = true; 3025 } 3026 renderCompletedResultLayout(viewModel, data); 3027 }; 3028 3029 const renderIncompleteScanWarning = (discovery, resultProductsFound = null) => { 3030 let warning = E("mvpIncompleteScanWarning"); 3031 const failedOrBlockedPages = Array.isArray(discovery?.failedOrBlockedPages) 3032 ? discovery.failedOrBlockedPages 3033 : []; 3034 const productsFound = Number(resultProductsFound ?? discovery?.productsFound ?? 0); 3035 const pageType = String(discovery?.sourcePageType || "").toLowerCase(); 3036 const extractedRoutes = new Set((discovery?.routesExtracted || []).map(safeDiscoveryPageUrl).filter(Boolean)); 3037 const failureMateriallyAffectedExtraction = failedOrBlockedPages.some((page) => { 3038 const url = safeDiscoveryPageUrl(typeof page === "string" ? page : page?.url); 3039 return !url || extractedRoutes.has(url); 3040 }); 3041 const shouldShow = discovery?.enabled === true && ( 3042 (failedOrBlockedPages.length > 0 && (productsFound <= 0 || failureMateriallyAffectedExtraction)) 3043 || (discovery.pagesRemaining === 0 && pageType === "homepage_or_navigation" && productsFound <= 4) 3044 ); 3045 if (!shouldShow) { 3046 if (warning) warning.hidden = true; 3047 return; 3048 } 3049 if (!warning && discoveryCoveragePanel) { 3050 warning = document.createElement("section"); 3051 warning.id = "mvpIncompleteScanWarning";
3052 warning.className = "mvp-recommended-action mvp-incomplete-scan-warning"; 3053 warning.setAttribute("role", "status"); 3054 const overview = discoveryCoveragePanel.querySelector(".mvp-discovery-overview"); 3055 discoveryCoveragePanel.insertBefore(warning, overview); 3056 } 3057 if (!warning) return; 3058 warning.replaceChildren(); 3059 const title = document.createElement("h3"); 3060 title.textContent = "Results may be incomplete"; 3061 const explanation = document.createElement("p"); 3062 explanation.className = "status"; 3063 explanation.append("ScrapingSmart found "); 3064 const productCount = document.createElement("strong"); 3065 productCount.textContent = `${productsFound} products`; 3066 explanation.append(productCount, ", but some catalog pages could not be accessed or discovered."); 3067 const action = document.createElement("p"); 3068 action.className = "status"; 3069 const actionStrong = document.createElement("strong"); 3070 actionStrong.textContent = "Try a category or collection URL, or upload saved page HTML."; 3071 action.append(actionStrong); 3072 const scope = document.createElement("p"); 3073 scope.className = "status"; 3074 scope.textContent = "Generated files include only the products ScrapingSmart could access."; 3075 warning.append(title, explanation, action, scope); 3076 warning.hidden = false; 3077 }; 3078 3079 const catalogPageDisplayName = (page) => { 3080 const title = String(page?.title || "").trim(); 3081 if (title && title.toLowerCase() !== "page" && title !== "Submitted page") return title; 3082 try { 3083 const url = new URL(page?.url); 3084 const segment = decodeURIComponent(url.pathname).replace(/\/+$/, "").split("/").filter(Boolean).pop(); 3085 if (segment) return segment.replace(/[-_]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase()); 3086 } catch { /* fall through to page type */ } 3087 const pageType = String(page?.pageType || "").trim(); 3088 return pageType ? discoveryLabel(pageType) : "Catalog page"; 3089 }; 3090 3091 const catalogPageDisplayPath = (page) => { 3092 try { return new URL(page?.url).pathname || "/"; } 3093 catch { return String(page?.url || ""); } 3094 }; 3095 3096 const renderCatalogRoute = (page, status, marker) => { 3097 const row = document.createElement("div"); 3098 row.className = `mvp-catalog-route mvp-catalog-route--${status}`; 3099 const icon = document.createElement("span"); 3100 icon.className = "mvp-catalog-route-marker"; 3101 icon.textContent = marker; 3102 icon.setAttribute("aria-label", status === "queued" ? "Waiting" : status === "review" ? "Needs review" : discoveryLabel(status)); 3103 const content = document.createElement("div"); 3104 content.className = "mvp-catalog-route-content"; 3105 const link = document.createElement("a"); 3106 link.className = "mvp-catalog-route-title"; 3107 link.href = page.url; 3108 link.target = "_blank"; 3109 link.rel = "noopener"; 3110 link.textContent = catalogPageDisplayName(page); 3111 const path = document.createElement("span"); 3112 path.className = "mvp-catalog-route-path"; 3113 path.textContent = catalogPageDisplayPath(page); 3114 content.append(link, path); 3115 row.append(icon, content); 3116 if (page.status === "scanned") { 3117 const count = document.createElement("span"); 3118 count.className = "mvp-catalog-route-products"; 3119 count.textContent = t("mvp.details.candidateCount", { count: finiteCount(page.productsFound) }, `${finiteCount(page.productsFound)} product candidates detected`); 3120 row.append(count); 3121 } 3122 return row; 3123 }; 3124 3125 const appendCatalogGroup = (container, label, pages, status, marker, preview = 0, wasOpen = false) => { 3126 if (!pages.length) return; 3127 const section = document.createElement("section"); 3128 section.className = "mvp-catalog-route-group"; 3129 const heading = document.createElement("h4"); 3130 heading.textContent = label; 3131 section.append(heading); 3132 (preview ? pages.slice(0, preview) : pages).forEach((page) => section.append(renderCatalogRoute(page, status, marker))); 3133 if (preview && pages.length > preview) { 3134 const more = document.createElement("details"); 3135 more.className = "mvp-catalog-route-more"; 3136 more.open = wasOpen; 3137 const moreSummary = document.createElement("summary"); 3138 moreSummary.textContent = `Show ${pages.length - preview} more`; 3139 more.append(moreSummary);
3140 pages.slice(preview).forEach((page) => more.append(renderCatalogRoute(page, status, marker))); 3141 section.append(more); 3142 } 3143 container.append(section); 3144 }; 3145 3146 const renderPrimaryCatalogMap = (discovery) => { 3147 const summary = E("mvpCatalogMapSummary"); 3148 const list = E("mvpCatalogPageList"); 3149 if (!summary || !list) return; 3150 const openGroups = new Set(Array.from(list.querySelectorAll(".mvp-catalog-route-more[open]")) 3151 .map((details) => details.closest(".mvp-catalog-route-group")?.querySelector("h4")?.textContent)); 3152 summary.replaceChildren(); 3153 list.replaceChildren(); 3154 if (!discovery) return; 3155 const pages = Array.isArray(discovery.pages) ? discovery.pages : []; 3156 [ 3157 `${plural(discovery.candidatePagesFound, "page")} discovered`, 3158 `${finiteCount(discovery.pagesScanned)} scanned · ${finiteCount(discovery.pagesRemaining)} waiting`, 3159 `${plural(finiteCount(discovery.pagesWithProducts), "page")} produced products`, 3160 ].forEach((text) => { 3161 const line = document.createElement("p"); 3162 line.textContent = text; 3163 summary.append(line); 3164 }); 3165 const detailedPages = pages.filter((page) => ["scanned", "queued", "excluded", "failed"].includes(page.status)); 3166 if (!detailedPages.length) { 3167 const fallback = document.createElement("p"); 3168 fallback.className = "status"; 3169 fallback.textContent = "Detailed catalog routes are not available for this saved run."; 3170 list.append(fallback); 3171 return; 3172 } 3173 const submitted = pages.find((page) => page.isSubmitted); 3174 const problems = pages.filter((page) => page.status !== "failed" && !page.isSubmitted && isProblemDiscoveryPage(page)); 3175 const scanned = pages.filter((page) => page.status === "scanned" && !page.isSubmitted && !isProblemDiscoveryPage(page)); 3176 const queued = pages.filter((page) => page.status === "queued" && !page.isSubmitted 3177 && !(discovery.pagesRemaining === 0 && String(page.pageType || "").toLowerCase() === "product_detail")); 3178 const failed = pages.filter((page) => page.status === "failed" && !page.isSubmitted); 3179 if (submitted) appendCatalogGroup(list, "Starting page", [submitted], submitted.status === "failed" ? "failed" : submitted.status === "queued" ? "queued" : "scanned", submitted.status === "failed" ? "Ã" : submitted.status === "queued" ? "â" : "â"); 3180 appendCatalogGroup(list, "Scanned", scanned, "scanned", "â"); 3181 appendCatalogGroup(list, "Waiting to scan", queued, "queued", "â", 5, openGroups.has("Waiting to scan")); 3182 appendCatalogGroup(list, "Needs review", problems, "review", "!"); 3183 appendCatalogGroup(list, "Failed", failed, "failed", "Ã"); 3184 const note = document.createElement("p"); 3185 note.className = "status mvp-catalog-complete"; 3186 if (discovery.candidatePagesFound === 1 && discovery.pagesScanned === 1) { 3187 note.textContent = "No additional catalog pages were discovered."; 3188 list.append(note); 3189 } else if (discovery.pagesRemaining === 0) { 3190 note.textContent = "All discovered catalog pages have been scanned."; 3191 list.append(note); 3192 } 3193 }; 3194 3195 const renderDiscoveryCoverage = (discovery) => { 3196 if (!discoveryCoveragePanel) return 0; 3197 renderIncompleteScanWarning(null); 3198 if (discovery?.enabled !== true) { 3199 lastDiscoveryModel = null; 3200 renderCatalogPotential(null); 3201 discoveryCoveragePanel.hidden = true; 3202 if (continueScanButton) { 3203 continueScanButton.hidden = true; 3204 continueScanButton.disabled = false; 3205 continueScanButton.textContent = ""; 3206 } 3207 if (stopDiscoveryButton) stopDiscoveryButton.hidden = true; 3208 setText("mvpDiscoverySubmittedUrl", "â"); 3209 setText("mvpDiscoverySourcePageType", "â"); 3210 setText("mvpDiscoveryCandidatePagesFound", "0"); 3211 setText("mvpDiscoveryPagesAttempted", "0"); 3212 setText("mvpDiscoveryPagesRemaining", "0"); 3213 setText("mvpDiscoveryRoutesExtracted", "0"); 3214 setText("mvpDiscoveryFailedOrBlockedPages", "0"); 3215 setText("mvpDiscoveryStoppedReason", "â"); 3216 setText("mvpDiscoveryNote", "â"); 3217 setText("mvpScanProgressSummary", ""); 3218 setText("mvpScanProgressSubcopy", ""); 3219 setText("mvpMetricPagesRemaining", "0"); 3220 renderDiscoveryMap(null); 3221 renderPrimaryCatalogMap(null); 3222 const completeMessage = E("mvpDiscoveryCompleteMessage"); 3223 if (completeMessage) completeMessage.hidden = true; 3224 if (continueScanStatus) { 3225 continueScanStatus.hidden = true; 3226 continueScanStatus.textContent = ""; 3227 } 3228 renderRecommendedActions(); 3229 return 0; 3230 } 3231 const pagesRemaining = discovery.pagesRemaining; 3232 lastDiscoveryModel = discovery; 3233 setText("mvpDiscoverySubmittedUrl", textOrDash(discovery.submittedUrl)); 3234 setText("mvpDiscoverySourcePageType", discoveryLabel(discovery.sourcePageType)); 3235 setText("mvpDiscoveryCandidatePagesFound", numberText(discovery.candidatePagesFound)); 3236 setText("mvpDiscoveryPagesAttempted", numberText(discovery.pagesScanned)); 3237 setText("mvpDiscoveryPagesRemaining", numberText(discovery.pagesRemaining)); 3238 setText("mvpMetricPagesRemaining", numberText(discovery.pagesRemaining)); 3239 const pagesProducingProducts = finiteCount(discovery.pagesWithProducts); 3240 setText("mvpDiscoveryRoutesExtracted", numberText(pagesProducingProducts)); 3241 setText("mvpDiscoveryFailedOrBlockedPages", numberText(discovery.failedOrBlockedPages.length)); 3242 setText("mvpDiscoveryStoppedReason", discoveryStoppedReasonLabel(discovery.stoppedReason)); 3243 setText( 3244 "mvpDiscoveryNote", 3245 pagesRemaining > 0 3246 ? "The customer-facing action appears under Next step." 3247 : "All discovered catalog pages have been scanned.", 3248 ); 3249 renderDiscoveryMap(discovery, { problemOnly: true }); 3250 renderPrimaryCatalogMap(discovery); 3251 setText( 3252 "mvpScanProgressSummary", 3253 pagesRemaining === 0 3254 ? `${discovery.productsFound} products collected · ${discovery.candidatePagesFound} routes evaluated · ${discovery.routesExtracted.length} catalog pages extracted` 3255 : `${discovery.productsFound} products collected so far · ${discovery.pagesScanned} pages scanned · ${pagesRemaining} pages waiting.`, 3256 ); 3257 const discoveredButNotExtractedProductPages = discovery.pages.some((page) => { 3258 if (String(page?.pageType || "").toLowerCase() !== "product_detail") return false;
3259 const url = safeDiscoveryPageUrl(page?.url); 3260 return Boolean(url) && !discovery.routesExtracted.some((route) => safeDiscoveryPageUrl(route) === url); 3261 }); 3262 setText( 3263 "mvpScanProgressSubcopy", 3264 pagesRemaining > 0 ? `${plural(pagesRemaining, "catalog page")} ${pagesRemaining === 1 ? "remains" : "remain"}. Scan them to find more products.` : discoveredButNotExtractedProductPages ? "Product pages were discovered but were not individually extracted in this scan." : "All discovered catalog pages have been scanned.", 3265 ); 3266 const canContinueScan = entitlementState().canContinueScan; 3267 if (continueScanButton) continueScanButton.hidden = pagesRemaining <= 0 || !canContinueScan; 3268 if (stopDiscoveryButton) stopDiscoveryButton.hidden = pagesRemaining <= 0 || !canContinueScan; 3269 const completeMessage = E("mvpDiscoveryCompleteMessage"); 3270 if (completeMessage) completeMessage.hidden = pagesRemaining > 0; 3271 if (continueScanStatus) { 3272 continueScanStatus.hidden = true; 3273 continueScanStatus.textContent = ""; 3274 } 3275 discoveryCoveragePanel.hidden = false; 3276 return pagesRemaining; 3277 }; 3278 3279 const catalogPotentialFromResponse = (data) => data?.catalogPotential || data?.customerSummary?.catalog_potential || null; 3280 3281 const extractionCoverageDescription = ({ pagesWithProducts, attemptedPages, scannedWithoutProducts, failedPages }) => { 3282 const sentences = [`Products were found on ${pagesWithProducts} of ${attemptedPages} attempted catalog ${attemptedPages === 1 ? "page" : "pages"}.`]; 3283 if (scannedWithoutProducts === 1) sentences.push("Review the page that returned no products."); 3284 if (scannedWithoutProducts > 1) sentences.push(`Review the ${scannedWithoutProducts} pages that returned no products.`); 3285 if (failedPages > 0) sentences.push(`${plural(failedPages, "page")} failed or could not be accessed.`); 3286 3287 return sentences.join(" "); 3288 }; 3289 3290 const supplierWideExpansionHost = (discovery) => { 3291 const submitted = toSafeHttpUrl(discovery?.submittedUrl); 3292 if (!submitted || !Array.isArray(discovery?.pages)) return ""; 3293 try { 3294 const submittedUrl = new URL(submitted); 3295 const submittedPath = submittedUrl.pathname.replace(/\/+$/, "") || "/"; 3296 if (submittedPath === "/") return ""; 3297 const extendsSubmittedRoute = discovery.pages.some((page) => { 3298 if (page?.status !== "queued") return false; 3299 const candidate = toSafeHttpUrl(page.url, submittedUrl.href); 3300 if (!candidate) return false; 3301 const candidateUrl = new URL(candidate); 3302 const candidatePath = candidateUrl.pathname.replace(/\/+$/, "") || "/"; 3303 return candidateUrl.host === submittedUrl.host 3304 && candidatePath !== submittedPath 3305 && !candidatePath.startsWith(`${submittedPath}/`); 3306 }); 3307 return extendsSubmittedRoute ? submittedUrl.host : ""; 3308 } catch { 3309 return ""; 3310 } 3311 }; 3312 3313 const renderCatalogPotential = (potential, discovery = null, viewModel = null) => { 3314 const panel = E("mvpCatalogPotential"); 3315 if (!panel) return; 3316 if (!potential || typeof potential !== "object") { 3317 panel.hidden = true; 3318 const expansion = E("mvpCatalogScopeExpansion"); 3319 if (expansion) expansion.hidden = true; 3320 return; 3321 } 3322 const structure = potential.catalogStructure || {}; 3323 const extraction = potential.extractionCoverage || null; 3324 const coverage = potential.scanCoverage || {}; 3325 const structureStatus = String(structure.status || "limited").toLowerCase(); 3326 const coverageStatus = String(coverage.status || "limited").toLowerCase(); 3327 const submittedPageType = String(structure.submittedPageType || ""); 3328 const structureDescription = structureStatus === "clear" 3329 ? submittedPageType === "product_detail" 3330 ? "Clear product-based catalog structure." 3331 : "Clear collection-based catalog structure." 3332 : structureStatus === "mixed" 3333 ? "Mixed catalog structure: some recognizable catalog routes were found." 3334 : "Limited catalog structure: few recognizable catalog routes were found."; 3335 setText( 3336 "mvpCatalogPotentialStructure", 3337 structureDescription, 3338 ); 3339 const remainingPages = viewModel ? viewModel.pagesRemaining : finiteCount(coverage.pagesRemaining); 3340 const coverageFailedPages = viewModel ? viewModel.pagesFailedOrBlocked : finiteCount(coverage.failedOrBlockedPages); 3341 const scanDescription = coverageStatus === "complete" 3342 ? "Complete scan: no discovered catalog pages remain." 3343 : coverageStatus === "partial" 3344 ? remainingPages > 0
3345 ? `Partial scan: ${remainingPages} discovered ${remainingPages === 1 ? "page remains" : "pages remain"}.` 3346 : `Partial scan: ${coverageFailedPages} ${coverageFailedPages === 1 ? "page failed or was inaccessible" : "pages failed or were inaccessible"}.` 3347 : `Limited scan: ${viewModel ? viewModel.pagesScanned : finiteCount(coverage.pagesScanned)} catalog ${(viewModel ? viewModel.pagesScanned : finiteCount(coverage.pagesScanned)) === 1 ? "page was" : "pages were"} scanned.`; 3348 setText( 3349 "mvpCatalogPotentialCoverage", 3350 scanDescription, 3351 ); 3352 const optionalCount = (value) => value !== null && value !== undefined && Number.isFinite(Number(value)) 3353 ? finiteCount(value) 3354 : null; 3355 const pagesWithProducts = extraction 3356 ? finiteCount(extraction.pagesWithProducts) 3357 : viewModel ? viewModel.pagesProducingProducts 3358 : optionalCount(collected.pagesWithProducts); 3359 const attemptedPages = extraction 3360 ? finiteCount(extraction.pagesScanned) 3361 : viewModel ? viewModel.pagesScanned 3362 : optionalCount(collected.pagesScanned); 3363 const scannedWithoutProducts = viewModel 3364 ? (Array.isArray(discovery?.pages) ? discovery.pages : []).filter((page) => page.status === "scanned" && finiteCount(page.productsFound) === 0 && isProblemDiscoveryPage(page)).length 3365 : extraction 3366 ? finiteCount(extraction.scannedPagesWithoutProducts) 3367 : finiteCount(structure.scannedPagesWithoutProducts); 3368 const failedPages = viewModel ? viewModel.pagesFailedOrBlocked : extraction 3369 ? finiteCount(extraction.failedOrBlockedPages) 3370 : finiteCount(coverage.failedOrBlockedPages ?? structure.failedOrBlockedPages); 3371 setText( 3372 "mvpCatalogPotentialData", 3373 extraction 3374 ? extractionCoverageDescription({ pagesWithProducts, attemptedPages, scannedWithoutProducts, failedPages }) 3375 : "Product extraction coverage is not available for this saved run.", 3376 ); 3377 const readiness = potential.commerceReadiness || potential.commerce_readiness || null; 3378 const readinessValues = [ 3379 readiness?.catalogDiscoverability ?? readiness?.catalog_discoverability, 3380 readiness?.productStructuredData ?? readiness?.product_structured_data, 3381 readiness?.seoFoundation ?? readiness?.seo_foundation, 3382 readiness?.status, 3383 ].map((value) => String(value || "").trim().toLowerCase()); 3384 if (readinessValues.length && readinessValues.every((value) => !value || value === "not_assessed" || value === "not assessed")) { 3385 panel.hidden = true; 3386 return; 3387 } 3388 setText("mvpCommerceCatalogDiscoverability", readinessDisplayLabel(readiness?.catalogDiscoverability || readiness?.catalog_discoverability)); 3389 setText("mvpCommerceProductStructuredData", readinessDisplayLabel(readiness?.productStructuredData || readiness?.product_structured_data)); 3390 setText("mvpCommerceSeoFoundation", readinessDisplayLabel(readiness?.seoFoundation || readiness?.seo_foundation)); 3391 setText("mvpCommerceOverallReadiness", readinessDisplayLabel(readiness?.status)); 3392 const readinessNote = E("mvpCommerceReadinessNote"); 3393 if (readinessNote) { 3394 readinessNote.textContent = readiness?.primaryObservation || readiness?.primary_observation || readiness?.note || ""; 3395 readinessNote.hidden = !readinessNote.textContent; 3396 } 3397 const evidence = readiness?.evidenceScope || readiness?.evidence_scope || null; 3398 const signals = readiness?.signals || null; 3399 const evidencePanel = E("mvpCommerceEvidence"); 3400 if (evidencePanel) evidencePanel.hidden = !evidence && !signals; 3401 if (evidence || signals) { 3402 setText("mvpCommerceEvidenceScope", String(readiness?.scope || "").replaceAll("_", " ") || "â"); 3403 setText("mvpCommercePdpPagesAttempted", finiteCount(evidence?.productDetailPagesAttempted ?? evidence?.product_detail_pages_attempted)); 3404 setText("mvpCommercePdpPagesSampled", finiteCount(evidence?.sampledProductDetailPages ?? evidence?.sampled_product_detail_pages)); 3405 setText("mvpCommerceJsonLdBlocks", finiteCount(signals?.jsonLdBlocks ?? signals?.json_ld_blocks)); 3406 const structuredFields = Array.isArray(signals?.structuredFields) 3407 ? signals.structuredFields 3408 : Array.isArray(signals?.structured_fields) ? signals.structured_fields : []; 3409 setText("mvpCommerceStructuredFields", structuredFields.length ? structuredFields.join(", ") : "â"); 3410 const sampledUrls = Array.isArray(evidence?.sampledProductDetailPageUrls) 3411 ? evidence.sampledProductDetailPageUrls 3412 : Array.isArray(evidence?.sampled_product_detail_page_urls) ? evidence.sampled_product_detail_page_urls : []; 3413 const safeSampledUrls = sampledUrls.map((url) =>
3413 toSafeHttpUrl(url)).filter(Boolean); 3414 const sampledUrlsPanel = E("mvpCommerceSampledUrls"); 3415 if (sampledUrlsPanel) { 3416 sampledUrlsPanel.replaceChildren(); 3417 if (safeSampledUrls.length) { 3418 const label = document.createElement("strong"); 3419 label.textContent = t("mvp.technical.sampledUrls", null, "Sampled product-detail URLs:"); 3420 const list = document.createElement("ul"); 3421 safeSampledUrls.forEach((url) => { 3422 const item = document.createElement("li"); 3423 const link = document.createElement("a"); 3424 link.href = url; 3425 link.target = "_blank"; 3426 link.rel = "noopener"; 3427 link.textContent = url; 3428 item.append(link); 3429 list.append(item); 3430 }); 3431 sampledUrlsPanel.append(label, list); 3432 } 3433 sampledUrlsPanel.hidden = safeSampledUrls.length <= 0; 3434 } 3435 } 3436 3437 const expansion = E("mvpCatalogScopeExpansion"); 3438 if (expansion) { 3439 const expansionHost = supplierWideExpansionHost(discovery); 3440 expansion.textContent = expansionHost 3441 ? `ScrapingSmart expanded beyond the submitted collection or category to other catalog sections on ${expansionHost}.` 3442 : ""; 3443 expansion.hidden = !expansion.textContent; 3444 } 3445 3446 panel.hidden = false; 3447 }; 3448 3449 const setResultWorkflowStage = (state, { stopDiscovery = false } = {}) => { 3450 if (!state) return; 3451 resultWorkflowState = { ...state, stopDiscovery }; 3452 const discoveryInProgress = state.discovery?.enabled === true 3453 && state.discovery.pagesRemaining > 0 3454 && !stopDiscovery; 3455 if (stateTitle) stateTitle.textContent = state.hasSuccessfulExport ? "Your dataset is ready" : state.finalTitle; 3456 if (resultStatus) { 3457 resultStatus.textContent = discoveryInProgress ? "More products available" : state.finalStatus; 3458 resultStatus.dataset.state = discoveryInProgress ? "scanning" : state.finalStatusState; 3459 } 3460 if (resultBanner && !discoveryInProgress && !state.hasSuccessfulExport) { 3461 resultBanner.textContent = state.finalBanner; 3462 resultBanner.hidden = false; 3463 } 3464 if (resultSource) { 3465 setResultSourceLabel(state.requestMeta); 3466 } 3467 if (stateMessage) { 3468 stateMessage.textContent = discoveryInProgress ? "" : state.finalMessage; 3469 stateMessage.hidden = discoveryInProgress || !state.finalMessage; 3470 } 3471 const paidContinuationAvailable = entitlementState().canContinueScan; 3472 if (continueScanButton) continueScanButton.hidden = state.automaticFullCatalog || !discoveryInProgress || !paidContinuationAvailable; 3473 if (stopDiscoveryButton) stopDiscoveryButton.hidden = true; 3474 if (datasetActions) datasetActions.hidden = stopDiscovery || !state.hasSuccessfulExport || continuationScanActive || !paidContinuationAvailable; 3475 3476 if (downloadsTitle) downloadsTitle.hidden = false; 3477 if (currentDraftExport) { 3478 currentDraftExport.classList.toggle("mvp-current-draft-export--main", stopDiscovery); 3479 } 3480 if (recommendedAction) recommendedAction.hidden = !state.hasSuccessfulExport; 3481 if (cleanupPanel) cleanupPanel.hidden = lastMvpResultViewModel?.productsCollected <= 0; 3482 if (previewDetails) { 3483 previewDetails.hidden = !state.hasPreview; 3484 previewDetails.classList.remove("mvp-preview-details--discovery"); 3485 } 3486 if (discoveryPreviewSlot) discoveryPreviewSlot.hidden = true; 3487 if (reviewPreviewSlot) reviewPreviewSlot.hidden = !state.hasPreview; 3488 const reviewDetails = E("mvpReviewDetails"); 3489 if (reviewDetails) reviewDetails.hidden = !state.showReview; 3490 const progressSummary = E("mvpScanProgressSummary"); 3491 const progressSubcopy = E("mvpScanProgressSubcopy"); 3492 if (progressSummary) progressSummary.hidden = discoveryInProgress; 3493 if (progressSubcopy) progressSubcopy.hidden = discoveryInProgress; 3494 }; 3495 3496 const resultIssueCounts = (data) => data?.summary?.issueCounts || {}; 3497 3498 const resultContentMetrics = (data) => { 3499 const issueCounts = resultIssueCounts(data); 3500 const missingImages = Number(issueCounts?.missing_image ?? 0); 3501 const weakDescriptions = Number(
3502 issueCounts?.weak_description 3503 ?? issueCounts?.missing_description 3504 ?? 0 3505 ); 3506 return { 3507 missingImages: Number.isFinite(missingImages) ? Math.max(0, missingImages) : 0, 3508 weakDescriptions: Number.isFinite(weakDescriptions) ? Math.max(0, weakDescriptions) : 0, 3509 }; 3510 }; 3511 3512 const setEnrichmentState = (state, message) => { 3513 const normalized = ["queued", "starting", "processing", "completed", "partial", "failed"].includes(state) 3514 ? state 3515 : "processing"; 3516 if (enrichmentStatusPanel) enrichmentStatusPanel.hidden = false; 3517 if (enrichmentStatusState) { 3518 enrichmentStatusState.textContent = normalized.charAt(0).toUpperCase() + normalized.slice(1); 3519 enrichmentStatusState.dataset.state = normalized; 3520 } 3521 if (enrichmentProgress) enrichmentProgress.hidden = ["completed", "partial", "failed"].includes(normalized); 3522 if (enrichmentStatus) enrichmentStatus.textContent = message; 3523 }; 3524 3525 const enrichmentFailureSummary = (enrichment) => { 3526 const failures = Array.isArray(enrichment?.failures) ? enrichment.failures : []; 3527 if (!failures.length) return ""; 3528 const counts = failures.reduce((out, failure) => { 3529 const code = String(failure?.reasonCode || "unknown"); 3530 out[code] = (out[code] || 0) + 1; 3531 return out; 3532 }, {}); 3533 const labels = { 3534 blocked: "blocked", 3535 http_error: "returned HTTP errors", 3536 fetch_timeout: "timed out", 3537 non_html_response: "returned non-HTML responses", 3538 no_product_data: "returned no product data", 3539 invalid_url: "had invalid URLs", 3540 unknown: "failed for unknown reasons", 3541 }; 3542 const parts = Object.entries(counts).map(([code, count]) => `${count} ${labels[code] || "failed"}`); 3543 return parts.length ? `${Number(enrichment.productsFailed || failures.length)} failed: ${parts.join(", ")}.` : ""; 3544 }; 3545 3546 /** 3547 * @typedef {Object} MarketingContentProductContext 3548 * @property {string} name 3549 * @property {string=} description 3550 * @property {string=} price 3551 * @property {string=} sku 3552 * @property {string=} category 3553 * @property {string=} brand 3554 * @property {string=} productUrl 3555 * @property {string[]} images 3556 * 3557 * @typedef {Object} MarketingContentRequest 3558 * @property {"template"|"ai"} creationType 3559 * @property {MarketingContentProductContext} product 3560 * @property {Object} creativeOverrides 3561 * @property {string} selectedImage 3562 * @property {string} purpose 3563 * @property {{preset: string, width: number, height: number, aspectRatio: string}} output 3564 * @property {string} instruction 3565 * @property {string=} template 3566 */ 3567 3568 const marketingHttpUrl = (value) => { 3569 const raw = String(value || "").trim(); 3570 if (!raw) return ""; 3571 try { 3572 const url = new URL(raw); 3573 return url.protocol === "http:" || url.protocol === "https:" ? url.href : ""; 3574 } catch { 3575 return ""; 3576 } 3577 }; 3578 3579 const marketingImagesFor = (product) => { 3580 const images = []; 3581 const seen = new Set(); 3582 const add = (value) => { 3583 if (Array.isArray(value)) { 3584 value.forEach(add); 3585 return; 3586 } 3587 const image = marketingHttpUrl(value); 3588 if (!image || seen.has(image)) return; 3589 seen.add(image); 3590 images.push(image); 3591 }; 3592 add(product?.image); 3593 add(product?.images); 3594 add(product?.imageUrl); 3595 add(product?.image_url); 3596 add(product?.imageUrls); 3597 add(product?.image_urls); 3598 if (Array.isArray(product?.variants)) product.variants.forEac
3598h((variant) => add(variant?.image)); 3599 return images; 3600 }; 3601 3602 const marketingText = (value) => String(value ?? "").trim(); 3603 3604 const formatDetectedPrice = (product) => { 3605 const raw = marketingText(product?.price); 3606 if (!raw) return ""; 3607 const currency = marketingText(product?.currency ?? product?.priceCurrency ?? product?.price_currency).toUpperCase(); 3608 if (!currency || /[A-Za-z$â¬Â£Â¥]/.test(raw)) return raw; 3609 const numeric = Number(raw.replace(/[^0-9,.-]/g, "").replace(/,(?=\d{1,2}$)/, ".").replace(/,/g, "")); 3610 if (!Number.isFinite(numeric)) return `${currency} ${raw}`; 3611 return `${currency} ${new Intl.NumberFormat(undefined, { maximumFractionDigits: Number.isInteger(numeric) ? 0 : 2 }).format(numeric)}`; 3612 }; 3613 3614 const normalizeMarketingProductContext = (product) => { 3615 const context = { 3616 name: marketingText(product?.name ?? product?.title), 3617 description: marketingText(product?.description), 3618 price: formatDetectedPrice(product), 3619 sku: marketingText(product?.sku), 3620 category: marketingText(product?.category), 3621 brand: marketingText(product?.brand), 3622 productUrl: marketingHttpUrl(product?.url ?? product?.productUrl ?? product?.product_url), 3623 images: marketingImagesFor(product), 3624 }; 3625 for (const key of ["description", "price", "sku", "category", "brand", "productUrl"]) { 3626 if (!context[key]) delete context[key]; 3627 } 3628 return context; 3629 }; 3630 3631 const productOptionLabel = (product) => { 3632 const context = normalizeMarketingProductContext(product); 3633 if (context.name && context.sku) return `${context.name} â ${context.sku}`; 3634 return context.name || context.sku || context.productUrl || t("mvp.marketing.unnamedProduct", null, "Unnamed product"); 3635 }; 3636 3637 const productStableIdentifier = (product, index) => { 3638 const context = normalizeMarketingProductContext(product); 3639 return marketingText(product?.id ?? product?.productId ?? product?.product_id) 3640 || context.sku 3641 || context.productUrl 3642 || `product-${index + 1}`; 3643 }; 3644 3645 const hasMarketingContext = (context) => Boolean( 3646 context?.name 3647 || context?.description 3648 || context?.price 3649 || context?.sku 3650 || context?.category 3651 || context?.brand 3652 || context?.productUrl 3653 || context?.images?.length 3654 ); 3655 3656 const setMarketingStatus = (message, state = "") => { 3657 if (!marketingContentStatus) return; 3658 marketingContentStatus.textContent = message; 3659 if (state) marketingContentStatus.dataset.state = state; 3660 else delete marketingContentStatus.dataset.state; 3661 }; 3662 3663 const clearPreparedMarketingContent = () => { 3664 marketingContentState.preparedRequest = null; 3665 if (window.__MVP_TEST_HOOKS__ && typeof window.__MVP_TEST_HOOKS__ === "object") { 3666 window.__MVP_TEST_HOOKS__.marketingContentRequest = null; 3667 } 3668 if (marketingTemplatePreview) { 3669 marketingTemplatePreview.hidden = true; 3670 marketingTemplatePreview.textContent = ""; 3671 } 3672 if (marketingDownload) { 3673 marketingDownload.hidden = false; 3674 marketingDownload.removeAttribute("href"); 3675 } 3676 setMarketingStatus(""); 3677 }; 3678 3679 const creativeOverridesFor = (context, product = {}) => ({ 3680 name: context.name || "", 3681 price: context.price || "", 3682 brand: context.brand || "", 3683 sku: context.sku || "", 3684 description: context.description || "", 3685 headline: marketingText(product?.headline) || context.name || "", 3686 cta: marketingText(product?.cta), 3687 }); 3688 3689 const readCreativeOverrides = () => ({ 3690 name: marketingText(marketingName?.value), 3691 price: marketingText(marketingPrice?.value), 3692 brand: marketingText(marketingBrand?.value), 3693 sku: marketingText(marketingSku?.value), 3694 description: marketingText(marketingDescription?.value), 3695 headline: marketingText(marketingHeadline?.value), 3696 cta: marketingText(marketingCta?.value), 3697 }); 3698 3699 const writeCreativeOverrides = (overrides) => { 3700 const fields = [ 3701 [marketingName, "name"], 3702 [marketingPrice, "price"], 3703 [marketingBrand, "brand"], 3704 [marketingSku, "sku"], 3705 [marketingDescription, "description"], 3706 [marketingHeadline, "headline"], 3707 [marketingCta, "cta"], 3708 ]; 3709 fields.forEach(([field, key]) => { 3710 if (field) field.value = overrides?.[key] || ""; 3711 }); 3712 }; 3713 3714 const renderSelectedMarketingProduct = () => { 3715 const product = marketingContentState.products[marketingContentState.selectedIndex]; 3716 const context = normalizeMarketingProductContext(product || {}); 3717 marketingContentState.creativeOverrides = creativeOverridesFor(context, product);
3718 marketingContentState.selectedImage = context.images[0] || ""; 3719 writeCreativeOverrides(marketingContentState.creativeOverrides); 3720 if (marketingImages) { 3721 marketingImages.textContent = ""; 3722 context.images.forEach((source, index) => { 3723 const label = document.createElement("label"); 3724 label.className = "mvp-marketing-image-option"; 3725 const image = document.createElement("img"); 3726 image.src = source; 3727 image.alt = context.name 3728 ? t("mvp.marketing.productImageAlt", { name: context.name }, `Product image for ${context.name}`) 3729 : t("mvp.marketing.productImage", null, "Product image"); 3730 image.loading = "lazy"; 3731 if (context.images.length > 1) { 3732 const radio = document.createElement("input"); 3733 radio.type = "radio"; 3734 radio.name = "mvpMarketingImage"; 3735 radio.value = source; 3736 radio.checked = source === marketingContentState.selectedImage; 3737 const selection = document.createElement("span"); 3738 selection.textContent = t("mvp.marketing.imageNumber", { number: index + 1 }, `Image ${index + 1}`); 3739 label.append(radio, image, selection); 3740 } else { 3741 label.append(image); 3742 } 3743 marketingImages.appendChild(label); 3744 }); 3745 if (!context.images.length) { 3746 const empty = document.createElement("p"); 3747 empty.className = "status"; 3748 empty.textContent = t("mvp.marketing.noImages", null, "No product images were collected."); 3749 marketingImages.appendChild(empty); 3750 } 3751 } 3752 clearPreparedMarketingContent(); 3753 }; 3754 3755 const renderMarketingProductSelector = () => { 3756 if (!marketingProductSelect) return; 3757 marketingProductSelect.textContent = ""; 3758 marketingContentState.products.forEach((product, index) => { 3759 const option = document.createElement("option"); 3760 option.value = String(index); 3761 option.textContent = productOptionLabel(product); 3762 marketingProductSelect.appendChild(option); 3763 }); 3764 marketingContentState.selectedIndex = Math.min( 3765 marketingContentState.selectedIndex, 3766 Math.max(0, marketingContentState.products.length - 1), 3767 ); 3768 marketingProductSelect.value = String(marketingContentState.selectedIndex); 3769 if (marketingCustomize && "open" in marketingCustomize) marketingCustomize.open = false; 3770 renderSelectedMarketingProduct(); 3771 }; 3772 3773 const marketingCanvasText = (value, fallback = "") => marketingText(value) || fallback; 3774 3775 const drawMarketingStory = async (request) => { 3776 if (!marketingTemplatePreview) return; 3777 marketingTemplatePreview.textContent = ""; 3778 marketingTemplatePreview.hidden = false; 3779 const canvas = document.createElement("canvas"); 3780 canvas.className = "mvp-marketing-story-canvas"; 3781 canvas.width = 1080; 3782 canvas.height = 1920; 3783 marketingTemplatePreview.appendChild(canvas); 3784 let context; 3785 try { context = canvas.getContext("2d"); } catch { context = null; } 3786 if (!context) return; 3787 3788 const gradient = context.createLinearGradient(0, 0, 1080, 1920); 3789 gradient.addColorStop(0, "#f8f4ec"); 3790 gradient.addColorStop(1, "#dce8e5"); 3791 context.fillStyle = gradient; 3792 context.fillRect(0, 0, 1080, 1920); 3793 context.fillStyle = "#172033"; 3794 context.fillRect(0, 0, 1080, 18); 3795 3796 if (request.selectedImage && typeof window.Image === "function") { 3797 await new Promise((resolve) => { 3798 const image = new window.Image(); 3799 image.crossOrigin = "anonymous"; 3800 image.onload = () => { 3801 const area = { x: 72, y: 180, width: 936, height: 1010 }; 3802 const scale = Math.min(area.width / image.naturalWidth, area.height / image.naturalHeight); 3803 const width = image.naturalWidth * scale; 3804 const height = image.naturalHeight * scale; 3805 context.drawImage(image, area.x + (area.width - width) / 2, area.y + (area.height - height) / 2, width, height); 3806 resolve(); 3807 }; 3808 image.onerror = () => resolve(); 3809 image.src = request.selectedImage; 3810 }); 3811 } 3812 3813 const wrap = (value, maxChars) => { 3814 const text = marketingCanvasText(value); 3815 return text.length > maxChars ? `${text.slice(0, maxChars - 1).trimEnd()}â¦` : text; 3816 }; 3817 const copy = request.creativeOverrides; 3818 context.fillStyle = "#172033"; 3819 context.textBaseline = "top"; 3820 if (copy.brand) { context.font = "700 28px Arial"; context.fillText(wrap(copy.brand, 30).toUpperCase(), 84, 1260); } 3821 context.font = "900 74px Arial"; 3822 context.fillText(wrap(copy.headline || copy.name, 28), 84, 1320, 912); 3823 context.font = "500 34px Arial"; 3824 context.fillText(wrap(copy.name, 42), 84, 1430, 912); 3825 if (copy.description) { context.font = "400 28px Arial"; context.fillText(wrap(copy.description, 52), 84, 1492, 912); } 3826 if (copy.price) { context.fillStyle = "#b0441e"; context.font = "900 58px Arial"; context.fillText(wrap(copy.price, 24), 84, 1580, 912); } 3827 context.fillStyle = "#fff"; 3828 context.fillRect(84, 1710, 300, 76); 3829 context.fillStyle = "#172033"; 3830 context.font = "700 28px Arial"; 3831 context.fillText(wrap(copy.cta || "Shop now", 24), 116, 1732, 236); 3832 if (marketingDownload && authenticatedActionAllowed()) {
3833 try { 3834 marketingDownload.href = canvas.toDataURL("image/png"); 3835 marketingDownload.hidden = false; 3836 renderActionAccessGates(); 3837 } catch { setMarketingStatus("Story preview created, but this image cannot be exported by the browser.", "error"); } 3838 } 3839 }; 3840 3841 const renderMarketingLabels = () => { 3842 setText("mvpMarketingContentCopy", "Create an Instagram Story from a product in this dataset."); 3843 setText("mvpMarketingProductSelectLabel", t("mvp.marketing.chooseProduct", null, "Choose a product")); 3844 setText("mvpMarketingPriceLabel", t("mvp.marketing.price", null, "Price")); 3845 setText("mvpMarketingBrandLabel", t("mvp.marketing.brand", null, "Brand")); 3846 setText("mvpMarketingDescriptionLabel", t("mvp.marketing.description", null, "Description")); 3847 setText("mvpMarketingHeadlineLabel", t("mvp.marketing.headline", null, "Headline")); 3848 setText("mvpMarketingCtaLabel", t("mvp.marketing.cta", null, "CTA")); 3849 setText("mvpMarketingCustomizeSummary", t("mvp.marketing.customize", null, "Customize story")); 3850 setText("mvpPrepareMarketingRequest", t("mvp.marketing.createStory", null, "Create story")); 3851 setText("mvpMarketingDownload", t("mvp.marketing.downloadPng", null, "Download PNG")); 3852 }; 3853 3854 function renderMarketingContentAuthState({ primary = marketingContentState.primary } = {}) { 3855 const signedIn = isSignedIn(); 3856 if (prepareMarketingRequestButton) { 3857 prepareMarketingRequestButton.classList.toggle("button--primary", primary); 3858 prepareMarketingRequestButton.classList.toggle("secondary", !primary); 3859 } 3860 if (openMarketingContentButton) { 3861 openMarketingContentButton.hidden = false; 3862 openMarketingContentButton.disabled = !authenticatedActionAllowed(); 3863 openMarketingContentButton.textContent = "Create marketing content"; 3864 openMarketingContentButton.classList.toggle("button--primary", primary); 3865 openMarketingContentButton.classList.toggle("secondary", !primary); 3866 } 3867 renderActionAccessGates(); 3868 if (marketingContentAuthSupport) marketingContentAuthSupport.hidden = true; 3869 if (marketingContentState.products.length && marketingContentWorkspace) { 3870 renderMarketingProductSelector(); 3871 marketingContentWorkspace.hidden = false; 3872 openMarketingContentButton?.setAttribute("aria-expanded", "true"); 3873 if (signedIn && !marketingContentState.productsComplete) void loadMarketingWorkspaceProducts(); 3874 } 3875 } 3876 3877 const renderMarketingContentStep = (data, productsFound, { showAsNextAction = true, primary = false } = {}) => { 3878 const availableProducts = [ 3879 ...(Array.isArray(data?.products) ? data.products : []), 3880 ...(Array.isArray(data?.preview) ? data.preview : []), 3881 ]; 3882 const hasUsableProduct = availableProducts.some((product) => hasMarketingContext(normalizeMarketingProductContext(product))); 3883 const show = showAsNextAction 3884 && selectedMvpIntent === "extraction" 3885 && Number(productsFound || 0) > 0 3886 && hasUsableProduct 3887 && String(data?.status || "").toLowerCase() !== "failed"; 3888 marketingContentState.primary = Boolean(primary); 3889 if (marketingContentStep) marketingContentStep.hidden = !show; 3890 if (!show) { 3891 if (marketingContentWorkspace) marketingContentWorkspace.hidden = true; 3892 openMarketingContentButton?.setAttribute("aria-expanded", "false"); 3893 return; 3894 } 3895 setText("mvpMarketingContentTitle", "Create marketing content"); 3896 if (marketingContentState.sourceData !== data) { 3897 const fullProducts = Array.isArray(data?.products) ? data.products : []; 3898 const preview = Array.isArray(data?.preview) ? data.preview : []; 3899 marketingContentState.sourceData = data; 3900 marketingContentState.productsComplete = fullProducts.length >= Number(productsFound || 0); 3901 marketingContentState.products = marketingContentState.productsComplete 3902 ? fullProducts 3903 : preview; 3904 marketingContentState.selectedIndex = 0; 3905 marketingContentState.creativeOverrides = null; 3906 marketingContentState.selectedImage = ""; 3907 marketingContentState.preparedRequest = null; 3908 marketingContentState.productsLoadPromise = null; 3909 if (marketingContentWorkspace) marketingContentWorkspace.hidden = true; 3910 openMarketingContentButton?.setAttribute("aria-expanded", "false"); 3911 } 3912 renderMarketingLabels(); 3913 renderMarketingContentAuthState({ primary }); 3914 }; 3915 3916 const loadFullMarketingProducts = async (sourceData = marketingContentState.sourceData) => { 3917 if (marketingContentState.productsComplete) return true; 3918 const runId = marketingContentState.sourceData?.runId || lastMvpResult?.runId; 3919 if (!runId) return false;
3920 const response = await authenticatedFetch(`/api/mvp/runs/${encodeURIComponent(runId)}/result`, { 3921 credentials: "same-origin", 3922 }, { endpointType: "marketing_content_result", runId }); 3923 const data = await parseResponsePayload(response); 3924 if (!response.ok || data?.ok === false || !Array.isArray(data?.products)) return false; 3925 if (marketingContentState.sourceData !== sourceData) return false; 3926 marketingContentState.products = data.products; 3927 marketingContentState.productsComplete = true; 3928 return true; 3929 }; 3930 3931 const loadMarketingWorkspaceProducts = () => { 3932 if (marketingContentState.productsComplete || marketingContentState.productsLoadPromise) { 3933 return marketingContentState.productsLoadPromise; 3934 } 3935 const sourceData = marketingContentState.sourceData; 3936 if (!sourceData) return null; 3937 if (marketingProductSelect) marketingProductSelect.disabled = true; 3938 if (prepareMarketingRequestButton) prepareMarketingRequestButton.disabled = true; 3939 setMarketingStatus(t("mvp.marketing.loading", null, "Loading productsâ¦")); 3940 const loadPromise = loadFullMarketingProducts(sourceData) 3941 .then((loaded) => { 3942 if (marketingContentState.sourceData !== sourceData) return false; 3943 if (!loaded || !marketingContentState.products.length) { 3944 setMarketingStatus(t("mvp.marketing.loadFailed", null, "The full product collection could not be loaded. Try opening this dataset again from My runs."), "error"); 3945 return false; 3946 } 3947 if (marketingContentState.selectedIndex >= marketingContentState.products.length) marketingContentState.selectedIndex = 0; 3948 renderMarketingProductSelector(); 3949 setMarketingStatus(""); 3950 return true; 3951 }) 3952 .catch(() => { 3953 if (marketingContentState.sourceData === sourceData) { 3954 setMarketingStatus(t("mvp.marketing.loadFailed", null, "The full product collection could not be loaded. Try opening this dataset again from My runs."), "error"); 3955 } 3956 return false; 3957 }) 3958 .finally(() => { 3959 if (marketingContentState.sourceData !== sourceData) return; 3960 if (marketingProductSelect) marketingProductSelect.disabled = false; 3961 if (prepareMarketingRequestButton) prepareMarketingRequestButton.disabled = false; 3962 if (marketingContentState.productsLoadPromise === loadPromise) marketingContentState.productsLoadPromise = null; 3963 }); 3964 marketingContentState.productsLoadPromise = loadPromise; 3965 return loadPromise; 3966 }; 3967 3968 const renderRecommendedActions = ({ data = null, productsFound = 0, viewModel = null } = {}) => { 3969 const eligibility = data?.enrichmentEligibility || {}; 3970 const hasEligibility = Number.isFinite(Number(eligibility.productsEligible)); 3971 const eligible = viewModel ? viewModel.productsRemainingForEnrichment : Math.max(0, Number(eligibility.productsEligible || 0)); 3972 const eligibleTotal = viewModel ? viewModel.productsEligibleForEnrichment : eligible; 3973 const checkedTotal = viewModel ? viewModel.productsChecked : 0; 3974 const improvedTotal = viewModel ? viewModel.productsImproved : 0; 3975 const ineligible = viewModel ? viewModel.productsIneligibleForEnrichment : Math.max(0, Number(productsFound || 0) - eligibleTotal); 3976 const credits = Math.max(0, Number(eligibility.creditsRequired || 0)); 3977 const enrichment = data?.enrichment || data?.summary?.enrichment || data?.customerSummary?.enrichment; 3978 const authenticated = data?.auth?.authenticated === true 3979 || data?.usage?.authenticated === true 3980 || lastUsage?.authenticated === true 3981 || isSignedIn(); 3982 const paidCredits = Math.max(0, Number( 3983 data?.usage?.paidCreditsRemaining 3984 ?? eligibility.paidCreditsRemaining 3985 ?? lastUsage?.paidCreditsRemaining 3986 ?? 0 3987 )); 3988 const canEnrich = isSignedIn() && paidCredits > 0 && paidCredits >= credits; 3989 const continuationPending = Number(viewModel?.pagesRemaining || 0) > 0; 3990 if (enrichment) enrichmentInProgress = false; 3991 const showEnrichment = Number(productsFound || 0) > 0 && (Boolean(enrichment) || hasEligibility); 3992 const enrichmentCompleted = Boolean(enrichment) 3993 && String(enrichment.status || "completed").toLowerCase() === "completed" 3994 && eligible <= 0 3995 && Number(enrichment.productsFailed || 0) <= 0 3996 && Number(enrichment.productsPartial || 0) <= 0; 3997 const enrichedCount = Math.max(0, Number(enrichment?.productsEnriched ?? checkedTotal ?? productsFound)); 3998 const enrichmentHasWork = eligible > 0 3999 || Number(enrichment?.productsFailed || 0) > 0 4000 || Number(enrichment?.productsPartial || 0) > 0; 4001 const showEnrichmentStage = enrichmentCompleted || enrichmentHasWork || enrichmentInProgress; 4002 if (enrichmentAction) enrichmentAction.hidden = !showEnrichmentStage; 4003 if (enrichmentSummary) { 4004 enrichmentSummary.textContent = enrichmentCompleted 4005 ? t("mvp.enrichment.compactComplete", { count: enrichedCount }, `â ${enrichedCount} products enriched`) 4006 : ""; 4007 enrichmentSummary.hidden = !enrichmentCompleted; 4008 } 4009 if (enrichProductsButton) { 4010 const retriesExist = eligible > 0 || Number(enrichment?.productsFailed || 0) > 0 || Number(enrichment?.productsPartial || 0) > 0; 4011 const showEnrichmentButton = showEnrichmentStage 4012 && !enrichmentCompleted 4013 && retriesExist 4014 && !enrichmentInProgress; 4015 enrichProductsButton.hidden = !showEnrichmentButton; 4016 enrichProductsButton.disabled = !canEnrich; 4017 const enrichmentIsPrimary = !continuationPending; 4018 enrichProductsButton.classList.toggle("button--primary", enrichmentIsPrimary); 4019 enrichProductsButton.classList.toggle("secondary", !enrichmentIsPrimary); 4020 enrichProductsButton.dataset.creditsRequired = String(credits); 4021 enrichProductsButton.dataset.requiresRuns = authenticated && !canEnrich ? "true" : "false"; 4022 enrichProductsButton.textContent = authenticated && paidCredits < credits 4023 ? t("mvp.enrichment.requiresCredits", { cost: credits, balance: paidCredits }, `Requires ${credits} credits · You have ${paidCredits}`)
4024 : t("mvp.enrichment.exactCost", { count: eligible, cost: credits }, `Enrich ${eligible} products · ${credits} credits`); 4025 } 4026 const enrichmentGateCopy = E("mvpEnrichmentGateCopy"); 4027 const enrichmentGateActions = E("mvpEnrichmentGateActions"); 4028 const enrichmentGateAction = E("mvpCreateAccountEnrich"); 4029 const enrichmentGateSignIn = E("mvpSignInEnrich"); 4030 const enrichmentLocked = authenticated && showEnrichmentStage && !enrichmentCompleted && eligible > 0 && !canEnrich; 4031 if (enrichmentGateCopy) { 4032 enrichmentGateCopy.hidden = true; 4033 enrichmentGateCopy.textContent = ""; 4034 } 4035 if (enrichmentGateActions) enrichmentGateActions.hidden = true; 4036 if (enrichmentGateAction) { 4037 enrichmentGateAction.hidden = !authenticated || !enrichmentLocked; 4038 enrichmentGateAction.textContent = t("mvp.runs.add", null, "Add credits"); 4039 enrichmentGateAction.classList.toggle("button--primary", !continuationPending); 4040 enrichmentGateAction.classList.toggle("secondary", continuationPending); 4041 } 4042 if (enrichmentGateSignIn) { 4043 enrichmentGateSignIn.hidden = true; 4044 enrichmentGateSignIn.textContent = t("mvp.auth.signIn", null, "Sign in"); 4045 } 4046 if (enrichment) { 4047 const attempted = Number(enrichment.productsAttempted ?? (Number(enrichment.productsEnriched || 0) + Number(enrichment.productsPartial || 0) + Number(enrichment.productsFailed || 0))); 4048 const checked = Number(enrichment.productsChecked ?? (Number(enrichment.productsEnriched || 0) + Number(enrichment.productsPartial || 0))); 4049 const improved = Number(enrichment.productsImproved ?? enrichment.productsEnriched ?? 0); 4050 const parsedNoChange = Number(enrichment.productsParsedNoChange || 0); 4051 const partial = Number(enrichment.productsPartial || 0); 4052 const failed = Number(enrichment.productsFailed || 0); 4053 const outcomes = `${checked} product page${checked === 1 ? "" : "s"} checked · ${improved} product${improved === 1 ? "" : "s"} improved.` 4054 + `${parsedNoChange ? ` ${parsedNoChange} had no useful new data.` : ""}` 4055 + `${partial ? ` ${partial} remain partial.` : ""}` 4056 + `${failed ? ` ${failed} failed out of ${attempted} attempted.` : ""}`; 4057 const failureSummary = enrichmentFailureSummary(enrichment); 4058 if (enrichmentCompleted) { 4059 if (enrichmentStatusPanel) enrichmentStatusPanel.hidden = true; 4060 } else { 4061 setEnrichmentState( 4062 ["completed", "partial", "failed"].includes(String(enrichment.status)) ? String(enrichment.status) : "completed", 4063 `${outcomes}${failureSummary ? ` ${failureSummary}` : ""}` 4064 ); 4065 } 4066 } else if (!enrichmentInProgress && enrichmentStatusPanel) { 4067 enrichmentStatusPanel.hidden = true; 4068 if (enrichmentStatus) enrichmentStatus.textContent = ""; 4069 } 4070 renderMarketingContentStep(data, productsFound, { 4071 showAsNextAction: true, 4072 primary: !continuationPending && !enrichmentHasWork, 4073 }); 4074 renderActionAccessGates(); 4075 return showEnrichment; 4076 }; 4077 4078 openMarketingContentButton?.addEventListener("click", async () => { 4079 if (!isSignedIn()) { 4080 focusAccountFromGate("signin", "story"); 4081 return; 4082 } 4083 const originalLabel = openMarketingContentButton.textContent; 4084 openMarketingContentButton.disabled = true; 4085 openMarketingContentButton.textContent = t("mvp.marketing.loading", null, "Loading productsâ¦"); 4086 setMarketingStatus(""); 4087 try { 4088 const loaded = await loadFullMarketingProducts(); 4089 if (!loaded || !marketingContentState.products.length) { 4090 setMarketingStatus(t("mvp.marketing.loadFailed", null, "The full product collection could not be loaded. Try opening this dataset again from My runs."), "error"); 4091 return; 4092 } 4093 renderMarketingLabels(); 4094 renderMarketingProductSelector(); 4095 if (marketingContentWorkspace) marketingContentWorkspace.hidden = false; 4096 openMarketingContentButton.setAttribute("aria-expanded", "true"); 4097 marketingContentWorkspace?.scrollIntoView?.({ behavior: "smooth", block: "nearest" }); 4098 } catch { 4099 setMarketingStatus(t("mvp.marketing.loadFailed", null, "The full product collection could not be loaded. Try opening this dataset again from My runs."), "error"); 4100 } finally { 4101 openMarketingContentButton.textContent = originalLabel || t("mvp.marketing.open", null, "Create marketing content"); 4102 renderActionAccessGates(); 4103 } 4104 }); 4105 4106 marketingProductSelect?.addEventListener("change", () => { 4107 const index = Number(marketingProductSelect.value); 4108 marketingContentState.selectedIndex = Number.isInteger(index) && index >= 0 ? index : 0; 4109 renderSelectedMarketingProduct(); 4110 }); 4111 4112 marketingContextFields?.addEventListener("input", () => { 4113 marketingContentState.creativeOverrides = readCreativeOverrides(); 4114 clearPreparedMarketingContent(); 4115 }); 4116 4117 marketingImages?.addEventListener("change", (event) => { 4118 const input = event.target; 4119 if (!(input instanceof HTMLInputElement) || input.name !== "mvpMarketingImage") return; 4120 marketingContentState.selectedImage = input.value; 4121 clearPreparedMarketingContent(); 4122 }); 4123 4124 prepareMarketingRequestButton?.addEventListener("click", async () => { 4125 if (!authenticatedActionAllowed() || marketingContentWorkspace?.hidden) return; 4126 const currentUsage = await loadUsage(); 4127 if (!currentUsage || !authenticatedActionAllowed()) return; 4128 const sourceProduct = marketingContentState.products[marketingContentState.selectedIndex]; 4129 if (!sourceProduct) { 4130 setMarketingStatus(t("mvp.marketing.selectRequired", null, "Choose a product before preparing creative content."), "error"); 4131 return; 4132 } 4133 const product = normalizeMarketingProductContext(sourceProduct); 4134 if (!hasMarketingContext(product)) { 4135 setMarketingStatus(t("mvp.marketing.contextRequired", null, "This product does not have enough usable information to prepare creative content."), "error"); 4136 return; 4137 } 4138 const creativeOverrides = readCreativeOverrides(); 4139 marketingContentState.creativeOverrides = creativeOverrides; 4140 marketingContentState.preparedRequest = { 4141 creationType: "template", 4142 product, 4143 creativeOverrides, 4144 selectedImage: marketingContentState.selectedImage, 4145 purpose: "instagram_story", 4146 output: { preset: "story", width: 1080, height: 1920, aspectRatio: "9:16" }, 4147 instruction: "", 4148 template: "product_spotlight", 4149 }; 4150 if (window.__MVP_TEST_HOOKS__ && typeof window.__MVP_TEST_HOOKS__ === "object") { 4151 window.__MVP_TEST_HOOKS__.marketingContentRequest = marketingContentState.preparedRequest; 4152 } 4153 setMarketingStatus("Creating Instagram Storyâ¦", "prepared"); 4154 await drawMarketingStory(marketingContentState.preparedRequest); 4155 setMarketingStatus(""); 4156 }); 4157 4158 const resetPreview = () => {
4159 if (previewBody) previewBody.textContent = ""; 4160 if (previewSummary) previewSummary.textContent = ""; 4161 if (previewMore) { 4162 previewMore.textContent = ""; 4163 previewMore.hidden = true; 4164 } 4165 if (previewAccountGate) previewAccountGate.hidden = true; 4166 if (previewEmpty) { 4167 previewEmpty.textContent = ""; 4168 previewEmpty.hidden = true; 4169 } 4170 if (previewTable) previewTable.hidden = false; 4171 if (previewWrap) previewWrap.style.display = "none"; 4172 if (previewDetails) { 4173 previewDetails.hidden = true; 4174 previewDetails.open = false; 4175 } 4176 }; 4177 4178 const previewProductState = { products: [], totalCount: 0, selectedIndex: "first", searchQuery: "", authenticated: false }; 4179 4180 const toSafeHttpUrl = (value, base = "") => { 4181 const raw = String(value || "").trim(); 4182 if (!raw) return ""; 4183 try { 4184 const url = base ? new URL(raw, base) : new URL(raw); 4185 return url.protocol === "http:" || url.protocol === "https:" ? url.href : ""; 4186 } catch { 4187 return ""; 4188 } 4189 }; 4190 4191 const renderCompletedScanSource = (requestMeta) => { 4192 const source = E("mvpCompletedScanSource"); 4193 const logo = E("mvpCompletedScanLogo"); 4194 const host = E("mvpCompletedScanHost"); 4195 const link = E("mvpCompletedScanUrl"); 4196 if (!source || !logo || !host || !link) return; 4197 source.hidden = true; 4198 logo.hidden = true; 4199 logo.onerror = null; 4200 logo.removeAttribute("src"); 4201 host.textContent = ""; 4202 link.textContent = ""; 4203 link.removeAttribute("href"); 4204 const safeUrl = requestMeta?.sourceType === "url" ? toSafeHttpUrl(requestMeta.url) : ""; 4205 if (!safeUrl) return; 4206 const url = new URL(safeUrl); 4207 host.textContent = url.hostname.replace(/^www\./i, ""); 4208 link.textContent = String(requestMeta.url).trim(); 4209 link.href = safeUrl; 4210 logo.onerror = () => { logo.hidden = true; }; 4211 logo.src = `${url.origin}/favicon.ico`; 4212 logo.hidden = false; 4213 source.hidden = false; 4214 }; 4215 4216 const appendTextCell = (row, value, className = "", label = "") => { 4217 const cell = document.createElement("td"); 4218 if (className) cell.className = className; 4219 if (label) cell.dataset.label = label; 4220 if (className === "mvp-preview-name") { 4221 const text = document.createElement("span"); 4222 text.textContent = String(value || ""); 4223 text.title = String(value || ""); 4224 cell.appendChild(text); 4225 } else { 4226 cell.textContent = String(value || ""); 4227 } 4228 row.appendChild(cell); 4229 }; 4230 4231 const firstText = (...values) => { 4232 for (const value of values) { 4233 const text = String(value || "").trim(); 4234 if (text) return text; 4235 } 4236 return ""; 4237 }; 4238 4239 const productImageFor = (product) => firstText(product?.image, product?.imageUrl, product?.image_url); 4240 4241 const productUrlFor = (product) => firstText(product?.url, product?.productUrl, product?.product_url); 4242 4243 const appendProductLinkCell = (row, value) => { 4244 const cell = document.createElement("td"); 4245 cell.className = "mvp-preview-product"; 4246 cell.dataset.label = t("mvp.preview.sourceProduct", null, "Source product"); 4247 const href = toSafeHttpUrl(value); 4248 if (href) { 4249 const link = document.createElement("a"); 4250 link.href = href; 4251 link.target = "_blank"; 4252 link.rel = "noopener noreferrer"; 4253 link.textContent = t("mvp.preview.openSource", null, "Open source page"); 4254 cell.appendChild(link); 4255 } else { 4256 cell.textContent = t("mvp.preview.noLink", null, "No product link"); 4257 } 4258 row.appendChild(cell); 4259 }; 4260 4261 const imageHrefFor = (product) => { 4262 const raw = productImageFor(product); 4263 if (!raw) return ""; 4264 return toSafeHttpUrl(raw, firstText(productUrlFor(product), product?.sourcePage, product?.sourceUrl, product?.source_url)); 4265 }; 4266 4267 const appendImageCell = (row, product) => { 4268 const cell = document.createElement("td"); 4269 cell.className = "mvp-preview-image-cell"; 4270 cell.dataset.label = t("mvp.preview.image", null, "Image"); 4271 const raw = productImageFor(product); 4272 const href = imageHrefFor(product); 4273 if (href) { 4274 const link = document.createElement("a"); 4275 link.href = href; 4276 link.target = "_blank"; 4277 link.rel = "noopener noreferrer"; 4278 link.className = "mvp-preview-image-link"; 4279 link.title = t("mvp.preview.imageAvailable", null, "Image available"); 4280 const image = document.createElement("img"); 4281 image.src = href; 4282 image.alt = ""; 4283 image.loading = "lazy"; 4284 image.className = "mvp-preview-image"; 4285 image.addEventListener("error", () => { 4286 image.hidden = true; 4287 link.classList.add("mvp-preview-image-link--fallback"); 4288 }); 4289 const text = document.createElement("span"); 4290 text.className = "mvp-preview-image-link-text"; 4291 text.textContent = t("mvp.preview.imageAvailable", null, "Image available"); 4292 link.appendChild(image); 4293 link.appendChild(text); 4294 cell.appendChild(link); 4295 } else if (raw) { 4296 cell.textContent = t("mvp.preview.imageAvailable", null, "Image available"); 4297 } else { 4298 cell.classList.add("mvp-preview-image-cell--missing"); 4299 cell.textContent = t("mvp.preview.noImage", null, "No image."); 4300 } 4301 row.appendChild(cell); 4302 }; 4303 4304 const descriptionStatusFor = (product) => { 4305 const description = String(product?.description || product?.body_html || product?.bodyHtml || product?.body || "").trim(); 4306 if (!description) return "Missing"; 4307 return description.length >= 40 ? "Ready" : "Limited"; 4308 }; 4309 4310 const appendImageStatusCell = (row, product) => { 4311 const hasImage = Boolean(productImageFor(product)); 4312 const status = hasImage ? "Available" : "Missing image"; 4313 const cell = document.createElement("td"); 4314 cell.className = `mvp-preview-image-status mvp-preview-image-status--${hasImage ? "has" : "missing"}`; 4315 cell.dataset.label = "Image"; 4316 cell.textContent = status; 4317 row.appendChild(cell); 4318 }; 4319 4320 const isNoiseTagToken = (token) => { 4321 const text = String(token || "").toLowerCase(); 4322 if (!text || text.length < 4) return true; 4323 if (/^\d+$/.test(text)) return true; 4324 if (/^\d+(?:x|by|-)\d+/.test(text)) return true; 4325 if (/^\d+(?:cm|mm|m|in|inch|ft|kg|g|lb|oz)$/i.test(text)) return true; 4326 if (/^\$?\d+(?:[.,]\d+)?$/.test(text)) return true; 4327 if (/^(usd|cad|eur|gbp|cop|mxn|price|sale|new|the|and|for|with|from|product|products|producto|productos|item|items|shop|store|collections|collection|categoria|categorias|category|categories|gen|kit|badge|amps|cm|mm|inch|inches|feet|meter|meters|www|html|htm)$/.test(text)) return true; 4328 if (/^[a-z]{0,4}\d{2,}[a-z0-9-]*$/i.test(text)) return true; 4329 if (/^[a-z]+-\d+[a-z0-9-]*$/i.test(text)) return true; 4330 return false;
4331 }; 4332 4333 const tagTokensFrom = (value) => String(value || "") 4334 .toLowerCase() 4335 .split(/[^a-z0-9]+/i) 4336 .map((token) => token.trim()) 4337 .filter((token) => token && !isNoiseTagToken(token)); 4338 4339 const suggestedTagsFor = (product) => { 4340 const candidates = []; 4341 const addTokens = (items) => { 4342 for (const token of items) { 4343 if (!candidates.includes(token)) candidates.push(token); 4344 } 4345 }; 4346 addTokens(tagTokensFrom(product?.name || product?.title)); 4347 const sourceUrl = firstText(product?.sourcePage, product?.sourceUrl, product?.source_url, productUrlFor(product)); 4348 try { 4349 const parsed = new URL(sourceUrl, "https://example.invalid"); 4350 addTokens(tagTokensFrom(parsed.pathname)); 4351 } catch { 4352 addTokens(tagTokensFrom(sourceUrl)); 4353 } 4354 const canonicalTag = (token) => token.endsWith("s") && token.length > 4 ? token.slice(0, -1) : token; 4355 return candidates.filter((token, index, allTokens) => { 4356 if (allTokens.slice(0, index).some((other) => canonicalTag(other) === canonicalTag(token))) return false; 4357 return !allTokens.some((left, leftIndex) => allTokens.some((right, rightIndex) => ( 4358 leftIndex !== rightIndex 4359 && leftIndex !== index 4360 && rightIndex !== index 4361 && `${left}${right}` === token 4362 ))); 4363 }).slice(0, 5); 4364 }; 4365 4366 const appendSuggestedTagsCell = (row, product) => { 4367 const tags = suggestedTagsFor(product); 4368 const cell = document.createElement("td"); 4369 cell.className = "mvp-preview-tags"; 4370 if (tags.length) cell.dataset.i18nSkip = ""; 4371 cell.dataset.label = "Suggested tags"; 4372 cell.textContent = tags.length ? tags.join(", ") : "No suggested tags"; 4373 row.appendChild(cell); 4374 }; 4375 4376 const normalizedPreviewSearch = () => String(previewProductState.searchQuery || "").trim().toLocaleLowerCase(); 4377 4378 const accessiblePreviewProducts = () => previewProductState.products; 4379 4380 const matchingPreviewEntries = () => { 4381 const query = normalizedPreviewSearch(); 4382 return accessiblePreviewProducts() 4383 .map((product, index) => ({ product, index, id: productStableIdentifier(product, index) })) 4384 .filter(({ product }) => { 4385 if (!query) return true; 4386 const context = normalizeMarketingProductContext(product); 4387 return [context.name, context.sku].some((value) => String(value || "").toLocaleLowerCase().includes(query)); 4388 }); 4389 }; 4390 4391 const renderPreviewSelect = () => { 4392 if (!previewProductSelect) return; 4393 const matches = matchingPreviewEntries(); 4394 const query = normalizedPreviewSearch(); 4395 const selectedStillMatches = previewProductState.selectedIndex !== "first" 4396 && matches.some(({ id }) => id === previewProductState.selectedIndex); 4397 if (!selectedStillMatches) previewProductState.selectedIndex = "first"; 4398 4399 previewProductSelect.textContent = ""; 4400 const defaultOption = document.createElement("option"); 4401 defaultOption.value = "first"; 4402 const defaultCount = 10; 4403 const selector = previewProductSelect.closest(".mvp-preview-product-selector"); 4404 if (selector) selector.hidden = false; 4405 defaultOption.textContent = query 4406 ? t(`mvp.preview.showFirst${defaultCount}Matches`, null, `Show first ${defaultCount} matching products`) 4407 : t(`mvp.preview.showFirst${defaultCount}`, null, `Show first ${defaultCount} products`); 4408 previewProductSelect.appendChild(defaultOption); 4409 for (const { product, id } of matches) { 4410 const option = document.createElement("option"); 4411 option.value = id; 4412 option.textContent = productOptionLabel(product); 4413 previewProductSelect.appendChild(option); 4414 } 4415 previewProductSelect.value = previewProductState.selectedIndex; 4416 previewProductSelect.disabled = matches.length === 0; 4417 }; 4418 4419 const renderPreviewRows = () => { 4420 resetPreview(); 4421 if (!previewBody || !accessiblePreviewProducts().length) return; 4422 const matches = matchingPreviewEntries(); 4423 const query = normalizedPreviewSearch(); 4424 const selectedEntry = matches.find(({ id }) => id === previewProductState.selectedIndex); 4425 const defaultCount = 10; 4426 const products = previewProductState.selectedIndex === "first" || !selectedEntry 4427 ? matches.slice(0, defaultCount).map(({ product }) => product) 4428 : [selectedEntry.product]; 4429 const renderedCount = products.length; 4430 const total = Number(previewProductState.totalCount || 0); 4431 if (previewSummary) { 4432 previewSummary.textContent = query 4433 ? t("mvp.preview.searchSummary", { shown: renderedCount, matches: matches.length }, `Showing ${renderedCount} of ${matches.length} matching products`) 4434 : !previewProductState.authenticated 4435 ? t("mvp.preview.anonymousSummary", { shown: renderedCount, total }, `Showing ${renderedCount} of ${total} collected products`) 4436 : total > renderedCount 4437 ? `Product preview â showing ${renderedCount} of ${total} collected products` 4438 : `Product preview â showing ${renderedCount} of ${total || renderedCount} collected ${renderedCount === 1 ? "product" : "products"}`; 4439 } 4440 const hiddenProductCount = Math.max(0, total - accessiblePreviewProducts().length); 4441 if (previewMore) { 4442 previewMore.textContent = hiddenProductCount 4443 ? t("mvp.preview.moreFound", { count: hiddenProductCount }, `${hiddenProductCount} more products found`) 4444 : ""; 4445 previewMore.hidden = hiddenProductCount <= 0; 4446 } 4447 if (previewAccountGate) previewAccountGate.hidden = true; 4448 if (!matches.length) { 4449 if (previewEmpty) { 4450 previewEmpty.textContent = t("mvp.preview.noMatches", { query: String(previewProductState.searchQuery || "").trim() }, `No products match "${String(previewProductState.searchQuery || "").trim()}".`); 4451 previewEmpty.hidden = false;
4452 } 4453 if (previewTable) previewTable.hidden = true; 4454 } 4455 for (const product of products) { 4456 const context = normalizeMarketingProductContext(product); 4457 const row = document.createElement("tr"); 4458 const productIndex = previewProductState.products.indexOf(product); 4459 const productId = productStableIdentifier(product, Math.max(0, productIndex)); 4460 row.tabIndex = 0; 4461 row.title = context.name || "Select product"; 4462 const selectRow = () => { 4463 previewProductState.selectedIndex = productId; 4464 const marketingIndex = marketingContentState.products.indexOf(product); 4465 if (marketingIndex >= 0) { 4466 marketingContentState.selectedIndex = marketingIndex; 4467 if (marketingProductSelect) marketingProductSelect.value = String(marketingIndex); 4468 } 4469 previewBody?.querySelectorAll("tr").forEach((candidate) => candidate.setAttribute("aria-selected", candidate === row ? "true" : "false")); 4470 }; 4471 row.addEventListener("click", selectRow); 4472 row.addEventListener("keydown", (event) => { 4473 if (event.key === "Enter" || event.key === " ") { 4474 event.preventDefault(); 4475 selectRow(); 4476 } 4477 }); 4478 appendImageCell(row, product); 4479 appendTextCell(row, context.name, "mvp-preview-name", t("mvp.preview.name", null, "Product name")); 4480 appendTextCell(row, context.sku, "mvp-preview-sku", t("mvp.preview.sku", null, "SKU")); 4481 appendTextCell(row, context.price, "mvp-preview-price", t("mvp.preview.price", null, "Price")); 4482 appendProductLinkCell(row, context.productUrl || productUrlFor(product)); 4483 if (explorerMode) { 4484 appendTextCell(row, String(explorerPopulatedFieldCount(product)), "mvp-explorer-field-count", "Populated fields"); 4485 const useCell = document.createElement("td"); 4486 useCell.className = "mvp-explorer-use"; 4487 const use = document.createElement("a"); 4488 const productUrl = context.productUrl || productUrlFor(product); 4489 use.href = `/app-mvp2?pipeline=instagram-story&url=${encodeURIComponent(productUrl || "")}`; 4490 use.textContent = "Use product"; 4491 useCell.append(use); row.append(useCell); 4492 } 4493 previewBody.appendChild(row); 4494 } 4495 if (previewDetails) { 4496 previewDetails.hidden = false; 4497 previewDetails.open = true; 4498 } 4499 if (previewWrap) previewWrap.style.display = "block"; 4500 }; 4501 4502 const renderPreview = (products, totalCount, authenticated = isSignedIn()) => { 4503 const selectedIdentifier = previewProductState.selectedIndex; 4504 previewProductState.products = Array.isArray(products) ? products : []; 4505 previewProductState.totalCount = Number(totalCount || previewProductState.products.length); 4506 previewProductState.authenticated = authenticated; 4507 const selectionStillExists = selectedIdentifier !== "first" 4508 && previewProductState.products.some((product, index) => productStableIdentifier(product, index) === selectedIdentifier); 4509 previewProductState.selectedIndex = selectionStillExists ? selectedIdentifier : "first"; 4510 previewProductState.searchQuery = ""; 4511 if (previewSearch) { 4512 previewSearch.value = ""; 4513 previewSearch.placeholder = t("mvp.preview.searchPlaceholder", null, "Search products by name or SKUâ¦"); 4514 previewSearch.setAttribute("aria-label", t("mvp.preview.searchLabel", null, "Search products")); 4515 } 4516 setText("mvpPreviewSearchLabel", t("mvp.preview.searchLabel", null, "Search products")); 4517 setText("mvpPreviewProductSelectLabel", t("mvp.marketing.chooseProduct", null, "Choose a product")); 4518 renderPreviewSelect(); 4519 renderPreviewRows(); 4520 }; 4521 4522 const renderResultAccessState = (authenticated) => { 4523 previewProductState.authenticated = authenticated; 4524 renderPreviewSelect(); 4525 renderPreviewRows(); 4526 renderDownloadLinks(lastMvpResult?.artifacts || {}, authenticated); 4527 if (productReviewSection) productReviewSection.hidden = true; 4528 if (downloadsPanel) downloadsPanel.hidden = false; 4529 if (cleanupPanel) cleanupPanel.hidden = lastMvpResultViewModel?.productsCollected <= 0; 4530 renderActionAccessGates(); 4531 if (completedSignupCta) completedSignupCta.hidden = authenticated || !completedSignupCtaEligible; 4532 if (lastMvpResultViewModel) renderCompletedResultLayout(lastMvpResultViewModel, lastMvpResult); 4533 }; 4534 4535 previewSearch?.addEventListener("input", () => { 4536 previewProductState.searchQuery = previewSearch.value; 4537 renderPreviewSelect(); 4538 renderPreviewRows(); 4539 }); 4540 4541 previewProductSelect?.addEventListener("change", () => { 4542 previewProductState.selectedIndex = previewProductSelect.value; 4543 renderPreviewRows(); 4544 }); 4545 4546 E("mvpPreviewCreateAccount")?.addEventListener("click", () => { 4547 focusAccountFromGate("signup", "catalog"); 4548 }); 4549 4550 E("mvpPreviewSignIn")?.addEventListener("click", () => { 4551 focusAccountFromGate("signin", "catalog"); 4552 }); 4553 4554 E("mvpCompletedSignupCreate")?.addEventListener("click", () => { 4555 focusAccountFromGate("signup", "catalog"); 4556 }); 4557 4558 E("mvpCompletedSignupSignIn")?.addEventListener("click", () => { 4559 focusAccountFromGate("signin", "download"); 4560 }); 4561 4562 // The canonical Next actions section owns the real workflow controls. The 4563 // former preview-derived suggestion list is intentionally no longer rendered.
4564 const renderNextActions = () => {}; 4565 4566 const renderTopIssues = (issues, emptyText = "No warnings or blocking errors.") => { 4567 const list = E("mvpTopIssues"); 4568 if (!list) return; 4569 const panel = list.closest(".mvp-top-issues"); 4570 const details = E("mvpReviewDetails"); 4571 list.textContent = ""; 4572 const hasIssues = Array.isArray(issues) && issues.length > 0; 4573 if (!hasIssues && !emptyText) { 4574 if (panel) panel.hidden = true; 4575 if (details) { 4576 details.hidden = true; 4577 } 4578 return; 4579 } 4580 if (panel) panel.hidden = false; 4581 if (details) { 4582 details.hidden = false; 4583 } 4584 const items = hasIssues ? issues : [emptyText]; 4585 for (const issue of items.slice(0, 1)) { 4586 const item = document.createElement("li"); 4587 item.textContent = String(issue || "").trim() || "Review the generated files."; 4588 list.appendChild(item); 4589 } 4590 }; 4591 4592 const catalogOpportunityHiddenState = new Map(); 4593 const renderCatalogOpportunityPreview = (data, requestMeta, viewModel) => { 4594 const discovery = viewModel.discovery || {}; 4595 const potential = catalogPotentialFromResponse(data) || {}; 4596 const collected = potential.dataCollected || potential.data_collected || {}; 4597 const coverage = data?.customerSummary?.discovery_coverage || data?.discoveryCoverage || {}; 4598 const candidates = Number(collected.relevantCandidatePages ?? collected.relevant_candidate_pages ?? discovery.candidatePagesFound ?? 0); 4599 const scanned = Number(collected.pagesScanned ?? collected.pages_scanned ?? discovery.pagesScanned ?? 0); 4600 const blocked = /blocked|403|429|needs_capture|capture[_ -]required/i.test([ 4601 data?.status, data?.outcome, data?.customerSummary?.status, 4602 data?.reasonCode, data?.reason_code, data?.customerSummary?.reason_code, 4603 coverage.stopped_reason, coverage.stoppedReason, discovery.stoppedReason, 4604 potential.scanCoverage?.stoppedReason, 4605 ].join(" ")); 4606 if (!requestMeta?.salesAgentClaimPreview || viewModel.productsCollected !== 0 || candidates < 5 || scanned < 1 || blocked) return false; 4607 if (stateTitle) stateTitle.textContent = "Catalog structure detected"; 4608 if (resultStatus) { resultStatus.textContent = "Catalog diagnostic"; resultStatus.dataset.state = "ready"; } 4609 if (resultBanner) { 4610 resultBanner.textContent = `We found ${candidates} likely catalog or brand pages, but no public product records were available in this scan.`; 4611 resultBanner.hidden = false; 4612 } 4613 if (stateMessage) { 4614 stateMessage.textContent = "This often means detailed product data is supplied through another catalog, spreadsheet, PDF, supplier website, or B2B workflow."; 4615 stateMessage.hidden = false; 4616 } 4617 if (stateHint) { 4618 stateHint.textContent = "Send us one supplier catalog and we can show how it converts into structured ecommerce-ready product data."; 4619 stateHint.hidden = false; 4620 } 4621 for (const id of ["mvpPreviewDetails", "mvpNextActions", "mvpEnrichmentAction", "mvpEnrichmentStatusPanel", "mvpMarketingContentStep", "mvpMarketingContentWorkspace", "mvpCleanupPanel", "mvpDownloadsPanel", "mvpDatasetActions", "mvpDatasetRecommendation", "mvpProductDatasetContent", "mvpOverviewEnrichment"]) { 4622 const element = E(id); 4623 if (element) { 4624 catalogOpportunityHiddenState.set(element, element.hidden); 4625 element.hidden = true; 4626 } 4627 } 4628 return true; 4629 }; 4630 4631 const renderCompletedPackage = (data, requestMeta, startedAt, completionEvent = "mvp_ingest_completed") => { 4632 if (!isForeground(requestMeta)) return; 4633 for (const [element, hidden] of catalogOpportunityHiddenState) element.hidden = hidden; 4634 catalogOpportunityHiddenState.clear(); 4635 const renderStartedAt = performance.now(); 4636 lastCompletedPackage = { data, requestMeta }; 4637 if (!requestMeta?.salesAgentClaimPreview && !isSignedIn() && requestMeta?.sourceType === "url" && data?.runId && data?.ok !== false && data?.discoveryJob?.state !== "discovering") { 4638 anonymousCompletedRunId = String(data.runId); 4639 try { 4640 window.localStorage?.setItem(ANONYMOUS_COMPLETED_RUN_STORAGE_KEY, JSON.stringify({
4641 runId: anonymousCompletedRunId, 4642 sourceUrl: requestMeta?.url || data?.sourceUrl || "", 4643 host: requestMeta?.host || data?.host || "", 4644 status: "completed", 4645 })); 4646 } catch {} 4647 } 4648 setScanActive(["queued", "running", "discovering", "processing"].includes(String(data?.discoveryJob?.state || "").toLowerCase()), { keepIntentHidden: true }); 4649 if (startAnotherScanButton) startAnotherScanButton.hidden = false; 4650 renderCheckoutRetentionMessage(); 4651 const statusEl = E("mvpStatus"); 4652 const metricProducts = E("mvpMetricProducts"); 4653 const metricImportReady = E("mvpMetricImportReady"); 4654 const metricReviewRows = E("mvpMetricReviewRows"); 4655 const metricBlocked = E("mvpMetricBlocked"); 4656 const summaryMetricImportReady = E("mvpSummaryMetricImportReady"); 4657 const summaryMetricReviewRows = E("mvpSummaryMetricReviewRows"); 4658 const summaryMetricBlocked = E("mvpSummaryMetricBlocked"); 4659 const summaryMetricWarnings = E("mvpSummaryMetricWarnings"); 4660 const summaryMetricRejected = E("mvpSummaryMetricRejected"); 4661 const metricBlockingIssues = E("mvpMetricBlockingIssues"); 4662 const metricWarnings = E("mvpMetricWarnings"); 4663 const metricRejected = E("mvpMetricRejected"); 4664 const summary = data?.summary || {}; 4665 const customerSummary = data?.customerSummary || {}; 4666 const report = data?.report || {}; 4667 const viewModel = deriveMvpResultViewModel(data, requestMeta); 4668 const productsFound = viewModel.productsCollected; 4669 const importReadyProducts = viewModel.importableProducts; 4670 const blockedProducts = Number(summary.blockedProducts ?? customerSummary.blocked_products ?? Math.max(0, productsFound - importReadyProducts)); 4671 const rejectedRows = Number(summary.rejectedRows ?? customerSummary.rejected_rows ?? 0); 4672 const warningCount = Number(summary.warningCount ?? customerSummary.warning_count ?? 0); 4673 const { missingImages, weakDescriptions } = resultContentMetrics(data); 4674 const rowsNeedingReview = viewModel.productsNeedingReview; 4675 const mainIssues = Array.isArray(summary?.topIssues) 4676 ? summary.topIssues 4677 : Array.isArray(customerSummary?.main_issues) ? customerSummary.main_issues : []; 4678 const hasSuccessfulExport = productsFound > 0 && importReadyProducts > 0; 4679 const hasBlockingErrors = blockedProducts > 0 || rejectedRows > 0; 4680 const blockingIssueCount = Number(summary.blockingIssueCount ?? customerSummary.blocking_issue_count ?? (hasBlockingErrors ? mainIssues.length : 0)); 4681 const resultState = !hasSuccessfulExport 4682 ? "failure" 4683 : hasBlockingErrors 4684 ? "warning" 4685 : "success"; 4686 setStateCopy(resultState); 4687 const summaryMessage = String(customerSummary?.recommendation || data?.message || "").trim(); 4688 const hasReviewIssues = hasBlockingErrors || warningCount > 0 || mainIssues.length > 0; 4689 setResultSourceLabel(requestMeta); 4690 if (stateTitle && hasSuccessfulExport) stateTitle.textContent = "Product dataset ready for review"; 4691 if (resultStatus) { 4692 resultStatus.textContent = blockedProducts > 0 ? "Rows require review" : "Dataset collected"; 4693 resultStatus.dataset.state = blockedProducts > 0 ? "blocked" : "ready"; 4694 } 4695 if (resultBanner) { 4696 resultBanner.textContent = !hasSuccessfulExport 4697 ? STATE_COPY.failure.banner 4698 : ""; 4699 resultBanner.hidden = hasSuccessfulExport; 4700 } 4701 if (stateMessage) { 4702 stateMessage.textContent = !hasSuccessfulExport 4703 ? summaryMessage 4704 : blockedProducts > 0 4705 ? "Some records require review before using the current dataset for an ecommerce import." 4706 : ""; 4707 stateMessage.hidden = !stateMessage.textContent; 4708 } 4709 if (issueSummaryTitle) issueSummaryTitle.textContent = hasBlockingErrors ? "Blocking issues" : "Content warnings"; 4710 if (stateHint) { 4711 stateHint.textContent = ""; 4712 stateHint.hidden = true; 4713 } 4714 if (statusEl) { 4715 statusEl.textContent = ""; 4716 statusEl.hidden = true; 4717 } 4718 setImportQualityMetrics({ productsFound, importReadyProducts, warningCount, blockingIssueCount, blockedProducts }); 4719 if (metricProducts) metricProducts.textContent = String(productsFound); 4720 if (metricImportReady) metricImportReady.textContent = String(importReadyProducts); 4721 if (metricReviewRows) metricReviewRows.textContent = String(rowsNeedingReview); 4722 if (metricBlocked) metricBlocked.textContent = String(blockedProducts); 4723 if (summaryMetricImportReady) summaryMetricImportReady.textContent = String(importReadyProducts); 4724 if (summaryMetricReviewRows) summaryMetricReviewRows.textContent = String(rowsNeedingReview); 4725 if (summaryMetricBlocked) summaryMetricBlocked.textContent = String(blockedProducts); 4726 if (metricBlockingIssues) metricBlockingIssues.textContent = String(blockingIssueCount); 4727 if (metricWarnings) metricWarnings.textContent = String(warningCount); 4728 setText("mvpMetricMissingImages", missingImages); 4729 setText("mvpMetricMissingSkus", viewModel.productsWithoutSkus); 4730 setText("mvpMetricWeakDescriptions", weakDescriptions); 4731 if (metricRejected) metricRejected.textContent = String(rejectedRows); 4732 if (summaryMetricWarnings) summaryMetricWarnings.textContent = String(warningCount); 4733 if (summaryMetricRejected) summaryMetricRejected.textContent = String(rejectedRows); 4734 renderTopIssues( 4735 mainIssues, 4736 hasReviewIssues ? (hasBlockingErrors ? "Blocking issues are in issues.csv." : "Content warnings are in issues.csv.") : "" 4737 ); 4738 const discovery = viewModel.discovery; 4739 const discoveryInProgress = discovery?.enabled === true && discovery.pagesRemaining > 0; 4740 const authenticated = data?.auth?.authenticated === true || data?.usage?.authenticated === true || isSignedIn(); 4741 renderPreview(Array.isArray(data?.products) && data.products.length ? data.products : data?.preview, productsFound, authenticated); 4742 renderNextActions(data?.preview); 4743 renderDiscoveryCoverage(discovery); 4744 renderExplorerPages(discovery); 4745 if (explorerMode) { 4746 if (reviewPreviewSlot && successPanel && reviewPreviewSlot.parentElement !== successPanel) successPanel.insertBefore(reviewPreviewSlot, explorerTabs?.nextSibling || null); 4747 if (discoveryCoveragePanel && successPanel && discoveryCoveragePanel.parentElement !== successPanel) successPanel.append(discoveryCoveragePanel); 4748 if (explorerTabs) explorerTabs.hidden = false;
4749 setExplorerTab("products"); 4750 } 4751 if (continueScanProgress && data?.discoveryJob?.state === "discovering") { 4752 const durableCoverage = data?.customerSummary?.discovery_coverage || data?.discoveryCoverage || {}; 4753 const pagesAttempted = firstFiniteCount( 4754 durableCoverage?.pages_attempted, 4755 durableCoverage?.pagesAttempted, 4756 data?.discoveryJob?.pagesAttempted, 4757 ) || 0; 4758 const pagesRemaining = firstFiniteCount( 4759 durableCoverage?.pages_remaining, 4760 durableCoverage?.pagesRemaining, 4761 ) || 0; 4762 const pagesDiscovered = firstFiniteCount( 4763 durableCoverage?.candidate_pages_found, 4764 durableCoverage?.pages_discovered, 4765 durableCoverage?.pagesDiscovered, 4766 data?.discoveryJob?.pagesDiscovered, 4767 ) || pagesAttempted + pagesRemaining; 4768 setScanProgress(pagesAttempted, pagesDiscovered); 4769 continueScanProgress.hidden = false; 4770 if (continueScanLoading) continueScanLoading.hidden = false; 4771 } else if (requestMeta?.sourceType === "url" && continueScanProgress && !continueScanProgress.hidden) { 4772 setScanProgress(1, 1); 4773 continueScanProgress.hidden = true; 4774 if (continueScanLoading) continueScanLoading.hidden = true; 4775 if (scanReassurance) scanReassurance.hidden = true; 4776 } 4777 const catalogPotential = catalogPotentialFromResponse(data); 4778 renderCatalogPotential(catalogPotential, discovery, viewModel); 4779 renderIncompleteScanWarning(discovery, productsFound); 4780 const showEnrichment = renderRecommendedActions({ 4781 data, 4782 productsFound, 4783 viewModel, 4784 }); 4785 storeLastMvpResult(data, requestMeta); 4786 log(completionEvent, { ...requestMeta, requestId: data?.requestId || null, summary, report, elapsedMs: Date.now() - startedAt }); 4787 if (successPanel) successPanel.style.display = "grid"; 4788 document.querySelector(".mvp-account-card")?.classList.add("mvp-account-card--result"); 4789 setNoReportMessageVisible(false); 4790 renderDownloadLinks(data?.artifacts || {}, authenticated); 4791 setResultWorkflowStage({ 4792 discovery, 4793 automaticFullCatalog: Boolean(data?.discoveryJob?.id), 4794 hasCatalogOpportunity: Boolean(catalogPotential), 4795 requestMeta, 4796 hasSuccessfulExport, 4797 hasPreview: Array.isArray(data?.preview) && data.preview.length > 0, 4798 showRecommended: blockedProducts > 0, 4799 showEnrichment, 4800 showReview: hasReviewIssues, 4801 finalTitle: stateTitle?.textContent || "", 4802 finalStatus: resultStatus?.textContent || "", 4803 finalStatusState: resultStatus?.dataset?.state || "", 4804 finalBanner: resultBanner?.textContent || "", 4805 finalMessage: stateMessage?.textContent || "", 4806 }); 4807 renderCompletedScanSource(requestMeta); 4808 renderMvpResultViewModel(viewModel, data); 4809 renderResultAccessState(authenticated); 4810 renderCatalogOpportunityPreview(data, requestMeta, viewModel); 4811 setDetails(JSON.stringify({ 4812 requestId: data?.requestId || null, 4813 status: data?.status, 4814 report, 4815 summary, 4816 customerSummary, 4817 artifacts: data?.artifacts || {}, 4818 usage: data?.usage || null, 4819 message: data?.message || "", 4820 }, null, 2)); 4821 const frontendRenderMs = Math.round(performance.now() - renderStartedAt); 4822 perfPhase("frontend_render", frontendRenderMs, { requestId: data?.requestId || null, runId: data?.runId || null }); 4823 if (requestMeta?.perf) { 4824 requestMeta.perf.frontendRenderMs = frontendRenderMs; 4825 requestMeta.perf.uiRenderedAtMs = Date.now(); 4826 log("mvp_run_complete", { requestId: data?.requestId || null, runId: data?.runId || null, ...requestMeta.perf, totalMs: Date.now() - startedAt }); 4827 } 4828 if (continueScanStatus) continueScanStatus.hidden = true; 4829 }; 4830 4831 function mvpDeliveryError(code, message) { 4832 const error = new Error(message); 4833 error.code = code; 4834 return error; 4835 } 4836 4837 async function waitForMvpUrlJob(jobId, requestMeta, startedAt, maxWaitMs = 20 * 60 * 1000, runId = "", statusUrl = "", requestId = "", authenticatedRequest = isSignedIn(), onStatus = null, showGlobalStatus = true) { 4838 const statusEl = E("mvpStatus"); 4839 const configuredPollDelayMs = Number(globalThis.__MVP_JOB_POLL_DELAY_MS__); 4840 const pollDelayMs = Number.isFinite(configuredPollDelayMs) && configuredPollDelayMs >= 0 4841 ? configuredPollDelayMs 4842 : 2000; 4843 const requestTimeoutMs = 15_000; 4844 const maxConsecutiveReadFailures = 5; 4845 const startedPollingAt = Date.now(); 4846 const resolvedStatusUrl = statusUrl || `/api/jobs/${encodeURIComponent(jobId)}`; 4847 const pollingFetch = authenticatedRequest 4848 ? authenticatedFetch 4849 : (url, options) => fetch(url, options);
4850 let consecutiveStatusReadFailures = 0; 4851 let consecutiveResultReadFailures = 0; 4852 let previousStatus = ""; 4853 while (Date.now() - startedPollingAt < maxWaitMs) { 4854 assertForeground(requestMeta); 4855 let res; 4856 let payload; 4857 const controller = new AbortController(); 4858 const timeout = setTimeout(() => controller.abort(), requestTimeoutMs); 4859 try { 4860 res = await pollingFetch(resolvedStatusUrl, { 4861 credentials: "same-origin", 4862 signal: controller.signal, 4863 }, { endpointType: "job_status", requestId, runId, jobId }); 4864 payload = await parseResponsePayload(res); 4865 } catch (err) { 4866 consecutiveStatusReadFailures += 1; 4867 if (consecutiveStatusReadFailures >= maxConsecutiveReadFailures) { 4868 const failure = mvpDeliveryError("status_read_failed", err?.message || "The job finished remotely, but its status could not be read."); 4869 Object.assign(failure, { requestId, runId, jobId, endpointType: "job_status" }); 4870 throw failure; 4871 } 4872 await new Promise((resolve) => setTimeout(resolve, pollDelayMs)); 4873 continue; 4874 } finally { 4875 clearTimeout(timeout); 4876 } 4877 if (!res.ok || payload?.ok === false) { 4878 if (res.status === 401) throw responseError(res, payload, "Job access is unavailable.", { endpointType: "job_status", requestId, runId, jobId, authenticatedRequest }); 4879 consecutiveStatusReadFailures += 1; 4880 if (consecutiveStatusReadFailures >= maxConsecutiveReadFailures) { 4881 const failure = responseError(res, payload, `Job status request failed (${res.status}).`, { endpointType: "job_status", requestId, runId, jobId, reasonCode: "status_read_failed" }); 4882 failure.code = "status_read_failed"; 4883 throw failure; 4884 } 4885 await new Promise((resolve) => setTimeout(resolve, pollDelayMs)); 4886 continue; 4887 } 4888 consecutiveStatusReadFailures = 0; 4889 const job = payload?.job || {}; 4890 const status = String(job.status || payload?.status || "").toLowerCase(); 4891 if (status !== previousStatus) { 4892 const createdAtMs = Date.parse(job.createdAt || ""); 4893 const jobStartedAtMs = Date.parse(job.startedAt || ""); 4894 const jobFinishedAtMs = Date.parse(job.finishedAt || ""); 4895 requestMeta.perf ||= {}; 4896 if (status === "queued" && !requestMeta.perf.jobQueuedAtMs) requestMeta.perf.jobQueuedAtMs = Date.now(); 4897 if (Number.isFinite(jobStartedAtMs)) { 4898 requestMeta.perf.jobStartedAtMs = jobStartedAtMs; 4899 if (Number.isFinite(createdAtMs)) requestMeta.perf.queueWaitMs = Math.max(0, jobStartedAtMs - createdAtMs); 4900 } 4901 if (Number.isFinite(jobFinishedAtMs)) { 4902 requestMeta.perf.jobCompletedAtMs = jobFinishedAtMs; 4903 if (Number.isFinite(jobStartedAtMs)) requestMeta.perf.workerExecutionMs = Math.max(0, jobFinishedAtMs - jobStartedAtMs); 4904 } 4905 log("job_status_change", { requestId, runId, jobId, status, queueWaitMs: requestMeta.perf.queueWaitMs, workerExecutionMs: requestMeta.perf.workerExecutionMs }); 4906 previousStatus = status; 4907 } 4908 assertForeground(requestMeta); 4909 if (typeof onStatus === "function") await onStatus(status, job, payload); 4910 assertForeground(requestMeta); 4911 if (status === "completed" || status === "succeeded") { 4912 const result = job?.result?.stdoutJson || payload?.result?.stdoutJson; 4913 // Worker stdout can carry only a compact preview. The durable result has 4914 // the complete `products` array used by every user's product selector. 4915 const productsFound = Number(result?.summary?.productsFound || 0); 4916 const compactPreviewCanCoverCatalog = productsFound <= ANONYMOUS_PRODUCT_LIMIT; 4917 if (result?.ok === true && (Array.isArray(result?.products) || compactPreviewCanCoverCatalog)) return result; 4918 if (result?.ok === false && !result?._truncated) { 4919 const failure = new Error(result?.error || "The MVP run failed."); 4920 Object.assign(failure, { requestId, runId, jobId, endpointType: "job_status" }); 4921 throw failure; 4922 } 4923 if (runId) { 4924 const resultFetchStartedAt = performance.now(); 4925 let resultRes; 4926 let durableResult; 4927 const resultController = new AbortController(); 4928 const resultTimeout = setTimeout(() => resultController.abort(), requestTimeoutMs); 4929 try { 4930 resultRes = await pollingFetch(`/api/mvp/runs/${encodeURIComponent(runId)}/result`, { 4931 credentials: "same-origin", 4932 signal: resultController.signal, 4933 }
4933, { endpointType: "run_result", requestId, runId, jobId }); 4934 durableResult = await parseResponsePayload(resultRes); 4935 } catch (err) { 4936 consecutiveResultReadFailures += 1; 4937 if (consecutiveResultReadFailures >= maxConsecutiveReadFailures) { 4938 const failure = mvpDeliveryError("result_unavailable", err?.message || "The job completed, but its import package is temporarily unavailable."); 4939 Object.assign(failure, { requestId, runId, jobId, endpointType: "run_result" }); 4940 throw failure; 4941 } 4942 await new Promise((resolve) => setTimeout(resolve, pollDelayMs)); 4943 continue; 4944 } finally { 4945 clearTimeout(resultTimeout); 4946 } 4947 if (resultRes.ok && durableResult?.ok) { 4948 requestMeta.perf ||= {}; 4949 requestMeta.perf.resultFetchMs = Math.round(performance.now() - resultFetchStartedAt); 4950 requestMeta.perf.resultLoadedAtMs = Date.now(); 4951 perfPhase("result_fetch", requestMeta.perf.resultFetchMs, { requestId, runId, jobId }); 4952 return durableResult; 4953 } 4954 if (resultRes.status === 401) throw responseError(resultRes, durableResult, "Result access is unavailable.", { endpointType: "run_result", requestId, runId, jobId, authenticatedRequest }); 4955 if (durableResult?.status === "failed" || durableResult?.reasonCode === "result_failed") { 4956 const failure = responseError(resultRes, durableResult, "The worker could not complete this import package.", { endpointType: "run_result", requestId, runId, jobId, reasonCode: "result_failed" }); 4957 failure.code = "result_failed"; 4958 throw failure; 4959 } 4960 if ((resultRes.status === 202 || resultRes.status === 409) && durableResult?.ok !== true) { 4961 consecutiveResultReadFailures = 0; 4962 if (showGlobalStatus && statusEl) { 4963 statusEl.textContent = "Finalizing your import packageâ¦"; 4964 statusEl.hidden = false; 4965 } 4966 await new Promise((resolve) => setTimeout(resolve, pollDelayMs)); 4967 continue; 4968 } 4969 consecutiveResultReadFailures += 1; 4970 if (consecutiveResultReadFailures < maxConsecutiveReadFailures) { 4971 await new Promise((resolve) => setTimeout(resolve, pollDelayMs)); 4972 continue; 4973 } 4974 const failure = responseError(resultRes, durableResult, "The job completed, but its import package is temporarily unavailable.", { endpointType: "run_result", requestId, runId, jobId, reasonCode: "result_unavailable" }); 4975 failure.code = "result_unavailable"; 4976 throw failure; 4977 } 4978 const failure = mvpDeliveryError("result_unavailable", "The job completed, but its import package is unavailable."); 4979 Object.assign(failure, { requestId, runId, jobId, endpointType: "run_result" }); 4980 throw failure; 4981 } 4982 if (["failed", "cancelled", "enqueue_failed", "stale", "error"].includes(status)) { 4983 const failure = new Error(job.error || payload?.error || `Job ${status}.`); 4984 Object.assign(failure, { requestId, runId, jobId, endpointType: "job_status", httpStatus: res.status }); 4985 throw failure; 4986 } 4987 if (showGlobalStatus && statusEl) { 4988 const elapsed = Math.max(1, Math.round((Date.now() - startedAt) / 1000)); 4989 statusEl.textContent = `Generating import package in the background (${elapsed}s)â¦`; 4990 statusEl.hidden = false; 4991 } 4992 await new Promise((resolve) => setTimeout(resolve, pollDelayMs)); 4993 } 4994 log("mvp_ingest_job_timeout", { ...requestMeta, requestId, runId, jobId, endpointType: "job_status", elapsedMs: Date.now() - startedAt }); 4995 const failure = mvpDeliveryError("poll_timeout", "Timed out waiting for the URL run to finish."); 4996 Object.assign(failure, { requestId, runId, jobId, endpointType: "job_status" }); 4997 throw failure; 4998 } 4999 5000 const finishDiscoveryFromDurableCoverage = (data) => { 5001 const coverage = data?.customerSummary?.discovery_coverage || data?.discoveryCoverage || null; 5002 const pagesRemaining = firstFiniteCount(coverage?.pages_remaining, coverage?.pagesRemaining); 5003 if (data?.discoveryJob?.state !== "discovering" || pagesRemaining === null || pagesRemaining > 0) return data; 5004 return { ...data, discoveryJob: { ...data.discoveryJob, state: "complete", terminal: true } }; 5005 }; 5006 5007 const waitForFullCatalogChain = async (data, requestMeta, startedAt) => { 5008 assertForeground(requestMeta);
5009 let current = finishDiscoveryFromDurableCoverage(data); 5010 let lastPolledJobId = ""; 5011 const configuredPollDelayMs = Number(globalThis.__MVP_JOB_POLL_DELAY_MS__); 5012 const pollDelayMs = Number.isFinite(configuredPollDelayMs) && configuredPollDelayMs >= 0 5013 ? configuredPollDelayMs 5014 : 2000; 5015 const readPersistedRun = async (runId, currentJobId) => { 5016 const resultFetch = isSignedIn() 5017 ? authenticatedFetch 5018 : (url, options) => fetch(url, options); 5019 const response = await resultFetch(`/api/mvp/runs/${encodeURIComponent(runId)}/result`, { 5020 credentials: "same-origin", 5021 }, { endpointType: "run_result", runId, jobId: currentJobId || "" }); 5022 const result = await parseResponsePayload(response); 5023 assertForeground(requestMeta); 5024 if (!response.ok || result?.ok !== true) { 5025 throw responseError(response, result, "The current discovery run could not be refreshed.", { 5026 endpointType: "run_result", runId, jobId: currentJobId || "", 5027 }); 5028 } 5029 return result; 5030 }; 5031 const throwIfDiscoveryFailed = (result) => { 5032 if (result?.discoveryJob?.state !== "failed") return; 5033 clearActiveRunObserver(result?.runId || current?.runId || data?.runId); 5034 const failure = mvpDeliveryError("result_failed", result?.message || "Full-catalog discovery failed."); 5035 Object.assign(failure, { 5036 reasonCode: "result_failed", 5037 runId: result?.runId || current?.runId || "", 5038 jobId: result?.discoveryJob?.currentJobId || "", 5039 endpointType: "run_result", 5040 }); 5041 throw failure; 5042 }; 5043 5044 while (current?.discoveryJob) { 5045 assertForeground(requestMeta); 5046 const observedRunId = String(current?.runId || data?.runId || ""); 5047 trackActiveRunObserver({ 5048 runId: observedRunId, 5049 sourceUrl: requestMeta?.url || "", 5050 host: requestMeta?.host || "", 5051 state: "observing", 5052 }); 5053 throwIfDiscoveryFailed(current); 5054 if (current.discoveryJob.state !== "discovering") break; 5055 const runId = observedRunId; 5056 renderCompletedPackage(current, requestMeta, startedAt, "mvp_discovery_partial_updated"); 5057 if (continueScanLoadingStatus) continueScanLoadingStatus.textContent = "Discovering full catalog"; 5058 if (!runId) break; 5059 const nextJobId = String(current.discoveryJob.currentJobId || ""); 5060 if (!nextJobId || nextJobId === lastPolledJobId) { 5061 await new Promise((resolve) => setTimeout(resolve, pollDelayMs)); 5062 current = finishDiscoveryFromDurableCoverage(await readPersistedRun(runId, nextJobId)); 5063 continue; 5064 } 5065 5066 lastPolledJobId = nextJobId; 5067 activeDiscoveryJobId = nextJobId; 5068 if (stopDiscoveryButton) { 5069 stopDiscoveryButton.hidden = false; 5070 stopDiscoveryButton.textContent = "Stop discovery"; 5071 } 5072 try { 5073 await waitForMvpUrlJob( 5074 nextJobId, requestMeta, startedAt, Infinity, runId, 5075 `/api/jobs/${encodeURIComponent(nextJobId)}`, current.requestId || "", isSignedIn(), 5076 async () => { 5077 if (continueScanLoadingStatus) continueScanLoadingStatus.textContent = "Discovering full catalog"; 5078 const durable = finishDiscoveryFromDurableCoverage(await readPersistedRun(runId, nextJobId)); 5079 current = durable; 5080 renderCompletedPackage(durable, requestMeta, startedAt, "mvp_discovery_partial_updated"); 5081 }, false 5082 ); 5083 } catch (error) { 5084 assertForeground(requestMeta); 5085 // A job pointer is only a detail channel. The persisted run decides whether 5086 // discovery is still healthy or has actually failed. 5087 current = { ...current, discoveryJob: { ...current.discoveryJob, currentJobId: nextJobId } }; 5088 } 5089 } 5090 assertForeground(requestMeta); 5091 activeDiscoveryJobId = ""; 5092 const finalRunId = String(current?.runId || data?.runId || ""); 5093 if (current?.discoveryJob?.state === "discovering") { 5094 clearActiveRunObserver(finalRunId); 5095 } else { 5096 clearActiveRunObserver(finalRunId); 5097 try { window.localStorage?.removeItem(ACTIVE_DISCOVERY_STORAGE_KEY); } catch {} 5098 } 5099 return current; 5100 }; 5101 5102 /* Legacy automatic restoration was intentionally removed. Recovery is now 5103 discovered below and only attaches or continues after an explicit click. */ 5104 const unusedRestoreActiveInitialUrlRun = async () => { 5105 if (activeInitialScanJobId) return false;
5106 let localRun = null; 5107 try { localRun = JSON.parse(window.localStorage?.getItem(ACTIVE_DISCOVERY_STORAGE_KEY) || "null"); } catch {} 5108 if (!isSignedIn() && localRun?.runId) { 5109 const run = localRun; 5110 const restoredStartedAt = Number.isFinite(Date.parse(run.createdAt)) ? Date.parse(run.createdAt) : Date.now(); 5111 const requestMeta = { 5112 sourceType: "url", host: run.host || "", url: run.sourceUrl || "", restored: true, 5113 perf: { requestSubmittedAtMs: restoredStartedAt }, 5114 }; 5115 activeInitialScanJobId = String(run.jobId || ""); 5116 trackActiveRunObserver({ runId: run.runId, sourceUrl: run.sourceUrl, host: run.host, state: "restoring" }); 5117 markActiveRunRecoveryReady(); 5118 setNoReportMessageVisible(false); 5119 let loading = null; 5120 try { 5121 const response = await fetch(`/api/mvp/runs/${encodeURIComponent(run.runId)}/result`, { credentials: "same-origin" }); 5122 const partial = finishDiscoveryFromDurableCoverage(await parseResponsePayload(response)); 5123 if (response.status === 404 || response.status === 410) { 5124 clearActiveRunObserver(run.runId); 5125 try { window.localStorage?.removeItem(ACTIVE_DISCOVERY_STORAGE_KEY); } catch {} 5126 return false; 5127 } 5128 if (!response.ok || partial?.ok !== true) { 5129 if (continueScanStatus) { 5130 continueScanStatus.hidden = false; 5131 continueScanStatus.textContent = "Discovery is continuing in the background. Reload later to resume live updates."; 5132 } 5133 return true; 5134 } 5135 if (partial?.discoveryJob?.state === "discovering") { 5136 setUrlGenerationLoading(true); 5137 loading = startInitialScanLoading(restoredStartedAt); 5138 loading.updateStatus("recovering"); 5139 } 5140 renderCompletedPackage(partial, requestMeta, restoredStartedAt, "mvp_active_run_partial_restored"); 5141 const data = partial?.discoveryJob?.state === "discovering" 5142 ? await waitForFullCatalogChain(partial, requestMeta, restoredStartedAt) 5143 : partial; 5144 renderCompletedPackage(data, requestMeta, restoredStartedAt, "mvp_active_run_restored"); 5145 if (data?.discoveryJob?.state !== "discovering") { 5146 clearActiveRunObserver(run.runId); 5147 try { window.localStorage?.removeItem(ACTIVE_DISCOVERY_STORAGE_KEY); } catch {} 5148 } 5149 return true; 5150 } catch { 5151 if (continueScanStatus) { 5152 continueScanStatus.hidden = false; 5153 continueScanStatus.textContent = "Discovery is continuing in the background. Reload later to resume live updates."; 5154 } 5155 return true; 5156 } finally { 5157 loading?.stop(); 5158 if (loading) setUrlGenerationLoading(false); 5159 activeInitialScanJobId = ""; 5160 } 5161 } 5162 const runs = isSignedIn() ? await loadRunHistory() : (localRun ? [localRun] : []); 5163 const run = runs.find((candidate) => { 5164 const createdAtMs = Date.parse(candidate?.createdAt || ""); 5165 const recentlyCreated = Number.isFinite(createdAtMs) && Date.now() - createdAtMs <= 24 * 60 * 60 * 1000; 5166 return candidate?.jobId 5167 && (!candidate?.jobType || candidate.jobType === "mvp-url-run") 5168 && recentlyCreated 5169 && Number(candidate?.productCount || 0) === 0 5170 && ["queued", "running"].includes(String(candidate?.status || "").toLowerCase()); 5171 }); 5172 if (!run) return false; 5173 5174 const restoredStartedAt = Number.isFinite(Date.parse(run.createdAt)) ? Date.parse(run.createdAt) : Date.now(); 5175 const requestMeta = { 5176 sourceType: "url", 5177 host: run.host || "", 5178 url: run.sourceUrl || "", 5179 restored: true, 5180 perf: { requestSubmittedAtMs: restoredStartedAt }, 5181 }; 5182 activeInitialScanJobId = run.jobId; 5183 trackActiveRunObserver({ runId: run.runId, sourceUrl: run.sourceUrl, host: run.host, state: "restoring" }); 5184 markActiveRunRecoveryReady(); 5185 setUrlGenerationLoading(true); 5186 setScanActive(true); 5187 setNoReportMessageVisible(false); 5188 const loading = startInitialScanLoading(restoredStartedAt); 5189 loading.updateStatus(run.status); 5190 try { 5191 let data = await waitForMvpUrlJob( 5192 run.jobId, 5193 requestMeta, 5194 restoredStartedAt, 5195 RECOVERY_POLL_MAX_MS, 5196 run.runId, 5197 `/api/jobs/${encodeURIComponent(run.jobId)}`, 5198 "", 5199 isSignedIn(), 5200 (status) => loading.updateStatus(status), 5201 false 5202 ); 5203 data = finishDiscoveryFromDurableCoverage(data); 5204 if (data?.discoveryJob?.state === "discovering") { 5205 data = await waitForFullCatalogChain(data, requestMeta, restoredStartedAt); 5206 } 5207 renderCompletedPackage(data, requestMeta, restoredStartedAt, "mvp_active_run_restored"); 5208 if (data?.discoveryJob?.state !== "discovering") { 5209 clearActiveRunObserver(run.runId); 5210 try { window.localStorage?.removeItem(ACTIVE_DISCOVERY_STORAGE_KEY); } catch {} 5211 } 5212 if (isSignedIn()) await loadRunHistory(); 5213 return true; 5214 } catch (err) { 5215 setScanActive(false, { keepIntentHidden: true }); 5216 clearActiveRunObserver(run.runId); 5217 const deliveryCode = ["result_unavailable", "status_read_failed", "session_expired", "result_access_de
5217nied"].includes(err?.code) 5218 ? err.code 5219 : ""; 5220 setResultSourceLabel(requestMeta); 5221 if (deliveryCode) { 5222 if (stateTitle) stateTitle.textContent = "Import package could not be loaded"; 5223 if (stateMessage) stateMessage.textContent = err?.message || "Extraction may have completed. Your run remains available under My runs."; 5224 } else { 5225 setStateCopy("failure"); 5226 try { window.localStorage?.removeItem(ACTIVE_DISCOVERY_STORAGE_KEY); } catch {} 5227 if (stateMessage && err?.message) stateMessage.textContent = err.message; 5228 } 5229 renderTopIssues([err?.message || "The scan failed before the package was completed."]); 5230 if (successPanel) successPanel.style.display = "grid"; 5231 setNoReportMessageVisible(false); 5232 hideDownloadLinks(); 5233 setDetails(`Restored URL scan failed.\n${err?.message || err}`); 5234 return false; 5235 } finally { 5236 loading.stop(); 5237 setUrlGenerationLoading(false); 5238 activeInitialScanJobId = ""; 5239 } 5240 }; 5241 5242 const unusedRestoreActiveDiscovery = async () => { 5243 if (!isSignedIn() || activeDiscoveryJobId) return false; 5244 const runs = await loadRunHistory(); 5245 const run = runs.find((candidate) => candidate?.jobId 5246 && candidate?.jobType === "mvp-continue-scan" 5247 && ["queued", "running"].includes(String(candidate?.status || "").toLowerCase())); 5248 if (!run) return false; 5249 activeDiscoveryJobId = run.jobId; 5250 trackActiveRunObserver({ runId: run.runId, sourceUrl: run.sourceUrl, host: run.host, state: "restoring" }); 5251 markActiveRunRecoveryReady(); 5252 const startedAt = Number.isFinite(Date.parse(run.updatedAt || run.createdAt)) ? Date.parse(run.updatedAt || run.createdAt) : Date.now(); 5253 let stopLoading = null; 5254 try { 5255 const partialResponse = await authenticatedFetch(`/api/mvp/runs/${encodeURIComponent(run.runId)}/result`, { 5256 credentials: "same-origin", 5257 }, { endpointType: "run_result", runId: run.runId, jobId: run.jobId }); 5258 const partial = finishDiscoveryFromDurableCoverage(await parseResponsePayload(partialResponse)); 5259 if (!partialResponse.ok || partial?.ok !== true) throw responseError(partialResponse, partial, "The current discovery run could not be restored.", { endpointType: "run_result", runId: run.runId, jobId: run.jobId }); 5260 renderCompletedPackage(partial, { sourceType: "url", url: run.sourceUrl, host: run.host, restored: true }, startedAt, "mvp_discovery_partial_restored"); 5261 if (partial?.discoveryJob?.state === "discovering") { 5262 stopLoading = startContinueScanLoading(); 5263 if (continueScanLoadingStatus) continueScanLoadingStatus.textContent = "Discovering full catalog"; 5264 } 5265 const result = partial?.discoveryJob?.state === "discovering" 5266 ? await waitForFullCatalogChain(partial, { sourceType: "url", url: run.sourceUrl, host: run.host, restored: true, perf: {} }, startedAt) 5267 : partial; 5268 renderCompletedPackage(result, { sourceType: "url", url: run.sourceUrl, host: run.host, restored: true }, startedAt, "mvp_discovery_restored"); 5269 if (result?.discoveryJob?.state !== "discovering") { 5270 clearActiveRunObserver(run.runId); 5271 try { window.localStorage?.removeItem(ACTIVE_DISCOVERY_STORAGE_KEY); } catch {} 5272 } 5273 return true; 5274 } catch (error) { 5275 clearActiveRunObserver(run.runId); 5276 if (continueScanStatus) { 5277 continueScanStatus.hidden = false; 5278 continueScanStatus.textContent = /cancel/i.test(String(error?.message || "")) ? "Cancelled" : `Failed: ${error?.message || "Discovery failed."}`; 5279 } 5280 return false; 5281 } finally { 5282 activeDiscoveryJobId = ""; 5283 stopLoading?.(); 5284 } 5285 }; 5286 5287 const recoveryRequestMeta = (run) => ({ 5288 sourceType: "url", 5289 url: run?.sourceUrl || "", 5290 host: run?.host || "", 5291 restored: true, 5292 perf: {}, 5293 }); 5294 5295 const hideRecoveryChoice = () => { 5296 if (recoveryPanel) recoveryPanel.hidden = true; 5297 if (recoveryStatus) { recoveryStatus.hidden = true; recoveryStatus.textContent = ""; } 5298 recoveryCandidate = null; 5299 }; 5300 5301 const showRecoveryChoice = (run, durableResult = null) => { 5302 recoveryCandidate = { ...run, durableResult }; 5303 const host = String(run?.host || durableResult?.customerSummary?.supplier_context?.host || "this site"); 5304 if (recoveryTitle) recoveryTitle.textContent = `You have an unfinished extraction for ${host}`; 5305 const jobState = String(run?.status || "").toLowerCase(); 5306 const discovery = durableResult?.discoveryJob || run?.discoveryJob || {}; 5307 const pages = Number(discovery?.pagesAttempted || durableResult?.customerSummary?.discovery_coverage?.pages_attempted || 0); 5308 const products = Number(discovery?.productsFound || run?.productC
5308ount || durableResult?.summary?.productsFound || 0); 5309 if (recoveryProgress) { 5310 recoveryProgress.textContent = [ 5311 ["queued", "running"].includes(jobState) || discovery?.state === "discovering" ? "Currently running in the background." : "Saved progress is ready to continue.", 5312 pages > 0 ? `${pages} pages checked.` : "", 5313 products > 0 ? `${products} products found.` : "", 5314 ].filter(Boolean).join(" "); 5315 } 5316 if (recoveryPanel) recoveryPanel.hidden = false; 5317 }; 5318 5319 const reconnectSavedExtraction = async (run, durableResult = null) => { 5320 const startedAt = Date.now(); 5321 const requestMeta = { ...recoveryRequestMeta(run), foregroundGeneration: ++foregroundGeneration }; 5322 const jobId = String(durableResult?.discoveryJob?.currentJobId || run?.jobId || ""); 5323 if (!jobId) throw mvpDeliveryError("recovery_job_missing", "The saved extraction is no longer active."); 5324 5325 setScanActive(true); 5326 setUrlGenerationLoading(true); 5327 setNoReportMessageVisible(false); 5328 const loading = startInitialScanLoading(startedAt); 5329 loading.updateStatus("recovering"); 5330 try { 5331 // The job endpoint, rather than the browser's saved status, is the authority 5332 // for deciding whether this run is still active. 5333 trackActiveRunObserver({ runId: run.runId, sourceUrl: run.sourceUrl, host: run.host, state: "observing" }); 5334 let data = await waitForMvpUrlJob(jobId, requestMeta, startedAt, RECOVERY_POLL_MAX_MS, run.runId, 5335 `/api/jobs/${encodeURIComponent(jobId)}`, "", isSignedIn(), (status) => loading.updateStatus(status), false); 5336 data = finishDiscoveryFromDurableCoverage(data); 5337 if (data?.discoveryJob?.state === "discovering") data = await waitForFullCatalogChain(data, requestMeta, startedAt); 5338 assertForeground(requestMeta); 5339 renderCompletedPackage(data, requestMeta, startedAt, "mvp_recovery_continued"); 5340 hideRecoveryChoice(); 5341 clearActiveRunObserver(run.runId); 5342 try { window.localStorage?.removeItem(ACTIVE_DISCOVERY_STORAGE_KEY); } catch {} 5343 setScanActive(false); 5344 return true; 5345 } finally { 5346 loading.stop(); 5347 setUrlGenerationLoading(false); 5348 } 5349 }; 5350 5351 const checkForRecoveryChoice = async () => { 5352 const generation = foregroundGeneration; 5353 let localRun = null; 5354 try { localRun = JSON.parse(window.localStorage?.getItem(ACTIVE_DISCOVERY_STORAGE_KEY) || "null"); } catch {} 5355 const runs = isSignedIn() ? await loadRunHistory() : (localRun ? [localRun] : []); 5356 const run = runs.find((candidate) => { 5357 const status = String(candidate?.status || "").toLowerCase(); 5358 const discoveryState = String(candidate?.discoveryJob?.state || "").toLowerCase(); 5359 return candidate?.runId && ((candidate === localRun && candidate?.jobId) || ["queued", "running", "failed", "cancelled", "interrupted"].includes(status) 5360 || ["discovering", "stopped_at_limit", "cancelled", "failed", "interrupted"].includes(discoveryState)); 5361 }); 5362 if (!run) return false; 5363 5364 trackActiveRunObserver({ runId: run.runId, sourceUrl: run.sourceUrl, host: run.host, state: "restoring" }); 5365 let durable = null; 5366 const recoveryController = new AbortController(); 5367 const recoveryTimeout = setTimeout(() => recoveryController.abort(), 10_000); 5368 try { 5369 const resultFetch = isSignedIn() ? authenticatedFetch : (url, options) => fetch(url, options); 5370 const response = await resultFetch(`/api/mvp/runs/${encodeURIComponent(run.runId)}/result`, { 5371 credentials: "same-origin", 5372 signal: recoveryController.signal, 5373 }, { endpointType: "run_result", runId: run.runId, jobId: run.jobId || "" }); 5374 const payload = finishDiscoveryFromDurableCoverage(await parseResponsePayload(response)); 5375 if (response.ok && payload?.ok === true) durable = payload; 5376 if (response.status === 404) { 5377 clearActiveRunObserver(run.runId); 5378 try { window.localStorage?.removeItem(ACTIVE_DISCOVERY_STORAGE_KEY); } catch {} 5379 return false; 5380 } 5381 } catch {} finally { 5382 clearTimeout(recoveryTimeout); 5383 } 5384 5385 if (generation !== foregroundGeneration) return false;
5386 const durableState = String(durable?.discoveryJob?.state || "").toLowerCase(); 5387 const historyStatus = String(run?.status || "").toLowerCase(); 5388 const completed = durable && (durableState === "complete" 5389 || (!durable?.discoveryJob && ["success", "completed"].includes(String(durable?.status || "").toLowerCase()))); 5390 if (completed) { 5391 clearActiveRunObserver(run.runId); 5392 try { window.localStorage?.removeItem(ACTIVE_DISCOVERY_STORAGE_KEY); } catch {} 5393 renderCompletedPackage(durable, recoveryRequestMeta(run), Date.now(), "mvp_recovery_completed"); 5394 return true; 5395 } 5396 const durableActive = ["discovering", "queued", "running"].includes(durableState); 5397 const savedActive = ["queued", "running"].includes(historyStatus) || (run === localRun && run.jobId && !historyStatus); 5398 if (durableActive || savedActive) { 5399 try { 5400 await reconnectSavedExtraction(run, durable); 5401 return true; 5402 } catch (error) { 5403 if (generation !== foregroundGeneration - 1) return false; 5404 setScanActive(false); 5405 clearActiveRunObserver(run.runId); 5406 } 5407 } else { 5408 clearActiveRunObserver(run.runId); 5409 } 5410 showRecoveryChoice(run, durable); 5411 return true; 5412 }; 5413 5414 recoveryContinue?.addEventListener("click", async () => { 5415 const run = recoveryCandidate; 5416 if (!run) return; 5417 recoveryContinue.disabled = true; 5418 recoveryStartNew.disabled = true; 5419 setScanActive(true); 5420 if (recoveryStatus) { recoveryStatus.hidden = false; recoveryStatus.textContent = "Connecting to the saved extractionâ¦"; } 5421 const startedAt = Date.now(); 5422 const requestMeta = { ...recoveryRequestMeta(run), foregroundGeneration: ++foregroundGeneration }; 5423 try { 5424 let data = run.durableResult; 5425 const active = ["queued", "running"].includes(String(run.status || "").toLowerCase()); 5426 if (active && run.jobId) { 5427 trackActiveRunObserver({ runId: run.runId, sourceUrl: run.sourceUrl, host: run.host, state: "observing" }); 5428 data = await waitForMvpUrlJob(run.jobId, requestMeta, startedAt, RECOVERY_POLL_MAX_MS, run.runId, 5429 `/api/jobs/${encodeURIComponent(run.jobId)}`, "", isSignedIn(), (status) => { 5430 assertForeground(requestMeta); 5431 if (recoveryStatus) recoveryStatus.textContent = `Extraction ${status || "running"}â¦`; 5432 }, false); 5433 } else { 5434 const response = await authenticatedFetch(`/api/mvp/runs/${encodeURIComponent(run.runId)}/continue-scan`, { 5435 method: "POST", headers: { "Content-Type": "application/json" }, body: "{}", 5436 }, { endpointType: "continue_scan", runId: run.runId }); 5437 const queued = await parseResponsePayload(response); 5438 if (!response.ok || queued?.ok !== true) throw responseError(response, queued, "The saved extraction could not be continued."); 5439 if (queued.jobId) data = await waitForMvpUrlJob(queued.jobId, requestMeta, startedAt, RECOVERY_POLL_MAX_MS, 5440 run.runId, `/api/jobs/${encodeURIComponent(queued.jobId)}`, queued.requestId || "", isSignedIn(), null, false); 5441 else data = queued; 5442 } 5443 assertForeground(requestMeta); 5444 data = finishDiscoveryFromDurableCoverage(data); 5445 if (data?.discoveryJob?.state === "discovering") data = await waitForFullCatalogChain(data, requestMeta, startedAt); 5446 assertForeground(requestMeta); 5447 renderCompletedPackage(data, requestMeta, startedAt, "mvp_recovery_continued"); 5448 hideRecoveryChoice(); 5449 clearActiveRunObserver(run.runId); 5450 } catch (error) { 5451 if (!isForeground(requestMeta)) return; 5452 setScanActive(false, { keepIntentHidden: true }); 5453 clearActiveRunObserver(run.runId); 5454 if (recoveryStatus) recoveryStatus.textContent = error?.code === "poll_timeout" 5455 ? "Live updates stopped. The extraction may still be running in the background." 5456 : (error?.message || "The saved extraction could not be continued."); 5457 } finally { 5458 if (!isForeground(requestMeta)) return; 5459 recoveryContinue.disabled = false; 5460 recoveryStartNew.disabled = false; 5461 } 5462 }); 5463 5464 recoveryStartNew?.addEventListener("click", () => { 5465 if (!recoveryCandidate || scanActive) return; 5466 clearActiveRunObserver(recoveryCandidate.runId); 5467 try { window.localStorage?.removeItem(ACTIVE_DISCOVERY_STORAGE_KEY); } catch {} 5468 hideRecoveryChoice(); 5469 setScanActive(false); 5470 if (urlInput) urlInput.value = ""; 5471 urlInput?.focus?.(); 5472 }); 5473 5474 const openLinkedDiscoveryResult = async () => { 5475 let runId = ""; 5476 let resume = ""; 5477 let expires = ""; 5478 try { 5479 const params = new URL(window.location.href).searchParams;
5480 runId = String(params.get("run") || ""); resume = String(params.get("resume") || ""); expires = String(params.get("expires") || ""); 5481 } catch {} 5482 if (!runId || !/^[a-z0-9._-]+$/i.test(runId)) return false; 5483 if (!isSignedIn() && resume && expires) { 5484 const response = await fetch(`/api/mvp/runs/${encodeURIComponent(runId)}/result?resume=${encodeURIComponent(resume)}&expires=${encodeURIComponent(expires)}`, { credentials: "same-origin" }); 5485 const data = await parseResponsePayload(response); 5486 if (!response.ok || !data?.ok) return false; 5487 renderCompletedPackage(data, { sourceType: "url", url: data?.customerSummary?.supplier_context?.source_url || "", host: data?.customerSummary?.supplier_context?.host || "" }, Date.now(), "mvp_discovery_email_opened"); 5488 return true; 5489 } 5490 if (!isSignedIn()) return false; 5491 const runs = runHistoryLoaded ? cachedRunHistory : await loadRunHistory(); 5492 const run = runs.find((candidate) => candidate?.runId === runId); 5493 if (!run || run.canOpenResult !== true) return false; 5494 return openHistoryRun(run, null); 5495 }; 5496 5497 const startContinueScanLoading = () => { 5498 continuationScanActive = true; 5499 const globalStatus = E("mvpStatus"); 5500 if (globalStatus) globalStatus.hidden = true; 5501 if (continueScanLoading) continueScanLoading.hidden = false; 5502 if (continueScanProgress) continueScanProgress.hidden = false; 5503 if (datasetActions) datasetActions.hidden = true; 5504 if (stopDiscoveryButton) { 5505 stopDiscoveryButton.hidden = false; 5506 stopDiscoveryButton.textContent = "Stop discovery"; 5507 } 5508 if (currentDatasetState) { 5509 currentDatasetState.textContent = "Current dataset: Partial"; 5510 currentDatasetState.hidden = false; 5511 } 5512 if (lastMvpResultViewModel) { 5513 setText("mvpContinueProductsCollected", lastMvpResultViewModel.productsCollected); 5514 setText("mvpContinuePagesScanned", lastMvpResultViewModel.pagesScanned); 5515 setText("mvpContinuePagesRemaining", lastMvpResultViewModel.pagesRemaining); 5516 setScanProgress( 5517 lastMvpResultViewModel.pagesScanned, 5518 lastMvpResultViewModel.pagesScanned + lastMvpResultViewModel.pagesRemaining, 5519 ); 5520 } 5521 if (continueScanLoadingStatus) continueScanLoadingStatus.textContent = "Requesting the next catalog scanâ¦"; 5522 return () => { 5523 continuationScanActive = false; 5524 if (continueScanLoading) continueScanLoading.hidden = true; 5525 if (continueScanProgress) continueScanProgress.hidden = true; 5526 if (currentDatasetState) currentDatasetState.hidden = true; 5527 if (lastMvpResultViewModel && lastMvpResult) renderMvpResultViewModel(lastMvpResultViewModel, lastMvpResult); 5528 const hasIdleDiscoveryActions = lastMvpResultViewModel?.discovery?.enabled === true 5529 && lastMvpResultViewModel.pagesRemaining > 0 5530 && entitlementState().canContinueScan 5531 && resultWorkflowState?.stopDiscovery !== true; 5532 if (datasetActions) datasetActions.hidden = !hasIdleDiscoveryActions; 5533 }; 5534 }; 5535 5536 E("mvpDiscoveryClearProblemFilter")?.addEventListener("click", () => { 5537 if (!lastDiscoveryModel) return; 5538 renderDiscoveryMap(lastDiscoveryModel, { problemOnly: false, filter: "scanned" }); 5539 }); 5540 5541 document.querySelectorAll("[data-mvp-page-filter]").forEach((button) => { 5542 button.addEventListener("click", () => { 5543 if (!lastDiscoveryModel) return; 5544 const filter = button.dataset.mvpPageFilter || "needs_review"; 5545 renderDiscoveryMap(lastDiscoveryModel, { problemOnly: filter === "needs_review", filter }); 5546 }); 5547 }); 5548 5549 const openCurrentFiles = () => { 5550 if (freeFilesPanel && "open" in freeFilesPanel) freeFilesPanel.open = true; 5551 if (technicalSection && "open" in technicalSection) technicalSection.open = true; 5552 if (productReviewSection && "open" in productReviewSection) productReviewSection.open = true; 5553 if (currentDraftExport && "open" in currentDraftExport) currentDraftExport.open = true; 5554 freeFilesPanel?.scrollIntoView?.({ behavior: "smooth", block: "start" }); 5555 }; 5556 5557 E("mvpExportCurrentProducts")?.addEventListener("click", () => { 5558 const csvDownload = E("mvpCsvDownload"); 5559 if (csvDownload?.dataset.mvpDownloadPath) return csvDownload.click(); 5560 if (!isSignedIn()) return focusAccountFromGate("signin", "download"); 5561 openCurrentFiles(); 5562 }); 5563 E("mvpViewTechnicalDetails")?.addEventListener("click", () => { 5564 if (technicalSection && "open" in technicalSection) technicalSection.open = true; 5565 technicalSection?.scrollIntoView?.({ behavior: "smooth", block: "start" }); 5566 }); 5567 E("mvpPrimaryRecommendation")?.addEventListener("click", () => { 5568 const action = E("mvpPrimaryRecommendation")?.dataset.action; 5569 if (action === "generate_files") return openCurrentFiles(); 5570 if (action === "retry") return runButton?.click(); 5571 const discoveryMap = E("mvpDiscoveryMap"); 5572 if (lastDiscoveryModel && discoveryMap && discoveryProblemPages(lastDiscoveryModel).length > 0) { 5573 renderDiscoveryMap(lastDiscoveryModel, { problemOnly: true }); 5574 discoveryMap.open = true; 5575 discoveryMap.scrollIntoView?.({ behavior: "smooth", block: "start" }); 5576 return; 5577 } 5578 E("mvpReviewDetails")?.scrollIntoView?.({ behavior: "smooth", block: "start" }); 5579 }); 5580 5581 continueScanButton?.addEventListener("click", async () => { 5582 if (!isSignedIn()) { 5583 focusAccountFromGate("signin", "continue"); 5584 return; 5585 } 5586 if (!lastMvpResult?.runId || !lastMvpResult?.host || !entitlementState().canContinueScan) return; 5587 const originalLabel = continueScanButton.textContent; 5588 const startedAt = Date.now(); 5589 continueScanButton.disabled = true; 5590 continueScanButton.textContent = "Scanningâ¦"; 5591 if (continueScanStatus) { 5592 continueScanStatus.hidden = true; 5593 continueScanStatus.textContent = ""; 5594 } 5595 const stopContinueScanLoading = startContinueScanLoading(); 5596 try { 5597 const requestMeta = { sourceType: "url", url: lastMvpResult.url, host: lastMvpResult.host, perf: { requestSubmittedAtMs: Date.now() } }; 5598 const res = await authenticatedFetch(`/api/mvp/runs/${encodeURIComponent(lastMvpResult.runId)}/continue-scan`, { 5599 method: "POST", 5600 headers: { "Content-Type": "application/json" }, 5601 credentials: "same-origin", 5602 body: JSON.stringify({ host: lastMvpResult.host, ...discoveryNotificationPayload() }), 5603 }, { endpointType: "continue_scan", runId: lastMvpResult.runId }); 5604 let data = await parseResponsePayload(res); 5605 if (data?.notificationError && discoveryNotificationStatus) { 5606 discoveryNotificationStatus.textContent = data.notificationError; 5607 discoveryNotificationStatus.hidden = false;
5608 } 5609 requestMeta.perf.jobAcceptedMs = Date.now() - requestMeta.perf.requestSubmittedAtMs; 5610 if (res.status === 402) { 5611 renderUsage(data?.usage); 5612 setCheckoutMessage(AUTHENTICATED_EXHAUSTED_MESSAGE); 5613 } 5614 if (!res.ok || !data?.ok) throw responseError(res, data, "Continue scan failed.", { endpointType: "continue_scan", runId: lastMvpResult.runId }); 5615 if (res.status === 202 && data?.jobId) { 5616 activeDiscoveryJobId = data.jobId; 5617 data = await waitForMvpUrlJob( 5618 data.jobId, 5619 requestMeta, 5620 startedAt, 5621 35 * 60 * 1000, 5622 lastMvpResult.runId, 5623 data.statusUrl || "", 5624 data.requestId || lastMvpResult.requestId || "", 5625 isSignedIn(), 5626 (status) => { 5627 if (stateTitle) stateTitle.textContent = status === "queued" 5628 ? "Catalog scan queued" 5629 : "Scanning catalog"; 5630 if (!continueScanLoadingStatus) return; 5631 continueScanLoadingStatus.textContent = status === "queued" 5632 ? "Waiting for the next scan batch to startâ¦" 5633 : status === "completed" || status === "succeeded" 5634 ? "Finalizing the updated datasetâ¦" 5635 : "Scanning the next supplier pagesâ¦"; 5636 }, 5637 false 5638 ); 5639 data = await waitForFullCatalogChain(data, requestMeta, startedAt); 5640 } 5641 stopContinueScanLoading(); 5642 renderUsage(data?.usage); 5643 renderCompletedPackage(data, requestMeta, startedAt, "mvp_continue_scan_completed"); 5644 if (isSignedIn()) await loadRunHistory(); 5645 if (continueScanStatus) continueScanStatus.textContent = "Scan continued. The current package has been updated."; 5646 } catch (err) { 5647 stopContinueScanLoading(); 5648 if (continueScanStatus) { 5649 continueScanStatus.hidden = false; 5650 continueScanStatus.textContent = err?.message || "Continue scan failed."; 5651 } 5652 warn("mvp_continue_scan_failed", { runId: lastMvpResult?.runId || null, error: err?.message || String(err) }); 5653 } finally { 5654 activeDiscoveryJobId = ""; 5655 stopContinueScanLoading(); 5656 continueScanButton.disabled = false; 5657 continueScanButton.textContent = originalLabel || "Continue building dataset"; 5658 } 5659 }); 5660 5661 enrichProductsButton?.addEventListener("click", async () => { 5662 if (!isSignedIn()) { 5663 focusAccountFromGate("signin", "enrich"); 5664 return; 5665 } 5666 if (enrichProductsButton.dataset.requiresRuns === "true") { 5667 focusAccountFromGate("signin", "enrich"); 5668 return; 5669 } 5670 if (!lastMvpResult?.runId || !lastMvpResult?.host || enrichProductsButton.disabled || enrichmentInProgress) return; 5671 const originalLabel = enrichProductsButton.textContent; 5672 const startedAt = Date.now(); 5673 enrichmentInProgress = true; 5674 enrichProductsButton.disabled = true; 5675 enrichProductsButton.textContent = "Enriching productsâ¦"; 5676 setEnrichmentState("starting", "Submitting the enrichment job."); 5677 try { 5678 const res = await authenticatedFetch(`/api/mvp/runs/${encodeURIComponent(lastMvpResult.runId)}/enrich-products`, { 5679 method: "POST", 5680 headers: { "Content-Type": "application/json" }, 5681 credentials: "same-origin", 5682 body: JSON.stringify({ host: lastMvpResult.host }), 5683 }, { endpointType: "product_enrichment", runId: lastMvpResult.runId }); 5684 let data = await parseResponsePayload(res); 5685 if (!res.ok || !data?.ok) throw responseError(res, data, "Product enrichment failed.", { endpointType: "product_enrichment", runId: lastMvpResult.runId }); 5686 if (res.status === 202 && data?.jobId) { 5687 setEnrichmentState("queued", "The enrichment job is queued and waiting to start."); 5688 data = await waitForMvpUrlJob( 5689 data.jobId, 5690 { sourceType: "url", url: lastMvpResult.url, host: lastMvpResult.host }, 5691 startedAt, 5692 35 * 60 * 1000, 5693 lastMvpResult.runId, 5694 data.statusUrl || "", 5695 data.requestId || lastMvpResult.requestId || "", 5696 true, 5697 (status) => { 5698 if (status === "queued") { 5699 setEnrichmentState("queued", "The enrichment job is queued and waiting to start."); 5700 } else { 5701 setEnrichmentState( 5702 "processing", 5703 status === "completed" || status === "succeeded" 5704 ? "Product-page processing finished. Updating the product dataset." 5705 : "Visiting eligible product pages and collecting available product data." 5706 ); 5707 } 5708 } 5709 ); 5710 } 5711 renderUsage(data?.usage); 5712 renderCompletedPackage(data, { sourceType: "url", url: lastMvpResult.url, host: lastMvpResult.host }, startedAt, "mvp_product_enrichment_completed"); 5713 enrichmentInProgress = false;
5714 if (!data?.enrichment && !data?.summary?.enrichment && !data?.customerSummary?.enrichment) { 5715 setEnrichmentState("completed", "Enrichment completed and the product dataset was updated."); 5716 } 5717 } catch (err) { 5718 enrichmentInProgress = false; 5719 enrichProductsButton.hidden = false; 5720 enrichProductsButton.disabled = false; 5721 enrichProductsButton.textContent = originalLabel || "Enrich products"; 5722 setEnrichmentState("failed", err?.message || "Product enrichment failed."); 5723 warn("mvp_product_enrichment_failed", { runId: lastMvpResult?.runId || null, error: err?.message || String(err) }); 5724 } finally { 5725 if (enrichProductsButton.textContent === "Enriching productsâ¦") { 5726 enrichProductsButton.disabled = false; 5727 enrichProductsButton.textContent = originalLabel || "Enrich product data"; 5728 } 5729 } 5730 }); 5731 5732 stopDiscoveryButton?.addEventListener("click", async () => { 5733 if (!activeDiscoveryJobId) return openCurrentFiles(); 5734 stopDiscoveryButton.disabled = true; 5735 try { 5736 const response = await authenticatedFetch(`/api/jobs/${encodeURIComponent(activeDiscoveryJobId)}/cancel`, { 5737 method: "POST", credentials: "same-origin", 5738 }, { endpointType: "continue_scan_cancel", runId: lastMvpResult?.runId || "", jobId: activeDiscoveryJobId }); 5739 const data = await parseResponsePayload(response); 5740 if (!response.ok || data?.ok === false) throw new Error(data?.error || "Discovery could not be stopped."); 5741 if (continueScanStatus) { 5742 continueScanStatus.textContent = "Cancelled"; 5743 continueScanStatus.hidden = false; 5744 } 5745 } catch (error) { 5746 if (continueScanStatus) { 5747 continueScanStatus.textContent = error?.message || "Discovery could not be stopped."; 5748 continueScanStatus.hidden = false; 5749 } 5750 } finally { 5751 stopDiscoveryButton.disabled = false; 5752 } 5753 }); 5754 5755 E("mvpDownloadCurrentFiles")?.addEventListener("click", openCurrentFiles); 5756 const focusAccountFromGate = (mode, action = "continue") => { 5757 if (blockAuthDuringScan()) return; 5758 if (isSignedIn()) { 5759 setAddRunsExpanded(true, { remember: true }); 5760 } else { 5761 setAuthMode(mode); 5762 const enrichmentAccountMessage = mode === "signup" 5763 ? t("mvp.enrichment.createMessage", null, "Create an account to save this dataset and enrich its product details. Only product enrichment consumes credits.") 5764 : t("mvp.enrichment.signInMessage", null, "Sign in to save this dataset and enrich its product details."); 5765 const storyAccountMessage = mode === "signup" 5766 ? t("mvp.marketing.createMessage", null, "Create an account to create an Instagram Story from this dataset. Only product enrichment consumes credits.") 5767 : t("mvp.marketing.signInMessage", null, "Sign in to create an Instagram Story from this dataset."); 5768 const catalogAccountMessage = mode === "signup" 5769 ? t("mvp.preview.createMessage", null, "Create an account to save and reopen this run.") 5770 : t("mvp.preview.signInMessage", null, "Sign in to save and reopen this run."); 5771 const downloadAccountMessage = mode === "signup" 5772 ? "Create an account to download this generated file. Downloads do not consume credits." 5773 : "Sign in to download this generated file. Downloads do not consume credits."; 5774 setAuthMessage(action === "enrich" 5775 ? enrichmentAccountMessage 5776 : action === "story" 5777 ? storyAccountMessage 5778 : action === "download" 5779 ? downloadAccountMessage 5780 : action === "catalog" 5781 ? catalogAccountMessage 5782 : mode === "signup" 5783 ? t("mvp.next.createMessage", null, "Create an account to save this dataset and continue scanning the remaining catalog pages. Only product enrichment consumes credits.") 5784 : t("mvp.next.signInMessage", null, "Sign in to save and continue this dataset.")); 5785 } 5786 openAccountPanel(document.activeElement instanceof HTMLElement ? document.activeElement : null); 5787 }; 5788 5789 E("mvpCreateAccountEnrich")?.addEventListener("click", () => { 5790 focusAccountFromGate("signup", "enrich"); 5791 }); 5792 5793 E("mvpSignInEnrich")?.addEventListener("click", () => { 5794 focusAccountFromGate("signin", "enrich"); 5795 }); 5796 5797 currentDraftExport?.addEventListener("toggle", () => { 5798 const discoveryInProgress = resultWorkflowState?.discovery?.enabled === true 5799 && resultWorkflowState.discovery.pagesRemaining > 0 5800 && !resultWorkflowState.stopDiscovery; 5801 if (discoveryInProgress && cleanupPanel) cleanupPanel.hidden = !currentDraftExport.open; 5802 }); 5803 5804 cleanupRequestLink?.addEventListener("click", async () => { 5805 if (!authenticatedActionAllowed()) return; 5806 if (!lastMvpResult || !hasCleanupArtifacts(lastMvpResult)) { 5807 resetCleanupAction("Generate a report first to unlock this review action."); 5808 return; 5809 } 5810 const originalLabel = cleanupRequestLink.textContent; 5811 cleanupRequestLink.disabled = true; 5812 cleanupRequestLink.textContent = "Generating cleaned Shopify CSVâ¦"; 5813 if (cleanupStatus) { 5814 cleanupStatus.textContent = "Generating cleaned Shopify CSVâ¦"; 5815 cleanupStatus.hidden = false;
5816 } 5817 if (cleanupDownload) { 5818 cleanupDownload.hidden = true; 5819 cleanupDownload.removeAttribute("href"); 5820 } 5821 5822 try { 5823 const res = await authenticatedFetch("/api/mvp/cleanup-ai", { 5824 method: "POST", 5825 headers: { "Content-Type": "application/json" }, 5826 body: JSON.stringify({ 5827 requestId: lastMvpResult.requestId, 5828 sourceType: lastMvpResult.sourceType, 5829 sourceUrl: lastMvpResult.url, 5830 fileName: lastMvpResult.fileName, 5831 artifacts: lastMvpResult.artifacts, 5832 }), 5833 }); 5834 const data = await res.json(); 5835 if (!res.ok || !data?.ok) throw new Error(data?.error || "Cleaned CSV generation failed."); 5836 const artifactPath = String(data?.artifact?.cleanedShopifyCsv || ""); 5837 if (!artifactPath) throw new Error("Cleaned CSV artifact was not returned."); 5838 if (cleanupDownload) { 5839 const canDownload = isSignedIn(); 5840 cleanupDownload.textContent = canDownload ? "Download prepared Shopify CSV" : "Sign in to download"; 5841 cleanupDownload.dataset.mvpDownloadPath = canDownload ? artifactPath : ""; 5842 cleanupDownload.removeAttribute("href"); 5843 cleanupDownload.hidden = false; 5844 } 5845 if (cleanupPreview) cleanupPreview.hidden = false; 5846 const summary = data?.summary || {}; 5847 if (cleanupTitle) cleanupTitle.textContent = "Cleaned Shopify CSV ready"; 5848 if (cleanupDescription) cleanupDescription.hidden = true; 5849 if (cleanupStatus) { 5850 cleanupStatus.textContent = ""; 5851 cleanupStatus.hidden = true; 5852 } 5853 cleanupRequestLink.hidden = true; 5854 log("mvp_cleanup_csv_generated", { requestId: lastMvpResult.requestId, artifactPath, summary }); 5855 } catch (err) { 5856 if (cleanupStatus) { 5857 cleanupStatus.textContent = err?.message || "Cleaned CSV generation failed."; 5858 cleanupStatus.hidden = false; 5859 } 5860 warn("mvp_cleanup_csv_failed", { requestId: lastMvpResult?.requestId || null, error: err?.message || String(err) }); 5861 } finally { 5862 cleanupRequestLink.textContent = originalLabel || "Prepare Shopify CSV"; 5863 cleanupRequestLink.disabled = !hasCleanupArtifacts(lastMvpResult); 5864 renderActionAccessGates(); 5865 } 5866 }); 5867 5868 cleanupPreview?.addEventListener("click", () => { 5869 if (previewDetails) previewDetails.open = true; 5870 previewDetails?.scrollIntoView?.({ behavior: "smooth", block: "start" }); 5871 }); 5872 5873 const extractUrlFromHtml = (html) => { 5874 const text = String(html || ""); 5875 const patterns = [ 5876 /<link[^>]+rel=["'][^"']*canonical[^"']*["'][^>]+href=["']([^"']+)["']/i,
5877 /<meta[^>]+property=["']og:url["'][^>]+content=["']([^"']+)["']/i, 5878 /<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:url["']/i, 5879 /<base[^>]+href=["']([^"']+)["']/i, 5880 ]; 5881 for (const pattern of patterns) { 5882 const match = text.match(pattern); 5883 if (match?.[1]) return match[1].trim(); 5884 } 5885 return ""; 5886 }; 5887 5888 const normalizeSubmittedUrl = (value) => { 5889 const trimmed = String(value || "").trim(); 5890 if (!trimmed || /^https?:\/\//i.test(trimmed)) return trimmed; 5891 try { 5892 return new URL(`https://${trimmed}`).href; 5893 } catch { 5894 return `https://${trimmed}`; 5895 } 5896 }; 5897 5898 const comparableRunUrl = (value) => { 5899 try { 5900 const parsed = new URL(normalizeSubmittedUrl(value)); 5901 parsed.hash = ""; 5902 return parsed.href; 5903 } catch { 5904 return String(value || "").trim(); 5905 } 5906 }; 5907 5908 const trackActiveRunObserver = ({ runId, sourceUrl, host, state }) => { 5909 const id = String(runId || ""); 5910 if (!id) return; 5911 activeRunObservers.set(id, { 5912 runId: id, 5913 sourceUrl: comparableRunUrl(sourceUrl), 5914 host: String(host || "").toLowerCase(), 5915 state: String(state || "observing"), 5916 }); 5917 }; 5918 5919 const clearActiveRunObserver = (runId) => { 5920 const id = String(runId || ""); 5921 if (id) activeRunObservers.delete(id); 5922 }; 5923 5924 const activeRunForUrl = (url) => { 5925 const sourceUrl = comparableRunUrl(url); 5926 return Array.from(activeRunObservers.values()).find((run) => run.sourceUrl === sourceUrl && ["observing", "submitting"].includes(run.state)) || null; 5927 }; 5928 5929 const markActiveRunRecoveryReady = () => { 5930 if (!activeRunRecoveryPending) return; 5931 activeRunRecoveryPending = false; 5932 resolveActiveRunRecovery?.(); 5933 }; 5934 5935 const htmlFileInput = E("mvpHtmlFileInput"); 5936 const htmlFileName = E("mvpHtmlFileName"); 5937 5938 uploadTab?.addEventListener("click", () => setInputMode("upload")); 5939 urlTab?.addEventListener("click", () => setInputMode("url")); 5940 const focusCsvRecovery = () => { 5941 setInputMode("upload"); 5942 uploadPanel?.scrollIntoView?.({ behavior: "smooth", block: "center" }); 5943 csvFileInput?.focus?.(); 5944 }; 5945 const focusHtmlRecovery = () => { 5946 setInputMode("url"); 5947 if (fallbackPanel) { 5948 fallbackPanel.hidden = false; 5949 fallbackPanel.open = true; 5950 fallbackPanel.scrollIntoView?.({ behavior: "smooth", block: "center" }); 5951 } 5952 htmlFileInput?.focus?.(); 5953 }; 5954 E("mvpCreateAiJson")?.addEventListener("click", focusCsvRecovery); 5955 E("mvpUseCsvForJson")?.addEventListener("click", focusCsvRecovery); 5956 E("mvpUseHtmlForJson")?.addEventListener("click", focusHtmlRecovery); 5957 E("mvpZeroExtractionUseCsv")?.addEventListener("click", focusCsvRecovery); 5958 E("mvpZeroExtractionUseHtml")?.addEventListener("click", focusHtmlRecovery); 5959 E("mvpZeroExtractionTryAnother")?.addEventListener("click", () => { 5960 setInputMode("url"); 5961 urlInput?.scrollIntoView?.({ behavior: "smooth", block: "center" }); 5962 urlInput?.focus?.(); 5963 }); 5964 setInputMode("url"); 5965 5966 htmlFileInput?.addEventListener("change", async () => { 5967 const file = htmlFileInput.files?.[0]; 5968 if (!file) { 5969 if (htmlFileName) htmlFileName.textContent = "No file selected."; 5970 return; 5971 } 5972 if (htmlFileName) htmlFileName.textContent = `Selected: ${file.name}`; 5973 try { 5974 const text = await file.text(); 5975 if (htmlInput) htmlInput.value = text; 5976 if (fallbackPanel) fallbackPanel.open = true; 5977 log("mvp_html_file_loaded", { fileName: file.name, size: text.length }); 5978 } catch (err) { 5979 if (htmlFileName) htmlFileName.textContent = `Failed to read file: ${file.name}`; 5980 log("mvp_html_file_load_failed", { fileName: file.name, error: err?.message || String(err) }); 5981 } 5982 }); 5983 5984 inspectFileButton?.addEventListener("click", async () => { 5985 const statusEl = E("mvpStatus"); 5986 const file = csvFileInput?.files?.[0]; 5987 if (!file) { 5988 if (statusEl) { 5989 statusEl.hidden = false; 5990 statusEl.textContent = "Upload a CSV file to inspect."; 5991 } 5992 return; 5993 } 5994 if (statusEl) { 5995 statusEl.hidden = false; 5996 statusEl.textContent = "Inspecting supplier CSV and building import files..."; 5997 } 5998 setImportQualityMetrics({ productsFound: 0, importReadyProducts: 0, warningCount: 0, blockingIssueCount: 0, blockedProducts: 0 }); 5999 setPackageLoading(true, "upload"); 6000 if (successPanel) successPanel.style.display = "none"; 6001 setNoReportMessageVisible(false); 6002 setResultSourceLabel(); 6003 hideDownloadLinks(); 6004 resetCleanupAction(); 6005 resetPreview(); 6006 renderDiscoveryCoverage(null); 6007 renderNextActions([]); 6008 setDetails("CSV inspection started."); 6009 const startedAt = Date.now(); 6010 const requestMeta = { sourceType: "upload", fileName: file.name, size: file.size }; 6011 log("mvp_file_inspection_started", requestMeta); 6012 try { 6013 const form = new FormData(); 6014 form.append("file", file); 6015 const res = await fetch("/api/mvp/inspect-file", { 6016 method: "POST", 6017 body: form, 6018 }); 6019 const data = await res.json(); 6020 if (!res.ok || !data?.ok) throw new Error(data?.error || "CSV inspection failed."); 6021 renderCompletedPackage(data, requestMeta, startedAt, "mvp_file_inspection_completed"); 6022 } catch (err) { 6023 log("mvp_file_inspection_failed", { ...requestMeta, error: err?.message || String(err), elapsedMs: Date.now() - startedAt }); 6024 setResultSourceLabel(requestMeta); 6025 setStateCopy("failure"); 6026 if (stateMessage) stateMessage.textContent = err?.message || "CSV inspection failed.";
6027 if (issueSummaryTitle) issueSummaryTitle.textContent = "Blocking errors"; 6028 setImportQualityMetrics({ productsFound: 0, importReadyProducts: 0, warningCount: 0, blockingIssueCount: 1, blockedProducts: 0 }); 6029 if (E("mvpMetricProducts")) E("mvpMetricProducts").textContent = "0"; 6030 if (E("mvpMetricImportReady")) E("mvpMetricImportReady").textContent = "0"; 6031 if (E("mvpMetricReviewRows")) E("mvpMetricReviewRows").textContent = "0"; 6032 if (E("mvpMetricBlocked")) E("mvpMetricBlocked").textContent = "0"; 6033 if (E("mvpSummaryMetricImportReady")) E("mvpSummaryMetricImportReady").textContent = "0"; 6034 if (E("mvpSummaryMetricReviewRows")) E("mvpSummaryMetricReviewRows").textContent = "0"; 6035 if (E("mvpSummaryMetricBlocked")) E("mvpSummaryMetricBlocked").textContent = "0"; 6036 if (E("mvpMetricBlockingIssues")) E("mvpMetricBlockingIssues").textContent = "0"; 6037 if (E("mvpMetricWarnings")) E("mvpMetricWarnings").textContent = "0"; 6038 if (E("mvpMetricRejected")) E("mvpMetricRejected").textContent = "0"; 6039 if (E("mvpSummaryMetricWarnings")) E("mvpSummaryMetricWarnings").textContent = "0"; 6040 if (E("mvpSummaryMetricRejected")) E("mvpSummaryMetricRejected").textContent = "0"; 6041 renderTopIssues([err?.message || "CSV inspection failed."]); 6042 renderDiscoveryCoverage(null); 6043 renderNextActions([]); 6044 if (successPanel) successPanel.style.display = "grid"; 6045 setNoReportMessageVisible(false); 6046 hideDownloadLinks(); 6047 resetCleanupAction("Generate a report first to unlock this review action."); 6048 if (statusEl) { 6049 statusEl.textContent = ""; 6050 statusEl.hidden = true; 6051 } 6052 setDetails(`CSV inspection failed.\n${err?.message || err}`); 6053 } finally { 6054 setPackageLoading(false, "upload"); 6055 } 6056 }); 6057 6058 runButton?.addEventListener("click", async () => { 6059 if (isSignedIn() && !entitlementState().canStartUrlExtraction) return; 6060 const url = normalizeSubmittedUrl(E("mvpUrlInput")?.value); 6061 const content = htmlInput?.value || ""; 6062 const fallbackUsed = Boolean(fallbackPanel?.open); 6063 const sourceType = fallbackUsed ? "html" : "url"; 6064 const pastedUrl = extractUrlFromHtml(content); 6065 const statusEl = E("mvpStatus"); 6066 const metricProducts = E("mvpMetricProducts"); 6067 const metricImportReady = E("mvpMetricImportReady"); 6068 const metricReviewRows = E("mvpMetricReviewRows"); 6069 const metricBlocked = E("mvpMetricBlocked"); 6070 const summaryMetricImportReady = E("mvpSummaryMetricImportReady"); 6071 const summaryMetricReviewRows = E("mvpSummaryMetricReviewRows"); 6072 const summaryMetricBlocked = E("mvpSummaryMetricBlocked"); 6073 const summaryMetricWarnings = E("mvpSummaryMetricWarnings"); 6074 const summaryMetricRejected = E("mvpSummaryMetricRejected"); 6075 const metricBlockingIssues = E("mvpMetricBlockingIssues"); 6076 const metricWarnings = E("mvpMetricWarnings"); 6077 const metricRejected = E("mvpMetricRejected"); 6078 if (!url) { 6079 if (statusEl) { 6080 statusEl.hidden = false; 6081 statusEl.textContent = "Add a category URL to start."; 6082 } 6083 return; 6084 } 6085 let parsedUrl; 6086 try { 6087 parsedUrl = new URL(url); 6088 if (!/^https?:$/.test(parsedUrl.protocol) || !parsedUrl.hostname) throw new Error("Invalid URL"); 6089 } catch { 6090 if (statusEl) { 6091 statusEl.hidden = false; 6092 statusEl.textContent = "Enter a valid HTTP or HTTPS URL."; 6093 } 6094 return; 6095 } 6096 const host = parsedUrl.hostname; 6097 if (fallbackUsed && !content.trim()) { 6098 if (statusEl) { 6099 statusEl.hidden = false; 6100 statusEl.textContent = "Upload or paste the saved HTML file to continue."; 6101 } 6102 return; 6103 } 6104 if (activeRunRecoveryPending) await activeRunRecoveryReady; 6105 const submissionUrlKey = comparableRunUrl(url); 6106 const duplicateRun = activeRunForUrl(url); 6107 if (activeSubmissionUrls.has(submissionUrlKey) || duplicateRun) { 6108 log("mvp_ingest_duplicate_prevented", { 6109 runId: duplicateRun?.runId || null, 6110 host: duplicateRun?.host || host, 6111 url: submissionUrlKey, 6112 observationState: duplicateRun?.state || "submitting", 6113 }); 6114 return; 6115 } 6116 activeSubmissionUrls.add(submissionUrlKey); 6117 const generation = ++foregroundGeneration; 6118 // Job pointers belong to the foreground, unlike the URL duplicate registry. 6119 activeInitialScanJobId = ""; 6120 activeDiscoveryJobId = ""; 6121 hideRecoveryChoice(); 6122 if (fallbackUsed && !pastedUrl) { 6123 log("mvp_html_url_missing", { hasUrl: Boolean(url), contentSize: String(content || "").length }); 6124 } else if (fallbackUsed) {
6125 try { 6126 const enteredHost = parsedUrl.hostname.toLowerCase(); 6127 const pastedHost = new URL(pastedUrl, url).hostname.toLowerCase(); 6128 if (enteredHost !== pastedHost) { 6129 log("mvp_html_url_host_mismatch", { enteredHost, pastedHost, pastedUrl }); 6130 } 6131 } catch {} 6132 } 6133 if (statusEl) { 6134 statusEl.textContent = ""; 6135 statusEl.hidden = true; 6136 } 6137 setUrlGenerationLoading(true); 6138 setScanActive(true); 6139 setImportQualityMetrics({ productsFound: 0, importReadyProducts: 0, warningCount: 0, blockingIssueCount: 0, blockedProducts: 0 }); 6140 if (successPanel && sourceType !== "url") successPanel.style.display = "none"; 6141 setNoReportMessageVisible(false); 6142 setResultSourceLabel(); 6143 hideDownloadLinks(); 6144 resetCleanupAction(); 6145 resetPreview(); 6146 renderDiscoveryCoverage(null); 6147 renderNextActions([]); 6148 setDetails("Product import package generation started."); 6149 const startedAt = Date.now(); 6150 const requestMeta = { foregroundGeneration: generation, sourceType, host, url, hasUrl: Boolean(url), contentSize: fallbackUsed ? String(content || "").length : 0, perf: { requestSubmittedAtMs: Date.now() } }; 6151 activePreScanLoading?.stop(); 6152 const initialScanLoading = sourceType === "url" ? startInitialScanLoading(startedAt) : null; 6153 activePreScanLoading = initialScanLoading; 6154 let submittedRunId = ""; 6155 log("mvp_ingest_started", requestMeta); 6156 try { 6157 const body = fallbackUsed 6158 ? { sourceType, host, url, content } 6159 : { sourceType, host, url }; 6160 Object.assign(body, discoveryNotificationPayload()); 6161 const ingestOptions = { 6162 method: "POST", 6163 headers: { "Content-Type": "application/json" }, 6164 body: JSON.stringify(body), 6165 }; 6166 const res = isSignedIn() 6167 ? await authenticatedFetch("/api/mvp/ingest", ingestOptions, { endpointType: "queue" }) 6168 : await fetch("/api/mvp/ingest", ingestOptions); 6169 let data = await parseResponsePayload(res); 6170 submittedRunId = String(data?.runId || ""); 6171 if (!isForeground(requestMeta)) { 6172 if (res.status === 202 && data?.jobId) trackActiveRunObserver({ runId: data.runId, sourceUrl: url, host, state: "observing" }); 6173 return; 6174 } 6175 if (data?.notificationError && discoveryNotificationStatus) { 6176 discoveryNotificationStatus.textContent = data.notificationError; 6177 discoveryNotificationStatus.hidden = false; 6178 } 6179 requestMeta.perf.jobAcceptedMs = Date.now() - requestMeta.perf.requestSubmittedAtMs; 6180 if (res.status === 202 && data?.jobId) { 6181 renderUsage(data?.usage); 6182 activeInitialScanJobId = data.jobId; 6183 trackActiveRunObserver({ runId: data.runId, sourceUrl: url, host, state: "submitting" }); 6184 try { 6185 window.localStorage?.setItem(ACTIVE_DISCOVERY_STORAGE_KEY, JSON.stringify({ 6186 jobId: data.jobId, runId: data.runId, status: data.status || "queued", createdAt: new Date().toISOString(), 6187 updatedAt: new Date().toISOString(), sourceUrl: url, host, productCount: 0, jobType: "mvp-url-run", 6188 })); 6189 } catch {} 6190 initialScanLoading.updateStatus(data.status || "queued"); 6191 log("mvp_ingest_queued", { ...requestMeta, requestId: data.requestId || null, runId: data.runId || null, jobId: data.jobId, elapsedMs: Date.now() - startedAt }); 6192 setDetails(JSON.stringify({ 6193 requestId: data.requestId || null, 6194 runId: data.runId || null, 6195 jobId: data.jobId, 6196 statusUrl: data.statusUrl || `/api/jobs/${data.jobId}`, 6197 status: data.status || "queued", 6198 }, null, 2)); 6199 data = await waitForMvpUrlJob( 6200 data.jobId, 6201 requestMeta, 6202 startedAt, 6203 20 * 60 * 1000, 6204 data.runId, 6205 data.statusUrl || "", 6206 data.requestId || "", 6207 data?.auth?.authenticated === true || data?.usage?.authenticated === true || isSignedIn(), 6208 (status, job, payload) => { 6209 assertForeground(requestMeta); 6210 initialScanLoading?.updateStatus(status); 6211 initialScanLoading?.updateDiscovery(job, payload); 6212 }, 6213 false 6214 ); 6215 data = await waitForFullCatalogChain(data, requestMeta, startedAt); 6216 } 6217 assertForeground(requestMeta); 6218 if (!res.ok || !data?.ok) throw new Error(data?.error || "Product extraction failed."); 6219 renderUsage(data?.usage); 6220 if (data?.discoveryJob?.state !== "discovering") { 6221 clearActiveRunObserver(submittedRunId); 6222 try { window.localStorage?.removeItem(ACTIVE_DISCOVERY_STORAGE_KEY); } catch {} 6223 }
6224 if (data?.status === "needs_capture") { 6225 const friendly = FRIENDLY_REASON[data?.reasonCode] || data?.message || STATE_COPY.capture.message; 6226 setResultSourceLabel(requestMeta); 6227 setStateCopy("capture"); 6228 if (fallbackPanel) fallbackPanel.open = true; 6229 if (stateMessage) stateMessage.textContent = friendly; 6230 if (stateHint) { 6231 stateHint.textContent = STATE_COPY.capture.hint; 6232 stateHint.hidden = false; 6233 } 6234 const hasCatalogSources = Boolean((data?.customerSummary?.discovery_coverage || data?.discoveryCoverage)?.catalog_files?.length); 6235 const captureViewModel = deriveMvpResultViewModel(data, requestMeta); 6236 const captureProductsFound = hasCatalogSources ? captureViewModel.productsCollected : Number(data?.summary?.productsFound || 0); 6237 const captureImportReady = Number(data?.summary?.importReadyProducts || 0); 6238 const captureBlocked = Number(data?.summary?.blockedProducts || 0); 6239 const captureBlockingIssues = Number(data?.summary?.blockingIssueCount || 0); 6240 const captureWarnings = Number(data?.summary?.warningCount || 0); 6241 const captureRejected = Number(data?.summary?.rejectedRows || 0); 6242 setImportQualityMetrics({ 6243 productsFound: captureProductsFound, 6244 importReadyProducts: captureImportReady, 6245 warningCount: captureWarnings, 6246 blockingIssueCount: captureBlockingIssues, 6247 blockedProducts: captureBlocked, 6248 }); 6249 if (metricProducts) metricProducts.textContent = String(captureProductsFound); 6250 if (metricImportReady) metricImportReady.textContent = String(Number(data?.summary?.importReadyProducts || 0)); 6251 if (metricReviewRows) metricReviewRows.textContent = "0"; 6252 if (metricBlocked) metricBlocked.textContent = String(Number(data?.summary?.blockedProducts || 0)); 6253 if (summaryMetricImportReady) summaryMetricImportReady.textContent = String(Number(data?.summary?.importReadyProducts || 0)); 6254 if (summaryMetricReviewRows) summaryMetricReviewRows.textContent = "0"; 6255 if (summaryMetricBlocked) summaryMetricBlocked.textContent = String(Number(data?.summary?.blockedProducts || 0)); 6256 if (metricBlockingIssues) metricBlockingIssues.textContent = String(Number(data?.summary?.blockingIssueCount || 0)); 6257 if (metricWarnings) metricWarnings.textContent = String(Number(data?.summary?.warningCount || 0)); 6258 if (metricRejected) metricRejected.textContent = String(Number(data?.summary?.rejectedRows || 0)); 6259 if (summaryMetricWarnings) summaryMetricWarnings.textContent = String(captureWarnings); 6260 if (summaryMetricRejected) summaryMetricRejected.textContent = String(captureRejected); 6261 renderTopIssues([friendly]); 6262 resetPreview(); 6263 renderDiscoveryCoverage(discoveryModelFromResponse(data)); 6264 if (hasCatalogSources) { 6265 renderZeroExtractionDiagnostic(deriveMvpResultViewModel(data, requestMeta), requestMeta.sourceType === "url", data); 6266 if (E("mvpZeroExtractionDiagnostic")?.hidden === false) { 6267 if (stateTitle) { stateTitle.textContent = ""; stateTitle.hidden = true; } 6268 if (stateMessage) { stateMessage.textContent = ""; stateMessage.hidden = true; } 6269 if (resultBanner) { resultBanner.textContent = ""; resultBanner.hidden = true; } 6270 if (stateHint) stateHint.hidden = true; 6271 if (fallbackPanel) fallbackPanel.open = false; 6272 renderTopIssues([]); 6273 } 6274 } 6275 renderNextActions([]); 6276 hideDownloadLinks(); 6277 resetCleanupAction("Generate a report first to unlock this review action."); 6278 if (successPanel) successPanel.style.display = "grid"; 6279 setNoReportMessageVisible(false); 6280 if (statusEl) { 6281 statusEl.textContent = ""; 6282 statusEl.hidden = true; 6283 } 6284 setScanActive(false, { keepIntentHidden: true }); 6285 log("mvp_ingest_needs_capture", { ...requestMeta, requestId: data.requestId || null, reasonCode: data.reasonCode || null, elapsedMs: Date.now() - startedAt }); 6286 setDetails(JSON.stringify({ 6287 requestId: data.requestId || null, 6288 status: hasCatalogSources ? "catalog_sources_detected" : data.status, 6289 reasonCode: data.reasonCode || "", 6290 summary: { ...data.summary, productsFound: captureProductsFound }, 6291 message: hasCatalogSources ? "Website extraction produced limited usable product data. Review the catalog sources below." : data.message || friendly, 6292 nextAction: hasCatalogSources ? "review_catalog_sources" : data.nextAction || "", 6293 }, null, 2)); 6294 return; 6295 } 6296 renderCompletedPackage(data, requestMeta, startedAt); 6297 if (sourceType === "url" && isSignedIn()) await loadRunHistory(); 6298 } catch (err) { 6299 if (!isForeground(requestMeta)) { 6300 if (submittedRunId) trackActiveRunObserver({ runId: submittedRunId, sourceUrl: url, host, state: "observing" }); 6301 return; 6302 } 6303 setScanActive(false, { keepIntentHidden: true }); 6304 clearActiveRunObserver(submittedRunId); 6305 const deliveryCode = ["result_unavailable", "poll_timeout", "status_read_failed", "session_expired", "result_access_de
6305nied"].includes(err?.code) 6306 ? err.code 6307 : ""; 6308 log("mvp_ingest_failed", { 6309 ...requestMeta, 6310 reasonCode: err?.reasonCode || deliveryCode || undefined, 6311 error: err?.message || String(err), 6312 httpStatus: err?.httpStatus || undefined, 6313 requestId: err?.requestId || null, 6314 runId: err?.runId || null, 6315 jobId: err?.jobId || null, 6316 endpointType: err?.endpointType || undefined, 6317 elapsedMs: Date.now() - startedAt, 6318 }); 6319 setResultSourceLabel(requestMeta); 6320 if (deliveryCode) { 6321 if (stateTitle) stateTitle.textContent = "Import package could not be loaded"; 6322 if (stateMessage) stateMessage.textContent = err?.message || "Extraction may have completed. Your run remains available under My runs."; 6323 if (resultBanner) resultBanner.textContent = err?.httpStatus === 401 6324 ? (err?.authenticatedRequest ? "Sign in again to load this package." : "This package belongs to a different browser session.") 6325 : "The package result is temporarily unavailable."; 6326 } else { 6327 setStateCopy("failure"); 6328 } 6329 if (issueSummaryTitle) issueSummaryTitle.textContent = "Blocking errors"; 6330 setImportQualityMetrics({ productsFound: 0, importReadyProducts: 0, warningCount: 0, blockingIssueCount: 1, blockedProducts: 0 }); 6331 if (metricProducts) metricProducts.textContent = "0"; 6332 if (metricImportReady) metricImportReady.textContent = "0"; 6333 if (metricReviewRows) metricReviewRows.textContent = "0"; 6334 if (metricBlocked) metricBlocked.textContent = "0"; 6335 if (summaryMetricImportReady) summaryMetricImportReady.textContent = "0"; 6336 if (summaryMetricReviewRows) summaryMetricReviewRows.textContent = "0"; 6337 if (summaryMetricBlocked) summaryMetricBlocked.textContent = "0"; 6338 if (metricBlockingIssues) metricBlockingIssues.textContent = "0"; 6339 if (metricWarnings) metricWarnings.textContent = "0"; 6340 if (metricRejected) metricRejected.textContent = "0"; 6341 if (summaryMetricWarnings) summaryMetricWarnings.textContent = "0"; 6342 if (summaryMetricRejected) summaryMetricRejected.textContent = "0"; 6343 renderTopIssues([deliveryCode ? `${err.message} (${deliveryCode})` : "No usable products found."]); 6344 renderDiscoveryCoverage(null); 6345 renderNextActions([]); 6346 if (successPanel) successPanel.style.display = "grid"; 6347 setNoReportMessageVisible(false); 6348 hideDownloadLinks(); 6349 resetCleanupAction("Generate a report first to unlock this review action."); 6350 if (statusEl) { 6351 statusEl.textContent = ""; 6352 statusEl.hidden = true; 6353 } 6354 setDetails(deliveryCode 6355 ? `Result delivery failed.\n${err?.message || err}\nError code: ${deliveryCode}` 6356 : `Product extraction failed.\n${err?.message || err}`); 6357 } finally { 6358 activeSubmissionUrls.delete(submissionUrlKey); 6359 if (!isForeground(requestMeta)) return; 6360 initialScanLoading?.stop(); 6361 if (activePreScanLoading === initialScanLoading) activePreScanLoading = null; 6362 activeInitialScanJobId = ""; 6363 setUrlGenerationLoading(false); 6364 } 6365 }); 6366 6367 intentInputs.forEach((input) => { 6368 input.addEventListener("change", () => { 6369 if (input.checked) setMvpIntent(input.value); 6370 }); 6371 }); 6372 6373 const restoreCheckoutScan = () => {
6374 let checkout = ""; 6375 try { 6376 checkout = new URL(window.location.href).searchParams.get("checkout") || ""; 6377 } catch {} 6378 if (!checkout) return; 6379 try { 6380 const saved = JSON.parse(window.sessionStorage?.getItem("fsMvpCheckoutScan") || "null"); 6381 window.sessionStorage?.removeItem("fsMvpCheckoutScan"); 6382 if (!saved || typeof saved !== "object") return; 6383 if (urlInput && typeof saved.url === "string") urlInput.value = saved.url; 6384 if (saved.intent === "visibility" || saved.intent === "extraction") setMvpIntent(saved.intent, { renderResult: false }); 6385 if (saved.inputMode === "upload" || saved.inputMode === "url") setInputMode(saved.inputMode); 6386 const completedPackage = saved.completedPackage; 6387 if (completedPackage?.data && completedPackage?.requestMeta) { 6388 renderCompletedPackage(completedPackage.data, completedPackage.requestMeta, Date.now(), "mvp_checkout_scan_restored"); 6389 } 6390 renderCheckoutRetentionMessage(); 6391 } catch {} 6392 }; 6393 6394 appInitCompletedMs = Math.round(performance.now() - appInitStartedAt); 6395 setMvpIntent("extraction", { renderResult: false }); 6396 prefillUrlFromLocation(); 6397 if (!claimPreviewOnlyFromLocation()) restoreCheckoutScan(); 6398 void initAuth().finally(async () => { 6399 try { 6400 if (!claimPreviewOnlyFromLocation()) { 6401 await claimCurrentAnonymousRun(); 6402 await handleCheckoutReturn(); 6403 await checkForRecoveryChoice(); 6404 await openLinkedDiscoveryResult(); 6405 } 6406 await loadUsage(); 6407 await previewSalesAgentRun(); 6408 if (!claimPreviewOnlyFromLocation()) await claimSalesAgentRun(); 6409 } finally { 6410 markActiveRunRecoveryReady(); 6411 } 6412 }); 6413})();
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.