1/* FlutterGo.AI marketing site â shared interactions (no dependencies). 2 Theme is bootstrapped inline in each page <head> to avoid a flash; this 3 file owns the toggle, persistence, and all scroll/motion behaviour. */ 4(function () { 5 'use strict'; 6 7 var root = document.documentElement; 8 var prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; 9 10 /* ---- Mobile app / Website choice in the hero composer ---------------- */ 11 // Mirrors the Home composer's BuildTargetChips. The pick travels with the 12 // prompt as the guest intent's `template` ('mobile-app' | 'website') and, 13 // for signed-in visitors, straight onto the new project's metadata. 14 function guestBuildTarget() { 15 var chip = document.querySelector('[data-guest-build-target][aria-checked="true"]'); 16 return chip && chip.getAttribute('data-guest-build-target') === 'website' ? 'website' : 'mobile'; 17 } 18 function guestIntentTemplate() { 19 return guestBuildTarget() === 'website' ? 'website' : 'mobile-app'; 20 } 21 document.addEventListener('click', function (event) { 22 var chip = event.target && event.target.closest ? event.target.closest('[data-guest-build-target]') : null; 23 if (!chip) return; 24 var group = chip.closest('[data-guest-build-target-group]'); 25 if (!group) return; 26 var chips = group.querySelectorAll('[data-guest-build-target]'); 27 for (var i = 0; i < chips.length; i += 1) { 28 var on = chips[i] === chip; 29 chips[i].setAttribute('aria-checked', on ? 'true' : 'false'); 30 chips[i].classList.toggle('is-selected', on); 31 } 32 var target = chip.getAttribute('data-guest-build-target'); 33 var form = chip.closest('[data-guest-prompt-form]'); 34 var input = form && form.querySelector('[data-guest-prompt-input]'); 35 if (input) { 36 if (!input.hasAttribute('data-mobile-placeholder')) { 37 input.setAttribute('data-mobile-placeholder', input.getAttribute('placeholder') || ''); 38 } 39 input.setAttribute( 40 'placeholder', 41 target === 'website' 42 ? 'Describe the website you want: the pages, the content, how it should look. We build it.' 43 : input.getAttribute('data-mobile-placeholder'), 44 ); 45 } 46 try { 47 if (window.__odTrack) window.__odTrack('landing_build_target_selected', { build_target: target }); 48 } catch (e) {} 49 }); 50 51 /* ---- Landing visitor beacon (unique people + country for admin) ----- */ 52 function trackClarityFunnel(event, source, detail) { 53 try { 54 if (typeof window.clarity !== 'function') return; 55 window.clarity('event', event); 56 window.clarity('set', 'funnel_step', event); 57 if (source) window.clarity('set', 'funnel_source', String(source).slice(0, 64)); 58 if (detail) window.clarity('set', 'funnel_detail', String(detail).slice(0, 100)); 59 } catch (e) {} 60 } 61 62 function trackLandingFunnel(event, source, detail) { 63 try { 64 var payload = JSON.stringify({ 65 event: event, 66 source: source || null, 67 detail: detail || null, 68 }); 69 fetch('/api/landing-funnel', { 70 method: 'POST', 71 credentials: 'include', 72 headers: { 'content-type': 'application/json' }, 73 body: payload, 74 keepalive: true, 75 }).catch(function () {}); 76 // Mirror to GTM / Meta when present. 77 try { 78 window.dataLayer = window.dataLayer || []; 79 window.dataLayer.push({ event: event, funnel_source: source || null }); 80 } catch (e) {} 81 // Mirror to Microsoft Clarity (session recordings / smart events). 82 trackClarityFunnel(event, source, detail); 83 } catch (e) {} 84 } 85 86 (function pingLandingVisit() { 87 try { 88 if (window.location.pathname.indexOf('/admin') === 0) return; 89 // Only marketing/static surfaces â skip the authenticated app shell. 90 var path = window.location.pathname || '/'; 91 if (path.indexOf('/home') === 0 || path.indexOf('/projects') === 0) return; 92 if (path.indexOf('/api/') === 0) return; 93 var body = JSON.stringify({ 94 path: path + (window.location.search || ''), 95 referrer: document.referrer ? String(document.referrer).slice(0, 300) : '', 96 }); 97 fetch('/api/landing-visits', { 98 method: 'POST', 99 credentials: 'include', 100 headers: { 'content-type': 'application/json' }, 101 body: body, 102 keepalive: true, 103 }).catch(function () {}); 104 } catch (e) {} 105 })(); 106 107 /* ---- Theme toggle + persistence ------------------------------------- */ 108 function systemTheme() { 109 return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; 110 }
111 function currentTheme() { 112 return root.getAttribute('data-theme') || systemTheme(); 113 } 114 function applyTheme(theme) { 115 root.setAttribute('data-theme', theme); 116 try { localStorage.setItem('fg-theme', theme); } catch (e) {} 117 // The app (studio + /docs) keeps its theme in `open-design:config`, a 118 // different key. Without mirroring the choice there, toggling dark on the 119 // marketing site left /docs rendering in light â which looked broken. 120 try { 121 var raw = localStorage.getItem('open-design:config'); 122 var cfg = raw ? JSON.parse(raw) : {}; 123 if (cfg && typeof cfg === 'object') { 124 cfg.theme = theme; 125 localStorage.setItem('open-design:config', JSON.stringify(cfg)); 126 } 127 } catch (e) {} 128 window.dispatchEvent(new CustomEvent('fg:theme', { detail: { theme: theme } })); 129 } 130 document.querySelectorAll('[data-theme-toggle]').forEach(function (btn) { 131 btn.addEventListener('click', function () { 132 applyTheme(currentTheme() === 'dark' ? 'light' : 'dark'); 133 }); 134 }); 135 136 // Product page is unpublished from chrome â drop nav/footer links so they 137 // don't resurface from static HTML templates that still list /product/. 138 document.querySelectorAll( 139 'a[href="/product/"], a[href="/product"], a[href="/product/index.html"]', 140 ).forEach(function (el) { 141 el.hidden = true; 142 el.setAttribute('aria-hidden', 'true'); 143 el.style.display = 'none'; 144 }); 145 146 // React to OS theme change only while the user hasn't pinned one. 147 try { 148 window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function (e) { 149 if (!localStorage.getItem('fg-theme')) { 150 root.setAttribute('data-theme', e.matches ? 'dark' : 'light'); 151 window.dispatchEvent(new CustomEvent('fg:theme', { detail: { theme: e.matches ? 'dark' : 'light' } })); 152 } 153 }); 154 } catch (e) {} 155 156 /* ---- Ambient page background (sparks + soft rings) ------------------- */ 157 function initPageBackground() { 158 var bg = document.querySelector('.fgs-bg'); 159 if (!bg || bg.dataset.fgBgInit) return; 160 bg.dataset.fgBgInit = '1'; 161 if (prefersReduced) return; 162 163 var sparkColors = ['var(--spark-blue)', 'var(--spark-sky)', 'var(--spark-violet)']; 164 var i; 165 for (i = 0; i < 28; i += 1) { 166 var spark = document.createElement('span'); 167 spark.className = 'fgs-bg__spark'; 168 spark.style.setProperty('--x', (4 + Math.random() * 92).toFixed(2) + '%'); 169 spark.style.setProperty('--y', (6 + Math.random() * 88).toFixed(2) + '%'); 170 spark.style.setProperty('--sz', (4 + Math.random() * 7).toFixed(1) + 'px'); 171 spark.style.setProperty('--spark-color', sparkColors[i % sparkColors.length]); 172 spark.style.setProperty('--spark-opacity', (0.28 + Math.random() * 0.38).toFixed(2)); 173 spark.style.setProperty('--dur', (14 + Math.random() * 18).toFixed(1) + 's'); 174 spark.style.setProperty('--delay', (-Math.random() * 20).toFixed(1) + 's'); 175 bg.appendChild(spark); 176 } 177 for (i = 0; i < 6; i += 1) { 178 var ring = document.createElement('span'); 179 ring.className = 'fgs-bg__ring'; 180 var ringSize = 80 + Math.random() * 160; 181 ring.style.setProperty('--x', (10 + Math.random() * 80).toFixed(2) + '%'); 182 ring.style.setProperty('--y', (8 + Math.random() * 84).toFixed(2) + '%'); 183 ring.style.setProperty('--ring', ringSize.toFixed(0) + 'px'); 184 ring.style.setProperty('--ring-color', sparkColors[i % sparkColors.length]); 185 ring.style.setProperty('--ring-opacity', (0.35 + Math.random() * 0.35).toFixed(2)); 186 ring.style.setProperty('--dur', (20 + Math.random() * 16).toFixed(1) + 's'); 187 ring.style.setProperty('--delay', (-Math.random() * 24).toFixed(1) + 's'); 188 bg.appendChild(ring); 189 } 190 } 191 initPageBackground(); 192 193 /* ---- Year stamp ------------------------------------------------------ */ 194 document.querySelectorAll('[data-year]').forEach(function (el) { 195 el.textContent = String(new Date().getFullYear()); 196 }); 197 198 /* ---- Sticky nav state ------------------------------------------------ */ 199 var nav = document.querySelector('.fgs-nav'); 200 if (nav) { 201 var onScroll = function () { nav.classList.toggle('is-stuck', window.scrollY > 12); }; 202 onScroll(); 203 window.addEventListener('scroll', onScroll, { passive: true }); 204 } 205 206 /* ---- Mobile drawer --------------------------------------------------- */ 207 var burger = document.querySelector('[data-burger]'); 208 var drawer = document.querySelector('[data-mobile]'); 209 if (burger && drawer) { 210 var setOpen = function (open) {
211 drawer.classList.toggle('is-open', open); 212 burger.setAttribute('aria-expanded', String(open)); 213 document.body.style.overflow = open ? 'hidden' : ''; 214 }; 215 burger.addEventListener('click', function () { 216 setOpen(!drawer.classList.contains('is-open')); 217 }); 218 drawer.querySelectorAll('a').forEach(function (a) { 219 a.addEventListener('click', function () { setOpen(false); }); 220 }); 221 } 222 223 /* ---- Scroll reveal --------------------------------------------------- */ 224 var revealEls = document.querySelectorAll('.reveal'); 225 if (prefersReduced || !('IntersectionObserver' in window)) { 226 revealEls.forEach(function (el) { el.classList.add('is-in'); }); 227 } else { 228 var io = new IntersectionObserver(function (entries) { 229 entries.forEach(function (en) { 230 if (en.isIntersecting) { en.target.classList.add('is-in'); io.unobserve(en.target); } 231 }); 232 }, { threshold: 0.12, rootMargin: '0px 0px -8% 0px' }); 233 revealEls.forEach(function (el) { io.observe(el); }); 234 } 235 236 /* ---- Features showcase: spring tilt + lift --------------------------- */ 237 (function initFeaturesShowcase() { 238 var section = document.querySelector('[data-fgx]'); 239 if (!section || prefersReduced) return; 240 if (!window.matchMedia('(pointer: fine)').matches) return; 241 242 var cards = Array.prototype.slice.call(section.querySelectorAll('[data-fgx-card]')); 243 cards.forEach(function (card) { 244 var rx = 0, ry = 0, tx = 0, ty = 0, raf = 0; 245 var active = false; 246 247 function tick() { 248 rx += (tx - rx) * 0.14; 249 ry += (ty - ry) * 0.14; 250 card.style.transform = 251 'translate3d(0, -8px, 0) scale(1.012) rotateX(' + 252 rx.toFixed(2) + 'deg) rotateY(' + ry.toFixed(2) + 'deg)'; 253 if (Math.abs(tx - rx) > 0.02 || Math.abs(ty - ry) > 0.02 || active) { 254 raf = requestAnimationFrame(tick); 255 } else { 256 raf = 0; 257 card.style.transform = ''; 258 } 259 } 260 261 card.addEventListener('pointerenter', function () { 262 active = true; 263 card.classList.add('is-lifted'); 264 if (!raf) raf = requestAnimationFrame(tick); 265 }); 266 267 card.addEventListener('pointermove', function (e) { 268 var rect = card.getBoundingClientRect(); 269 var px = (e.clientX - rect.left) / rect.width - 0.5; 270 var py = (e.clientY - rect.top) / rect.height - 0.5; 271 ty = px * 7; 272 tx = py * -5.5; 273 if (!raf) raf = requestAnimationFrame(tick); 274 }); 275 276 card.addEventListener('pointerleave', function () { 277 active = false; 278 card.classList.remove('is-lifted'); 279 tx = 0; 280 ty = 0; 281 if (!raf) raf = requestAnimationFrame(tick); 282 }); 283 }); 284 })(); 285 286 /* ---- Final CTA: magnetic primary + float parallax -------------------- */ 287 (function initFinalCta() { 288 var section = document.querySelector('[data-fgc]'); 289 if (!section || prefersReduced) return; 290 291 var magnet = section.querySelector('[data-fgc-magnet]'); 292 var fine = window.matchMedia('(pointer: fine)').matches; 293 294 if (magnet && fine) { 295 var mx = 0, my = 0, cx = 0, cy = 0, raf = 0, hovering = false; 296 297 function tick() { 298 cx += (mx - cx) * 0.18; 299 cy += (my - cy) * 0.18; 300 magnet.style.transform = 301 'translate3d(' + cx.toFixed(2) + 'px,' + (cy - 3).toFixed(2) + 'px,0) scale(1.04)'; 302 if (hovering || Math.abs(mx - cx) > 0.15 || Math.abs(my - cy) > 0.15) { 303 raf = requestAnimationFrame(tick); 304 } else { 305 raf = 0; 306 magnet.style.transform = ''; 307 } 308 } 309 310 magnet.addEventListener('pointerenter', function () { 311 hovering = true; 312 magnet.classList.add('is-magnet'); 313 if (!raf) raf = requestAnimationFrame(tick); 314 }); 315 316 magnet.addEventListener('pointermove', function (e) { 317 var rect = magnet.getBoundingClientRect(); 318 mx = (e.clientX - (rect.left + rect.width / 2)) * 0.22; 319 my = (e.clientY - (rect.top + rect.height / 2)) * 0.22; 320 if (!raf) raf = requestAnimationFrame(tick); 321 }); 322 323 magnet.addEventListener('pointerleave', function () { 324 hovering = false;
325 magnet.classList.remove('is-magnet'); 326 mx = 0; 327 my = 0; 328 if (!raf) raf = requestAnimationFrame(tick); 329 }); 330 } 331 332 if (!fine) return; 333 var floats = Array.prototype.slice.call(section.querySelectorAll('[data-fgc-float]')); 334 if (!floats.length) return; 335 var px = 0, py = 0, sx = 0, sy = 0, fraf = 0; 336 337 function floatTick() { 338 sx += (px - sx) * 0.06; 339 sy += (py - sy) * 0.06; 340 floats.forEach(function (el, i) { 341 var d = 0.35 + i * 0.12; 342 el.style.translate = (sx * 14 * d).toFixed(1) + 'px ' + (sy * 10 * d).toFixed(1) + 'px'; 343 }); 344 if (Math.abs(px - sx) > 0.001 || Math.abs(py - sy) > 0.001) { 345 fraf = requestAnimationFrame(floatTick); 346 } else { 347 fraf = 0; 348 } 349 } 350 351 section.addEventListener('pointermove', function (e) { 352 var rect = section.getBoundingClientRect(); 353 px = ((e.clientX - rect.left) / rect.width - 0.5) * 2; 354 py = ((e.clientY - rect.top) / rect.height - 0.5) * 2; 355 if (!fraf) fraf = requestAnimationFrame(floatTick); 356 }); 357 })(); 358 359 /* ---- Pricing plan icons: pointer 3D tilt ---------------------------- */ 360 (function initPlanIconTilt() { 361 if (prefersReduced || !window.matchMedia('(pointer: fine)').matches) return; 362 var icons = Array.prototype.slice.call(document.querySelectorAll('[data-plan-tilt]')); 363 if (!icons.length) return; 364 icons.forEach(function (icon) { 365 icon.addEventListener('pointermove', function (e) { 366 var r = icon.getBoundingClientRect(); 367 if (!r.width || !r.height) return; 368 var px = (e.clientX - r.left) / r.width - 0.5; 369 var py = (e.clientY - r.top) / r.height - 0.5; 370 icon.style.setProperty('--rx', (18 - py * 12).toFixed(2) + 'deg'); 371 icon.style.setProperty('--ry', (-22 + px * 18).toFixed(2) + 'deg'); 372 }); 373 icon.addEventListener('pointerleave', function () { 374 icon.style.setProperty('--rx', '18deg'); 375 icon.style.setProperty('--ry', '-22deg'); 376 }); 377 }); 378 })(); 379 380 /* ---- Credit packs: select + per-card 3D tilt ------------------------ */ 381 (function initCreditPacks() { 382 var section = document.querySelector('[data-fgp]'); 383 if (!section) return; 384 var cards = Array.prototype.slice.call(section.querySelectorAll('[data-fgp-card]')); 385 if (!cards.length) return; 386 var finePointer = window.matchMedia('(pointer: fine)').matches; 387 388 function select(card) { 389 cards.forEach(function (c) { 390 var on = c === card; 391 c.classList.toggle('is-selected', on); 392 c.setAttribute('aria-checked', on ? 'true' : 'false'); 393 c.tabIndex = on ? 0 : -1; 394 }); 395 } 396 397 function resetTilt(card) { 398 card.style.setProperty('--rx', '0deg'); 399 card.style.setProperty('--ry', '0deg'); 400 } 401 402 cards.forEach(function (card) { 403 if (!card.classList.contains('is-selected')) card.tabIndex = -1; 404 card.addEventListener('click', function () { select(card); }); 405 card.addEventListener('keydown', function (e) { 406 var i = cards.indexOf(card); 407 if (e.key === 'Enter' || e.key === ' ') { 408 e.preventDefault(); 409 select(card); 410 } else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { 411 e.preventDefault(); 412 select(cards[(i + 1) % cards.length]); 413 cards[(i + 1) % cards.length].focus(); 414 } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { 415 e.preventDefault(); 416 select(cards[(i - 1 + cards.length) % cards.length]); 417 cards[(i - 1 + cards.length) % cards.length].focus(); 418 } 419 }); 420 421 if (prefersReduced || !finePointer) return; 422 423 card.addEventListener('pointermove', function (e) { 424 var r = card.getBoundingClientRect(); 425 if (!r.width || !r.height) return; 426 var px = (e.clientX - r.left) / r.width - 0.5; 427 var py = (e.clientY - r.top) / r.height - 0.5; 428 card.style.setProperty('--rx', (-py * 11).toFixed(2) + 'deg'); 429 card.style.setProperty('--ry', (px * 14).toFixed(2) + 'deg'); 430 }); 431 card.addEventListener('pointerleave', function () { resetTilt(card); }); 432 card.addEventListener('blur', function () { resetTilt(card); }); 433 }); 434 })(); 435 436 /* ---- Pointer parallax / tilt ---------------------------------------- */ 437 if (!prefersReduced && window.matchMedia('(pointer: fine)').matches) { 438 var tiltEls = Array.prototype.slice.call(document.querySelectorAll('[data-tilt]')); 439 var floatEls = Array.prototype.slice.call(document.querySelectorAll('[data-parallax]')); 440 var tx = 0, ty = 0, cx = 0, cy = 0, raf = 0; 441 function loop() { 442 cx += (tx - cx) * 0.08; cy += (ty - cy) * 0.08;
443 tiltEls.forEach(function (el) { 444 var rx = (parseFloat(el.dataset.rx) || 8) - cy * 6; 445 var ry = (parseFloat(el.dataset.ry) || -14) + cx * 10; 446 el.style.transform = 'rotateX(' + rx.toFixed(2) + 'deg) rotateY(' + ry.toFixed(2) + 'deg)'; 447 }); 448 floatEls.forEach(function (el) { 449 var d = parseFloat(el.dataset.parallax) || 1; 450 el.style.transform = 'translate3d(' + (cx * 22 * d).toFixed(1) + 'px,' + (cy * 22 * d).toFixed(1) + 'px,0)'; 451 }); 452 if (Math.abs(tx - cx) > 0.001 || Math.abs(ty - cy) > 0.001) { raf = requestAnimationFrame(loop); } 453 else { raf = 0; } 454 } 455 window.addEventListener('pointermove', function (e) { 456 tx = (e.clientX / window.innerWidth - 0.5) * 2; 457 ty = (e.clientY / window.innerHeight - 0.5) * 2; 458 if (!raf) raf = requestAnimationFrame(loop); 459 }, { passive: true }); 460 } 461 462 /* ---- Pricing billing toggle (monthly / quarterly) -------------------- */ 463 var billing = document.querySelector('[data-billing]'); 464 if (billing) { 465 var setCycle = function (cycle) { 466 billing.querySelectorAll('button').forEach(function (b) { 467 b.classList.toggle('is-active', b.dataset.cycle === cycle); 468 }); 469 document.querySelectorAll('[data-price-monthly]').forEach(function (el) { 470 var monthly = el.getAttribute('data-price-monthly'); 471 var quarterly = el.getAttribute('data-price-quarterly'); 472 var next = cycle === 'quarterly' && quarterly ? quarterly : monthly; 473 if (typeof window.__fgSetPrice === 'function') { 474 window.__fgSetPrice(el, next, cycle); 475 } else { 476 el.textContent = next; 477 } 478 }); 479 document.querySelectorAll('[data-per-monthly]').forEach(function (el) { 480 var monthly = el.getAttribute('data-per-monthly'); 481 var quarterly = el.getAttribute('data-per-quarterly'); 482 el.textContent = cycle === 'quarterly' && quarterly ? quarterly : monthly; 483 }); 484 document.querySelectorAll('[data-billing-href-monthly]').forEach(function (link) { 485 var monthly = link.getAttribute('data-billing-href-monthly'); 486 var quarterly = link.getAttribute('data-billing-href-quarterly'); 487 link.setAttribute('href', cycle === 'quarterly' && quarterly ? quarterly : monthly); 488 }); 489 document.querySelectorAll('[data-save-quarterly]').forEach(function (el) { 490 var text = el.getAttribute('data-save-quarterly') || ''; 491 if (cycle === 'quarterly' && text) { 492 el.textContent = text; 493 el.hidden = false; 494 } else { 495 el.textContent = ''; 496 el.hidden = true; 497 } 498 }); 499 }; 500 billing.querySelectorAll('button').forEach(function (b) { 501 b.addEventListener('click', function () { setCycle(b.dataset.cycle); }); 502 }); 503 } 504 505 /* Pricing Upgrade: stay on this page unless redirecting to Polar or sign-in. */ 506 var PENDING_BILLING_KEY = 'fg.pendingBilling'; 507 var billingNotice = document.querySelector('[data-billing-notice]'); 508 509 function showBillingNotice(message, kind) { 510 if (!billingNotice) return; 511 billingNotice.textContent = message; 512 billingNotice.classList.remove('is-error', 'is-info'); 513 billingNotice.classList.add(kind === 'error' ? 'is-error' : 'is-info'); 514 billingNotice.hidden = false; 515 } 516 517 function clearBillingNotice() { 518 if (!billingNotice) return; 519 billingNotice.hidden = true; 520 billingNotice.textContent = ''; 521 billingNotice.classList.remove('is-error', 'is-info'); 522 } 523 524 function activeBillingCycle() { 525 var billingEl = document.querySelector('[data-billing]'); 526 if (!billingEl) return 'monthly'; 527 var activeBtn = billingEl.querySelector('button.is-active'); 528 return activeBtn && activeBtn.dataset.cycle === 'quarterly' ? 'quarterly' : 'monthly'; 529 } 530 531 function isCustomerAccount(user) { 532 if (!user || !user.id) return false; 533 // Loopback Local Mode owner is a machine identity â not a signed-up customer. 534 if (user.id === 'fluttergo-local-owner') return false; 535 if (user.email === '[email protected]') return false; 536 if (user.accountType && user.accountType !== 'registered') return false;
537 return true; 538 } 539 540 function isRegisteredBillingUser(user) { 541 return isCustomerAccount(user); 542 } 543 544 /** After login, App.tsx on /home resumes Polar checkout from ?billing=. */ 545 function checkoutResumeHomePath(planId, cycle) { 546 var params = new URLSearchParams({ billing: 'checkout', plan: planId || 'pro' }); 547 if (cycle === 'quarterly') params.set('interval', 'quarterly'); 548 return '/projects?' + params.toString(); 549 } 550 551 function signInCheckoutPath(planId, cycle) { 552 var next = checkoutResumeHomePath(planId, cycle); 553 return '/onboarding/sign-in?next=' + encodeURIComponent(next); 554 } 555 556 function savePendingCheckout(planId, cycle) { 557 var intent = { 558 action: 'checkout', 559 plan: planId || 'pro', 560 interval: cycle === 'quarterly' ? 'quarterly' : 'monthly', 561 }; 562 try { localStorage.setItem(PENDING_BILLING_KEY, JSON.stringify(intent)); } catch (err) {} 563 try { sessionStorage.setItem(PENDING_BILLING_KEY, JSON.stringify(intent)); } catch (err) {} 564 } 565 566 function askLoginBeforeCheckout(planId, cycle, trigger) { 567 savePendingCheckout(planId, cycle); 568 setUpgradeLoading(trigger, false); 569 showBillingNotice('Please sign in first to continue checkout. We will bring you back here after login.', 'info'); 570 window.setTimeout(function () { 571 window.location.href = signInCheckoutPath(planId, cycle); 572 }, 450); 573 } 574 575 function setUpgradeLoading(link, loading) { 576 if (!link) return; 577 if (typeof window.__fgUpgradeLoading === 'function') { 578 window.__fgUpgradeLoading(link, loading); 579 return; 580 } 581 link.classList.toggle('is-loading', loading); 582 if (loading) { 583 if (!link.dataset.fgLabel) link.dataset.fgLabel = link.textContent || ''; 584 link.textContent = 'Opening checkoutâ¦'; 585 } else if (link.dataset.fgLabel) { 586 link.textContent = link.dataset.fgLabel; 587 } 588 } 589 590 function checkoutErrorMessage(payload) { 591 if (payload && payload.error && payload.error.message) return payload.error.message; 592 return 'Checkout is temporarily unavailable. Please try again in a moment.'; 593 } 594 595 function startPolarCheckout(planId, cycle, trigger) { 596 clearBillingNotice(); 597 setUpgradeLoading(trigger, true); 598 var params = new URLSearchParams({ plan: planId || 'pro' }); 599 if (cycle === 'quarterly') params.set('interval', 'quarterly'); 600 return fetch('/api/billing/checkout?' + params.toString(), { credentials: 'include' }) 601 .then(function (resp) { 602 return resp.json().catch(function () { return {}; }).then(function (json) { 603 return { ok: resp.ok, status: resp.status, json: json }; 604 }); 605 }) 606 .then(function (result) { 607 if (result.ok && result.json && result.json.url) { 608 window.location.href = result.json.url; 609 return; 610 } 611 if (result.status === 401) { 612 askLoginBeforeCheckout(planId, cycle, trigger); 613 return; 614 } 615 setUpgradeLoading(trigger, false); 616 showBillingNotice(checkoutErrorMessage(result.json), 'error'); 617 }) 618 .catch(function () { 619 setUpgradeLoading(trigger, false); 620 showBillingNotice('Could not reach checkout. Check your connection and try again.', 'error'); 621 }); 622 } 623 624 function leaveForCheckout(link, planId, cycle) { 625 if (planId) savePendingCheckout(planId, cycle); 626 window.location.assign(link.href || signInCheckoutPath(planId, cycle)); 627 } 628 629 // Homepage plan tiles (and any other `data-fg-full-nav` CTA) must do a 630 // real document navigation. Next.js hydrates the marketing export and 631 // intercepts same-origin <a> clicks; the Polar shortcut below then 632 // preventDefault'd and left the visitor on the same page whenever the 633 // session fetch stalled or the notice slot was missing. 634 document.addEventListener('click', function (e) { 635 if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return; 636 var target = e.target; 637 if (!target || typeof target.closest !== 'function') return; 638 var link = target.closest('a[data-fg-full-nav]'); 639 if (!link || !link.href) return; 640 e.preventDefault(); 641 e.stopImmediatePropagation(); 642 leaveForCheckout( 643 link, 644 link.getAttribute('data-billing-plan'),
645 activeBillingCycle(), 646 ); 647 }, true); 648 649 var upgradeLinks = document.querySelectorAll('[data-billing-plan]:not([data-fg-full-nav])'); 650 if (upgradeLinks.length) { 651 upgradeLinks.forEach(function (link) { 652 link.addEventListener('click', function (e) { 653 if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button === 1) return; 654 e.preventDefault(); 655 e.stopImmediatePropagation(); 656 var planId = link.getAttribute('data-billing-plan'); 657 var cycle = activeBillingCycle(); 658 var finished = false; 659 var watchdog = window.setTimeout(function () { 660 if (finished) return; 661 finished = true; 662 leaveForCheckout(link, planId, cycle); 663 }, 4000); 664 665 fetch('/api/auth/session', { credentials: 'include', cache: 'no-store' }) 666 .then(function (resp) { return resp.ok ? resp.json() : null; }) 667 .then(function (session) { 668 if (finished) return; 669 finished = true; 670 window.clearTimeout(watchdog); 671 if (!isRegisteredBillingUser(session && session.user)) { 672 askLoginBeforeCheckout(planId, cycle, link); 673 return; 674 } 675 return startPolarCheckout(planId, cycle, link); 676 }) 677 .catch(function () { 678 if (finished) return; 679 finished = true; 680 window.clearTimeout(watchdog); 681 leaveForCheckout(link, planId, cycle); 682 }); 683 }); 684 }); 685 } 686 687 // Legacy links like /?billing=checkout&plan=pro land on marketing home â route 688 // them through sign-in (or /home resume) instead of a no-op query string. 689 (function routeLegacyBillingCheckoutQuery() { 690 var params; 691 try { params = new URLSearchParams(window.location.search); } catch (err) { return; } 692 var billing = params.get('billing'); 693 if (billing !== 'checkout' && billing !== 'upgrade' && billing !== 'plans' && billing !== 'pack') return; 694 // Already on the app shell â leave App.tsx to handle /projects?billing=. 695 if (window.location.pathname.indexOf('/projects') === 0) return; 696 697 var planId = params.get('plan') || 'pro'; 698 var packId = params.get('pack') || ''; 699 var cycle = params.get('interval') === 'quarterly' ? 'quarterly' : 'monthly'; 700 var resumeParams = new URLSearchParams({ billing: billing === 'pack' ? 'pack' : billing }); 701 if (packId) resumeParams.set('pack', packId); 702 else if (billing === 'checkout' || billing === 'upgrade' || billing === 'plans') { 703 resumeParams.set('plan', planId); 704 } 705 if (cycle === 'quarterly') resumeParams.set('interval', 'quarterly'); 706 var resumeHome = '/projects?' + resumeParams.toString(); 707 708 fetch('/api/auth/session', { credentials: 'include', cache: 'no-store' }) 709 .then(function (resp) { return resp.ok ? resp.json() : null; }) 710 .then(function (session) { 711 if (!isRegisteredBillingUser(session && session.user)) { 712 if (billing === 'checkout' || billing === 'upgrade') { 713 savePendingCheckout(planId, cycle); 714 } 715 window.location.replace( 716 '/onboarding/sign-in?next=' + encodeURIComponent(resumeHome), 717 ); 718 return; 719 } 720 window.location.replace(resumeHome); 721 }) 722 .catch(function () { 723 window.location.replace( 724 '/onboarding/sign-in?next=' + encodeURIComponent(resumeHome), 725 ); 726 }); 727 })(); 728 729 if (window.location.search.indexOf('checkout_failed=1') !== -1) { 730 showBillingNotice('Checkout is temporarily unavailable. Please try again in a moment.', 'error'); 731 try { 732 var cleanUrl = new URL(window.location.href); 733 cleanUrl.searchParams.delete('checkout_failed'); 734 window.history.replaceState(null, '', cleanUrl.pathname + cleanUrl.search + cleanUrl.hash); 735 } catch (err) {} 736 } 737 738 /* ---- Auth-aware nav (show signed-in user instead of Sign in) ---------- */ 739 function authDisplayName(user) { 740 if (user && user.name && String(user.name).trim()) return String(user.name).trim(); 741 var email = user && user.email ? String(user.email) : ''; 742 var at = email.indexOf('@'); 743 return at > 0 ? email.slice(0, at) : (email || 'Account'); 744 } 745 746 function authInitial(user) { 747 var label = authDisplayName(user); 748 return label.charAt(0).toUpperCase() || '?'; 749 } 750 751 function buildUserChip(user) { 752 var chip = document.createElement('a'); 753 // Studio Account settings â opens Settings directly, then returns to landing. 754 chip.href = '/settings/account'; 755 chip.className = 'fgs-user-chip'; 756 var name = authDisplayName(user); 757 chip.setAttribute('aria-label', 'Signed in as ' + name + ' â open account settings'); 758 chip.title = user.email || name; 759 760 var avatar = document.createElement('span'); 761 avatar.className = 'fgs-user-chip__avatar'; 762 avatar.setAttribute('aria-hidden', 'true'); 763 if (user.avatarUrl) { 764 var img = document.createElement('img'); 765 img.src = user.avatarUrl; 766 img.alt = ''; 767 avatar.appendChild(img); 768 } else { 769 avatar.textContent = authInitial(user); 770 } 771 772 var label = document.createElement('span'); 773 label.className = 'fgs-user-chip__name'; 774 label.textContent = name; 775 776 chip.appendChild(avatar); 777 chip.appendChild(label); 778 return chip; 779 } 780 781 /** 782 * Settings gear for the marketing nav. 783 * 784 * The studio's project workspace carries a gear in its header that opens the 785 * full Settings surface. The landing only had the account chip, which lands 786 * on the Account pane, so every other pane (Execution, Billing, App repo, 787 * Team access, ...) was unreachable without opening a project first. This is 788 * the same entry point on the marketing site, defaulting to the same section 789 * the workspace gear does. `/settings/<section>` is a page of its own: it renders the 790 * dialog on an empty host and its close button comes back here. 791 */ 792 function buildSettingsGear() { 793 var gear = document.createElement('a'); 794 gear.className = 'fgs-settings-gear'; 795 gear.href = '/settings/notifications'; 796 gear.setAttribute('data-fg-settings-gear', '1'); 797 gear.setAttribute('aria-label', 'Open settings'); 798 gear.title = 'Settings'; 799 gear.innerHTML = 800 '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"' 801 + ' stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">' 802 + '<circle cx="12" cy="12" r="3"></circle>' 803 + '<path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 0 1-2.82 2.83l-.06-.07a1.7 1.7 0 0 0-1.88-.33 1.7 1.7 0 0 0-1.04 1.56V21a2 2 0 0 1-4 0v-.1A1.7 1.7 0 0 0 9 19.4a1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.82l.07-.06a1.7 1.7 0 0 0 .33-1.88 1.7 1.7 0 0 0-1.56-1.04H3a2 2 0 0 1 0-4h.1a1.7 1.7 0 0 0 1.56-1.04 1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.07A1.7 1.7 0 0 0 9 4.6a1.7 1.7 0 0 0 1.04-1.56V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1.04 1.56 1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.07.06a1.7 1.7 0 0 0-.33 1.87V9a1.7 1.7 0 0 0 1.56 1.04H21a2 2 0 0 1 0 4h-.1a1.7 1.7 0 0 0-1.56 1.04Z"></path>' 804 + '</svg>'; 805 return gear; 806 } 807 808 /** Mount the gear immediately after the account chip, mirroring the studio topbar. */ 809 function ensureSettingsGear() { 810 var navRight = document.querySelector('.fgs-nav__right'); 811 if (!navRight || navRight.querySelector('[data-fg-settings-gear]')) return; 812 var gear = buildSettingsGear();
813 var chip = navRight.querySelector('.fgs-user-chip'); 814 if (chip) chip.insertAdjacentElement('afterend', gear); 815 else navRight.prepend(gear); 816 } 817 818 function applyAuthLandingCopy(session) { 819 var user = session && session.user; 820 var hint = document.querySelector('[data-guest-prompt-hint]'); 821 var lead = document.querySelector('.fgs-clean-lead'); 822 if (lead && !lead.dataset.fgGuestLead) { 823 lead.dataset.fgGuestLead = lead.textContent || ''; 824 } 825 if (!user) { 826 if (hint) { 827 hint.hidden = false; 828 hint.removeAttribute('aria-hidden'); 829 if (hint.dataset.fgGuestHint) hint.textContent = hint.dataset.fgGuestHint; 830 } 831 if (lead && lead.dataset.fgGuestLead) lead.textContent = lead.dataset.fgGuestLead; 832 return; 833 } 834 // Signed-in: drop the guest offer strip and the no-account copy. 835 if (hint) { 836 if (!hint.dataset.fgGuestHint) hint.dataset.fgGuestHint = hint.textContent || ''; 837 hint.hidden = true; 838 hint.setAttribute('aria-hidden', 'true'); 839 hint.textContent = ''; 840 } 841 if (lead) { 842 lead.textContent = 'Turn ideas into Android & iOS apps in minutes'; 843 } 844 } 845 846 function applyAuthNav(session) { 847 // Nav "signed in" chrome is for real customer accounts only. 848 var user = isCustomerAccount(session && session.user) ? session.user : null; 849 applyAuthLandingCopy(user ? session : null); 850 var signIn = document.querySelector( 851 '[data-fg-nav-actions] [data-fg-signin],' + 852 '.fgs-nav__right a.fgs-btn--ghost.fgs-btn--sm[href="/onboarding/sign-in"],' + 853 '.fgs-nav__right a.fgs-btn--ghost.fgs-btn--sm[href*="sign-in"],' + 854 '.fgs-nav__right a[href="/onboarding/sign-in"],' + 855 '.fgs-nav__right a[href*="sign-in"]', 856 ); 857 // Any primary small nav CTA (Create account / Start Building / Get started). 858 var cta = document.querySelector( 859 '[data-fg-nav-actions] [data-fg-create], .fgs-nav__right > a.fgs-btn--sm:not(.fgs-btn--ghost)', 860 ); 861 var mobile = document.querySelector('[data-mobile]'); 862 863 function showNavCta(el) { 864 if (!el) return; 865 el.hidden = false; 866 el.removeAttribute('aria-hidden'); 867 el.style.display = ''; 868 el.style.removeProperty('display'); 869 } 870 871 /** 872 * Mobile drawer always needs a clear path back to create. 873 * Header CTAs are display:none under 720px, so the drawer is the only path. 874 */ 875 function ensureMobileCreatePaths(signedIn) { 876 if (!mobile) return null; 877 878 var home = mobile.querySelector('[data-fg-mobile-home]'); 879 if (!home) { 880 home = document.createElement('a'); 881 home.href = '/'; 882 home.setAttribute('data-fg-mobile-home', '1'); 883 home.textContent = 'Home'; 884 mobile.insertBefore(home, mobile.firstChild); 885 } 886 887 var create = mobile.querySelector('[data-fg-mobile-create]'); 888 if (!create) { 889 create = mobile.querySelector('a.fgs-btn--block[href*="guest-hero"]'); 890 } 891 if (!create) { 892 // Prefer an existing primary block CTA that is not Sign in. 893 var blocks = mobile.querySelectorAll('a.fgs-btn--block'); 894 for (var i = 0; i < blocks.length; i++) { 895 var href = blocks[i].getAttribute('href') || ''; 896 if (href.indexOf('sign-in') === -1) { 897 create = blocks[i]; 898 break; 899 } 900 } 901 } 902 if (!create) { 903 create = document.createElement('a'); 904 create.className = 'fgs-btn fgs-btn--block'; 905 mobile.appendChild(create); 906 } 907 create.setAttribute('data-fg-mobile-create', '1'); 908 create.classList.add('fgs-btn', 'fgs-btn--block'); 909 create.textContent = 'Create a project'; 910 create.href = '/#guest-hero'; 911 showNavCta(create); 912 // Pin Create just under Home so it is the first action in the drawer. 913 if (home.nextSibling !== create) { 914 home.insertAdjacentElement('afterend', create); 915 } 916 917 var projects = mobile.querySelector('[data-fg-mobile-projects]'); 918 if (signedIn) { 919 if (!projects) { 920 projects = document.createElement('a'); 921 projects.setAttribute('data-fg-mobile-projects', '1'); 922 projects.href = '/projects'; 923 projects.textContent = 'My projects'; 924 create.insertAdjacentElement('afterend', projects); 925 } else { 926 showNavCta(projects); 927 } 928 } else if (projects) { 929 projects.remove(); 930 } 931 932 // The nav gear is display:none under 720px, so the drawer is the only 933 // settings path on phones - same reasoning as the Create/Sign in CTAs. 934 var settings = mobile.querySelector('[data-fg-mobile-settings]'); 935 if (signedIn) { 936 if (!settings) { 937 settings = document.createElement('a'); 938 settings.setAttribute('data-fg-mobile-settings', '1'); 939 settings.href = '/settings/notifications'; 940 settings.textContent = 'Settings'; 941 (projects || create).insertAdjacentElement('afterend', settings); 942 } else { 943 showNavCta(settings); 944 } 945 } else if (settings) { 946 settings.remove(); 947 } 948 949 // Hide mobile Sign in block when already authenticated. 950 mobile.querySelectorAll('a.fgs-btn--block[href*="sign-in"]').forEach(function (el) { 951 if (el.hasAttribute('data-fg-mobile-create')) return; 952 if (signedIn) { 953 el.hidden = true; 954 el.setAttribute('aria-hidden', 'true'); 955 el.style.display = 'none'; 956 } else { 957 showNavCta(el); 958 } 959 }); 960 961 return create; 962 } 963 964 if (!user) { 965 document.querySelectorAll('.fgs-user-chip, .fg-user-chip').forEach(function (el) { el.remove(); }); 966 document.querySelectorAll('[data-fg-settings-gear]').forEach(function (el) { el.remove(); });
967 try { 968 sessionStorage.removeItem('fg:header-auth-user'); 969 localStorage.removeItem('fg:header-auth-user'); 970 } catch (e) {} 971 if (cta) { 972 showNavCta(cta); 973 if (cta.dataset.fgGuestLabel) cta.textContent = cta.dataset.fgGuestLabel; 974 if (cta.dataset.fgGuestHref) cta.href = cta.dataset.fgGuestHref; 975 } 976 document.querySelectorAll( 977 '[data-fg-create], [data-fg-create-mobile], .nav-primary-cta, .nav-drawer-cta', 978 ).forEach(function (el) { showNavCta(el); }); 979 document.querySelectorAll('[data-fg-signin], [data-fg-signin-mobile]').forEach(function (el) { 980 showNavCta(el); 981 }); 982 var signedNav = document.querySelector('[data-fg-nav].is-signed-in, .nav.is-signed-in'); 983 if (signedNav) signedNav.classList.remove('is-signed-in'); 984 document.documentElement.classList.remove('fg-signed-in'); 985 document.documentElement.classList.add('fg-signed-out'); 986 ensureMobileCreatePaths(false); 987 if (mobile) { 988 mobile.querySelectorAll('.fgs-mobile-user').forEach(function (el) { el.remove(); }); 989 } 990 return; 991 } 992 993 if (signIn && !signIn.dataset.fgAuthReplaced) { 994 // Shared marketing header (`data-fg-nav-actions`) is owned by HEADER_AUTH 995 // in site-header.ts â it paints `.fg-user-chip`. Do not swap in the legacy 996 // `.fgs-user-chip` here or the two scripts fight on every homepage load. 997 if (signIn.closest('[data-fg-nav-actions]')) { 998 signIn.hidden = true; 999 signIn.setAttribute('aria-hidden', 'true'); 1000 signIn.style.display = 'none'; 1001 } else { 1002 signIn.dataset.fgAuthReplaced = '1'; 1003 signIn.replaceWith(buildUserChip(user)); 1004 } 1005 } else if (!signIn) { 1006 var navRight = document.querySelector('.fgs-nav__right'); 1007 if (navRight && !navRight.querySelector('.fgs-user-chip')) { 1008 var themeToggle = navRight.querySelector('[data-theme-toggle]'); 1009 var chip = buildUserChip(user); 1010 if (themeToggle) themeToggle.insertAdjacentElement('afterend', chip); 1011 else navRight.prepend(chip); 1012 } 1013 } 1014 1015 try { 1016 var cachedUser = JSON.stringify({ 1017 id: user.id, 1018 email: user.email || '', 1019 name: user.name || '', 1020 avatarUrl: user.avatarUrl || '', 1021 accountType: user.accountType || 'registered', 1022 }); 1023 sessionStorage.setItem('fg:header-auth-user', cachedUser); 1024 localStorage.setItem('fg:header-auth-user', cachedUser); 1025 } catch (e) {} 1026 document.documentElement.classList.add('fg-signed-in'); 1027 document.documentElement.classList.remove('fg-signed-out'); 1028 1029 ensureSettingsGear(); 1030 1031 // Signed-in chrome: drop the nav "Create a project" CTA â the chip + 1032 // studio (/home) already cover create/resume. Guests still see it above. 1033 // Cover both legacy (.fgs-nav__right .fgs-btn--sm) and shared header 1034 // (.nav-primary-cta[data-fg-create]) so homepage + grafted pages agree. 1035 var createButtons = document.querySelectorAll( 1036 '[data-fg-create], [data-fg-create-mobile], .nav-primary-cta, .nav-drawer-cta', 1037 ); 1038 if (cta) { 1039 cta.hidden = true; 1040 cta.setAttribute('aria-hidden', 'true'); 1041 cta.style.display = 'none'; 1042 } 1043 createButtons.forEach(function (el) { 1044 el.hidden = true; 1045 el.setAttribute('aria-hidden', 'true'); 1046 el.style.display = 'none'; 1047 }); 1048 var nav = document.querySelector('[data-fg-nav], .fgs-nav, header.nav'); 1049 if (nav) nav.classList.add('is-signed-in'); 1050 1051 var mobileCta = ensureMobileCreatePaths(true); 1052 if (mobileCta) { 1053 mobileCta.hidden = true; 1054 mobileCta.setAttribute('aria-hidden', 'true'); 1055 mobileCta.style.display = 'none'; 1056 } 1057 1058 if (mobile && !mobile.querySelector('.fgs-mobile-user')) { 1059 var row = document.createElement('p'); 1060 row.className = 'fgs-mobile-user'; 1061 row.textContent = 'Signed in as ' + authDisplayName(user); 1062 mobile.appendChild(row); 1063 } else if (mobile && mobileCta) { 1064 // Keep account label at the bottom; actions stay on top. 1065 var existing = mobile.querySelector('.fgs-mobile-user'); 1066 if (existing) mobile.appendChild(existing); 1067 } 1068 } 1069 1070 fetch('/api/auth/session', { credentials: 'include', cache: 'no-store' }) 1071 .then(function (resp) { 1072 if (!resp.ok) throw new Error('session_http_' + resp.status); 1073 return resp.json(); 1074 }) 1075 .then(function (session) {
1076 // Do not call applyAuthNav on a missing payload â that used to force 1077 // guest CTAs (Sign in / Create a project) whenever /api/auth/session 1078 // blipped, undoing HEADER_AUTH's signed-in chip on the homepage. 1079 if (!session || typeof session !== 'object') return; 1080 applyAuthNav(session); 1081 // Same customer gate as nav â never show Local/guest projects while 1082 // the chrome still says "Sign in". 1083 loadLandingRecentProjects( 1084 isCustomerAccount(session && session.user) ? session : null, 1085 ); 1086 }) 1087 .catch(function () { /* keep pending / cached chrome */ }); 1088 1089 /* ---- Funnel: nav / signup CTA clicks -------------------------------- */ 1090 (function bindFunnelCtas() { 1091 document.addEventListener('click', function (event) { 1092 var target = event.target; 1093 if (!target || typeof target.closest !== 'function') return; 1094 var link = target.closest('a'); 1095 if (!link) return; 1096 var href = (link.getAttribute('href') || '').toLowerCase(); 1097 var text = (link.textContent || '').trim().toLowerCase(); 1098 var isAuthCta = 1099 href.indexOf('sign-in') >= 0 || 1100 href.indexOf('signup') >= 0 || 1101 href.indexOf('sign-up') >= 0 || 1102 href.indexOf('get-started') >= 0 || 1103 text === 'create account' || 1104 text === 'sign up' || 1105 text === 'get started' || 1106 text === 'start building' || 1107 text === 'sign in'; 1108 if (!isAuthCta) return; 1109 trackLandingFunnel('cta_clicked', 'nav_cta', (link.getAttribute('href') || text).slice(0, 160)); 1110 // Signup surface only â login alone is not signup_page_opened. 1111 if ( 1112 href.indexOf('get-started') >= 0 || 1113 href.indexOf('signup') >= 0 || 1114 href.indexOf('sign-up') >= 0 || 1115 text === 'create account' || 1116 text === 'sign up' || 1117 text === 'start building' 1118 ) { 1119 trackLandingFunnel('signup_page_opened', 'nav_cta', (link.getAttribute('href') || text).slice(0, 160)); 1120 } 1121 }, true); 1122 })(); 1123 1124 /* ---- Recent projects under landing chat (signed-in) -------------------- */ 1125 function formatRelativeTime(ts) { 1126 var ms = typeof ts === 'number' ? ts : Date.parse(ts); 1127 if (!Number.isFinite(ms)) return ''; 1128 var diff = Math.max(0, Date.now() - ms); 1129 var mins = Math.floor(diff / 60000); 1130 if (mins < 1) return 'Just now'; 1131 if (mins < 60) return mins + 'm ago'; 1132 var hours = Math.floor(mins / 60); 1133 if (hours < 24) return hours + 'h ago'; 1134 var days = Math.floor(hours / 24); 1135 if (days < 30) return days + 'd ago'; 1136 return new Date(ms).toLocaleDateString(); 1137 } 1138 1139 function loadLandingRecentProjects(session) { 1140 var section = document.querySelector('[data-guest-recent]'); 1141 var row = document.querySelector('[data-guest-recent-row]'); 1142 var countEl = document.querySelector('[data-guest-recent-count]'); 1143 if (!section || !row) return; 1144 if (!isCustomerAccount(session && session.user)) { 1145 section.hidden = true; 1146 row.textContent = ''; 1147 if (countEl) { 1148 countEl.hidden = true; 1149 countEl.textContent = ''; 1150 } 1151 return; 1152 } 1153 1154 function isFlutterProject(project) { 1155 if (!project) return false; 1156 if (project.skillId === 'fluttergo-pipeline') return true; 1157 var meta = project.metadata || {}; 1158 if (meta.platform === 'flutter') return true; 1159 var targets = meta.platformTargets; 1160 if (Array.isArray(targets) && targets.indexOf('flutter') !== -1) return true; 1161 var pluginId = project.pluginId || ''; 1162 var nonFlutterPlugins = { 1163 'example-web-prototype': 1, 1164 'example-live-artifact': 1, 1165 'example-simple-deck': 1, 1166 'od-media-generation': 1, 1167 'example-hyperframes': 1, 1168 }; 1169 if (nonFlutterPlugins[pluginId]) return false; 1170 if (meta.kind === 'deck' || meta.kind === 'image' || meta.intent === 'live-artifact') return false; 1171 // FlutterGo default create path has no pluginId â treat as Flutter app. 1172 return !pluginId; 1173 } 1174 1175 function projectRawUrl(projectId, filePath) { 1176 return '/api/projects/' + encodeURIComponent(projectId) + '/raw/' + 1177 String(filePath || '').split('/').map(encodeURIComponent).join('/'); 1178 } 1179 1180 /** Flutter template launcher / splash â skip when any real UI art exists. */ 1181 function isTemplateFlutterIcon(path) { 1182 var n = String(path || '').toLowerCase().replace(/\\/g, '/'); 1183 if (/assets\/(?:images\/)?branding\//.test(n)) return false;
1184 if (/(?:^|\/)(?:logo|wordmark)\./.test(n)) return false; 1185 return ( 1186 /web\/icons\/icon-(?:maskable-)?(?:192|512)\.png$/i.test(n) || 1187 /web\/favicon\.png$/i.test(n) || 1188 /\/mipmap-[^/]+\/ic_launcher/i.test(n) || 1189 /appicon\.appiconset\//i.test(n) || 1190 /icon-app-\d+/i.test(n) || 1191 /launchimage\.imageset\//i.test(n) || 1192 /(?:^|\/)launch_image\./i.test(n) 1193 ); 1194 } 1195 1196 function isCustomBrandIcon(path) { 1197 var n = String(path || '').toLowerCase().replace(/\\/g, '/'); 1198 return ( 1199 /assets\/(?:images\/)?branding\/(?:app_icon|logo|launcher)/i.test(n) || 1200 /(?:^|\/)assets\/logo\.(png|svg|webp|jpe?g)$/i.test(n) || 1201 /(?:^|\/)(?:logo|wordmark)\.(png|svg|webp|jpe?g)$/i.test(n) 1202 ); 1203 } 1204 1205 /** UI art that should fill the phone screen (not a tiny centered tile). */ 1206 function isFullBleedArt(path) { 1207 var n = String(path || '').toLowerCase().replace(/\\/g, '/'); 1208 if (isTemplateFlutterIcon(n) || isCustomBrandIcon(n)) return false; 1209 // Skip launcher-ish assets that live under assets/images. 1210 if (/adaptive_foreground|adaptive_background|app_icon|launcher_icon|ic_launcher/i.test(n)) { 1211 return false; 1212 } 1213 if (/showcase\/|screenshot|screen[-_]?\d|project_cover|cover\.(png|jpe?g|webp)$/i.test(n)) return true; 1214 if (n.indexOf('assets/images/') === 0 && !/splash|perm-check|icons\//.test(n)) return true; 1215 if (n.indexOf('assets/') === 0 && !/splash|perm-check|icons\/|mipmap/.test(n)) return true; 1216 // Composer paste / upload previews sitting at project root. 1217 if (/^[^/]*(?:paste|screen|cover|preview|mock|shot)[^/]*\.(png|jpe?g|webp|gif)$/i.test(n)) return true; 1218 if (/^[^/]+\.(png|jpe?g|webp|gif)$/i.test(n) && !/icon|favicon|launcher/i.test(n)) return true; 1219 return false; 1220 } 1221 1222 /** Lower score wins. Real UI art first â never prefer Flutter default logo. */ 1223 function coverRank(path) { 1224 var n = String(path || '').toLowerCase().replace(/\\/g, '/'); 1225 if (isTemplateFlutterIcon(n)) return 80; 1226 if (n === 'showcase/cover.png' || /\/showcase\/cover\.(png|jpe?g|webp)$/.test(n)) return -20; 1227 if (/showcase\/cover\./.test(n) || /project_cover\./.test(n)) return -18; 1228 if (/showcase\/|showcase_|(?:^|\/)(?:app-)?screen-\d+/i.test(n)) return -14; 1229 if (/screenshot/i.test(n)) return -12; 1230 if (/assets\/images\/(?:onboarding|posters|hero|home|feed|empty)\//.test(n)) return -10; 1231 if (/^[^/]*(?:paste|screen|cover|preview|mock|shot)[^/]*\.(png|jpe?g|webp|gif)$/i.test(n)) return -9; 1232 if (n.indexOf('assets/images/') === 0 && !/splash|perm-check|icons\//.test(n)) return -8; 1233 if (/(?:^|\/)(?:logo|wordmark)\.(?:png|jpe?g|webp|svg)$/.test(n)) return -6; 1234 if (/assets\/(?:images\/)?branding\/logo\./.test(n)) return -5; 1235 if (/assets\/(?:images\/)?branding\/app_icon/.test(n)) return -3; 1236 if (n.indexOf('assets/') === 0 && !/splash|perm-check/.test(n)) return -1; 1237 if (/^[^/]+\.(png|jpe?g|webp|gif)$/i.test(n)) return 0; 1238 if (/favicon|launch_image|perm-check|icon-16|icon-32/i.test(n)) return 90; 1239 return 10; 1240 } 1241 1242 function coverKindForPath(path) { 1243 var n = String(path || '').toLowerCase().replace(/\\/g, '/'); 1244 if (isFullBleedArt(n)) return 'screen'; 1245 if (isCustomBrandIcon(n)) return 'logo'; 1246 if (isTemplateFlutterIcon(n) || /app_icon|ic_launcher|icon-512|mipmap/i.test(n)) return 'icon'; 1247 return 'image'; 1248 } 1249 1250 // Probe only real covers / custom brand marks â never Flutter template logos. 1251 var KNOWN_ICON_SUFFIXES = [ 1252 'showcase/cover.png', 1253 'assets/images/onboarding/onboarding_1.png', 1254 'assets/images/onboarding/onboarding_2.png', 1255 'assets/images/posters/poster_1.png', 1256 'assets/images/hero/hero.png', 1257 'assets/branding/app_icon_master_1024.png', 1258 'assets/branding/app_icon.png', 1259 'assets/images/branding/app_icon_master_1024.png', 1260 'assets/images/branding/app_icon.png', 1261 'assets/images/branding/logo.png', 1262 'assets/images/branding/logo.svg', 1263 'assets/logo.png', 1264 'assets/logo.svg', 1265 ]; 1266 var KNOWN_ICON_PREFIXES = ['', 'apps/mobile/', 'mobile/', 'app/', 'apps/app/', 'apps/flutter/']; 1267 1268 function pickCoverRef(files) { 1269 if (!Array.isArray(files) || !files.length) return null; 1270 var SKIP = /(?:^|\/)(?:launch_image|perm-check|Icon-16|Icon-32|favicon\.ico|Icon-App-20|Icon-App-29|Icon-App-40|Icon-App-60|Icon-App-76|Icon-App-83)/i; 1271 var list = files.filter(function (f) { 1272 if (!f || f.type === 'dir') return false;
1273 var p = f.path || f.name || ''; 1274 return /\.(png|jpe?g|webp|gif|avif|svg)$/i.test(p) && !SKIP.test(p); 1275 }); 1276 if (!list.length) return null; 1277 1278 function sortByRank(items) { 1279 return items.slice().sort(function (a, b) { 1280 var pa = a.path || a.name || ''; 1281 var pb = b.path || b.name || ''; 1282 var d = coverRank(pa) - coverRank(pb); 1283 if (d !== 0) return d; 1284 var sizeDelta = (b.size || 0) - (a.size || 0); 1285 if (sizeDelta !== 0) return sizeDelta; 1286 return (b.mtime || 0) - (a.mtime || 0); 1287 }); 1288 } 1289 1290 var uiArt = sortByRank(list.filter(function (f) { 1291 return isFullBleedArt(f.path || f.name || ''); 1292 })); 1293 var brand = sortByRank(list.filter(function (f) { 1294 return isCustomBrandIcon(f.path || f.name || ''); 1295 })); 1296 // Prefer real screens / UI assets over Flutter's default logo. 1297 var chosen = uiArt[0] || brand[0] || null; 1298 if (!chosen) { 1299 // No real art â skip template Flutter icons; avatar fallback looks better. 1300 return null; 1301 } 1302 var path = chosen.path || chosen.name; 1303 return { path: path, kind: coverKindForPath(path) }; 1304 } 1305 1306 function showcaseCoverUrl(project) { 1307 var media = project && project.showcase && project.showcase.media; 1308 if (!media) return null; 1309 var images = media.images || []; 1310 for (var i = 0; i < images.length; i += 1) { 1311 var url = images[i] && images[i].url && String(images[i].url).trim(); 1312 if (url) return url; 1313 } 1314 var poster = media.video && media.video.poster && String(media.video.poster).trim(); 1315 return poster || null; 1316 } 1317 1318 function escapeXml(value) { 1319 return String(value || '') 1320 .replace(/&/g, '&') 1321 .replace(/</g, '<') 1322 .replace(/>/g, '>') 1323 .replace(/"/g, '"') 1324 .replace(/'/g, '''); 1325 } 1326 1327 function projectAvatarDataUrl(name, seed) { 1328 var trimmed = String(name || '').trim(); 1329 var words = trimmed.split(/\s+/).filter(Boolean); 1330 var initials = words.length >= 2 1331 ? ((words[0].charAt(0) || '') + (words[1].charAt(0) || '')).toUpperCase() 1332 : (trimmed ? trimmed.slice(0, 2).toUpperCase() : '?'); 1333 var hash = 0; 1334 var key = String(seed || trimmed || 'x'); 1335 for (var i = 0; i < key.length; i += 1) { 1336 hash = (hash * 31 + key.charCodeAt(i)) >>> 0; 1337 } 1338 var hue = hash % 360; 1339 var hue2 = (hue + 36) % 360; 1340 var svg = 1341 '<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">' + 1342 '<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">' + 1343 '<stop offset="0%" stop-color="hsl(' + hue + ' 68% 52%)"/>' + 1344 '<stop offset="100%" stop-color="hsl(' + hue2 + ' 72% 42%)"/>' + 1345 '</linearGradient></defs>' + 1346 '<rect width="256" height="256" rx="56" fill="url(#g)"/>' + 1347 '<text x="128" y="152" text-anchor="middle" font-size="' + (initials.length > 1 ? 88 : 104) + '" ' + 1348 'font-weight="700" fill="#ffffff" font-family="ui-sans-serif,system-ui,sans-serif">' + 1349 escapeXml(initials) + '</text></svg>'; 1350 return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg); 1351 } 1352 1353 function applyThumbChrome(thumb, kind) { 1354 if (!thumb) return; 1355 thumb.classList.add('has-cover'); 1356 if (kind) thumb.setAttribute('data-cover-kind', kind); 1357 // Keep phone chrome; media CSS decides tile vs fill-screen. 1358 thumb.classList.remove('is-bleed'); 1359 thumb.classList.add('is-framed'); 1360 } 1361 1362 function setThumbMedia(thumb, src, kind) { 1363 if (!thumb || !thumb.isConnected || !src) { 1364 return Promise.resolve(false); 1365 } 1366 var screen = thumb.querySelector('[data-guest-recent-screen]'); 1367 if (!screen) return Promise.resolve(false); 1368 return new Promise(function (resolve) { 1369 var img = document.createElement('img'); 1370 img.className = 'fgs-recent__thumb-media'; 1371 img.alt = ''; 1372 img.decoding = 'async'; 1373 img.loading = 'eager'; 1374 var settled = false;
1375 function finish(ok) { 1376 if (settled) return; 1377 settled = true; 1378 resolve(ok); 1379 } 1380 img.addEventListener('load', function () { 1381 if (!screen.isConnected) { 1382 finish(false); 1383 return; 1384 } 1385 screen.textContent = ''; 1386 screen.appendChild(img); 1387 applyThumbChrome(thumb, kind); 1388 finish(true); 1389 }); 1390 img.addEventListener('error', function () { 1391 finish(false); 1392 }); 1393 img.src = src; 1394 }); 1395 } 1396 1397 function probeAssetExists(url) { 1398 return fetch(url, { 1399 method: 'GET', 1400 credentials: 'include', 1401 headers: { Range: 'bytes=0-0' }, 1402 cache: 'force-cache', 1403 }).then(function (resp) { 1404 return resp.ok || resp.status === 206; 1405 }).catch(function () { 1406 return false; 1407 }); 1408 } 1409 1410 function loadPathCover(thumb, projectId, coverPath) { 1411 var kind = coverKindForPath(coverPath); 1412 var url = projectRawUrl(projectId, coverPath); 1413 return setThumbMedia(thumb, url, kind); 1414 } 1415 1416 function tryKnownIconPaths(thumb, projectId) { 1417 var paths = []; 1418 KNOWN_ICON_PREFIXES.forEach(function (prefix) { 1419 KNOWN_ICON_SUFFIXES.forEach(function (suffix) { 1420 paths.push(prefix + suffix); 1421 }); 1422 }); 1423 var i = 0; 1424 function next() { 1425 if (!thumb.isConnected || i >= paths.length) { 1426 return Promise.resolve(false); 1427 } 1428 var path = paths[i++]; 1429 var url = projectRawUrl(projectId, path); 1430 return probeAssetExists(url).then(function (ok) { 1431 if (!ok) return next(); 1432 return loadPathCover(thumb, projectId, path).then(function (shown) { 1433 return shown ? true : next(); 1434 }); 1435 }); 1436 } 1437 return next(); 1438 } 1439 1440 function projectStatusValue(project) { 1441 var s = project && project.status; 1442 if (s && typeof s === 'object' && typeof s.value === 'string') return s.value; 1443 if (typeof s === 'string') return s; 1444 return 'not_started'; 1445 } 1446 1447 function statusLabel(status) { 1448 var map = { 1449 not_started: 'Not started', 1450 queued: 'Queued', 1451 running: 'Building', 1452 awaiting_input: 'Needs input', 1453 succeeded: 'Completed', 1454 failed: 'Failed', 1455 canceled: 'Canceled', 1456 published: 'Published', 1457 }; 1458 return map[status] || 'Not started'; 1459 } 1460 1461 function projectInitials(name) { 1462 var words = String(name || '').trim().split(/\s+/).filter(Boolean); 1463 if (words.length >= 2) { 1464 return ((words[0].charAt(0) || '') + (words[1].charAt(0) || '')).toUpperCase() || '?'; 1465 } 1466 var one = words[0] || '?'; 1467 return one.slice(0, 2).toUpperCase(); 1468 } 1469 1470 function skillLabel(project) { 1471 var skill = project && project.skillId ? String(project.skillId).trim() : ''; 1472 return skill || 'fluttergo-pipeline'; 1473 } 1474 1475 function paintAvatarFallback(thumb, name, projectId) { 1476 var screen = thumb && thumb.querySelector('[data-guest-recent-screen]'); 1477 if (!screen) return; 1478 var img = document.createElement('img'); 1479 img.className = 'fgs-recent__thumb-media'; 1480 img.alt = ''; 1481 img.src = projectAvatarDataUrl(name, projectId); 1482 screen.textContent = ''; 1483 screen.appendChild(img); 1484 applyThumbChrome(thumb, 'avatar'); 1485 } 1486 1487 function hydrateCover(thumb, project) { 1488 var projectId = project && project.id; 1489 if (!projectId || !thumb) return; 1490 var name = project.name && String(project.name).trim() ? String(project.name).trim() : 'Untitled'; 1491 var epoch = (Number(thumb.dataset.coverEpoch) || 0) + 1; 1492 thumb.dataset.coverEpoch = String(epoch); 1493 1494 // Instant unique avatar â real assets replace this when found. 1495 paintAvatarFallback(thumb, name, projectId); 1496 1497 function stillCurrent() { 1498 return thumb.isConnected && thumb.dataset.coverEpoch === String(epoch); 1499 } 1500 1501 function showCover(src, kind) { 1502 if (!stillCurrent() || !src) return Promise.resolve(false); 1503 return setThumbMedia(thumb, src, kind).then(function (ok) { 1504 return ok && stillCurrent(); 1505 }); 1506 } 1507 1508 var showcaseUrl = showcaseCoverUrl(project); 1509 var chain = showcaseUrl 1510 ? showCover(showcaseUrl, 'screen') 1511 : Promise.resolve(false); 1512 1513 chain 1514 .then(function (ok) { 1515 if (ok || !stillCurrent()) return true; 1516 return fetch('/api/projects/' + encodeURIComponent(projectId) + '/files', { 1517 credentials: 'include', 1518 cache: 'no-store', 1519 }) 1520 .then(function (resp) { return resp.ok ? resp.json() : null; }) 1521 .then(function (json) { 1522 if (!stillCurrent()) return true; 1523 var cover = pickCoverRef(json && json.files); 1524 if (cover) { 1525 return showCover(projectRawUrl(projectId, cover.path), cover.kind).then(function (shown) { 1526 if (shown || !stillCurrent()) return true; 1527 return tryKnownIconPaths(thumb, projectId); 1528 }); 1529 } 1530 return tryKnownIconPaths(thumb, projectId); 1531 }); 1532 }) 1533 .catch(function () { 1534 if (!stillCurrent()) return false;
1535 return tryKnownIconPaths(thumb, projectId); 1536 }); 1537 } 1538 1539 fetch('/api/projects', { credentials: 'include', cache: 'no-store' }) 1540 .then(function (resp) { return resp.ok ? resp.json() : null; }) 1541 .then(function (data) { 1542 var projects = data && Array.isArray(data.projects) ? data.projects.slice() : []; 1543 projects = projects.filter(isFlutterProject); 1544 projects.sort(function (a, b) { 1545 return (b.updatedAt || 0) - (a.updatedAt || 0); 1546 }); 1547 var total = projects.length; 1548 projects = projects.slice(0, 12); 1549 row.textContent = ''; 1550 if (!projects.length) { 1551 section.hidden = true; 1552 if (countEl) { 1553 countEl.hidden = true; 1554 countEl.textContent = ''; 1555 } 1556 return; 1557 } 1558 if (countEl) { 1559 countEl.hidden = false; 1560 countEl.textContent = String(total); 1561 } 1562 projects.forEach(function (project) { 1563 var id = project && typeof project.id === 'string' ? project.id : ''; 1564 if (!id) return; 1565 var name = project.name && String(project.name).trim() ? String(project.name).trim() : 'Untitled'; 1566 var status = projectStatusValue(project); 1567 var statusText = statusLabel(status); 1568 var relative = formatRelativeTime(project.updatedAt) || 'Recent'; 1569 var skill = skillLabel(project); 1570 1571 var card = document.createElement('a'); 1572 card.className = 'fgs-recent__card'; 1573 card.href = '/projects/' + encodeURIComponent(id); 1574 card.setAttribute('role', 'listitem'); 1575 card.title = name; 1576 1577 var thumb = document.createElement('div'); 1578 thumb.className = 'fgs-recent__thumb is-framed'; 1579 thumb.setAttribute('aria-hidden', 'true'); 1580 1581 var stage = document.createElement('div'); 1582 stage.className = 'fgs-recent__preview-stage'; 1583 var device = document.createElement('div'); 1584 device.className = 'fgs-recent__device'; 1585 var screen = document.createElement('div'); 1586 screen.className = 'fgs-recent__device-screen'; 1587 screen.setAttribute('data-guest-recent-screen', ''); 1588 var glyph = document.createElement('span'); 1589 glyph.className = 'fgs-recent__glyph'; 1590 glyph.textContent = projectInitials(name); 1591 screen.appendChild(glyph); 1592 device.appendChild(screen); 1593 stage.appendChild(device); 1594 thumb.appendChild(stage); 1595 1596 // Brand watermark under the phone while covers resolve. 1597 var mark = document.createElement('img'); 1598 mark.className = 'fgs-recent__brand-mark'; 1599 mark.src = '/brand/fluttergo-appicon.svg'; 1600 mark.alt = ''; 1601 mark.setAttribute('aria-hidden', 'true'); 1602 thumb.appendChild(mark); 1603 1604 var statusPill = document.createElement('span'); 1605 statusPill.className = 'fgs-recent__status-pill fgs-recent__status-pill--' + status; 1606 statusPill.textContent = statusText; 1607 thumb.appendChild(statusPill); 1608 1609 var body = document.createElement('div'); 1610 body.className = 'fgs-recent__body'; 1611 1612 var tag = document.createElement('span'); 1613 tag.className = 'fgs-recent__tag'; 1614 tag.textContent = 'Prototype'; 1615 1616 var title = document.createElement('div'); 1617 title.className = 'fgs-recent__name'; 1618 title.textContent = name; 1619 1620 var lead = document.createElement('div'); 1621 lead.className = 'fgs-recent__lead'; 1622 lead.textContent = 'freeform · ' + skill; 1623 1624 var foot = document.createElement('div'); 1625 foot.className = 'fgs-recent__foot fgs-recent__foot--' + status; 1626 foot.textContent = statusText + ' · ' + relative; 1627 1628 body.appendChild(tag); 1629 body.appendChild(title); 1630 body.appendChild(lead); 1631 body.appendChild(foot); 1632 1633 card.appendChild(thumb); 1634 card.appendChild(body); 1635 row.appendChild(card); 1636 hydrateCover(thumb, project); 1637 }); 1638 section.hidden = row.children.length === 0; 1639 }) 1640 .catch(function () { 1641 section.hidden = true; 1642 }); 1643 } 1644 1645 /* ---- Landing prompt â Flutter pipeline + attachments ----------------- 1646 Product mode is always Android & iOS (fluttergo-pipeline). Attachments, 1647 Figma/website links, and file upload reuse the Home create handoff. */
1648 (function bindGuestPrompt() { 1649 var form = document.querySelector('[data-guest-prompt-form]'); 1650 var input = document.querySelector('[data-guest-prompt-input]'); 1651 if (!form || !input) return; 1652 1653 var submit = form.querySelector('[data-guest-prompt-submit]'); 1654 var statusEl = form.querySelector('[data-guest-prompt-status]'); 1655 var statusRow = form.querySelector('[data-guest-prompt-status-row]'); 1656 var figmaConnectBtn = form.querySelector('[data-guest-figma-connect]'); 1657 var figmaProgress = form.querySelector('[data-guest-figma-progress]'); 1658 var figmaPhaseEl = form.querySelector('[data-guest-figma-phase]'); 1659 var figmaCountsEl = form.querySelector('[data-guest-figma-counts]'); 1660 var figmaInventoryEl = form.querySelector('[data-guest-figma-inventory]'); 1661 var figmaViewAllBtn = form.querySelector('[data-guest-figma-view-all]'); 1662 var figmaExportsModal = document.querySelector('[data-guest-figma-exports]'); 1663 var figmaExportsLead = document.querySelector('[data-guest-figma-exports-lead]'); 1664 var figmaExportsStats = document.querySelector('[data-guest-figma-exports-stats]'); 1665 var figmaExportsScreens = document.querySelector('[data-guest-figma-exports-screens]'); 1666 var figmaExportsAssets = document.querySelector('[data-guest-figma-exports-assets]'); 1667 var lastFigmaInventory = { 1668 screenNames: [], 1669 screenCount: 0, 1670 assetCount: 0, 1671 phase: '', 1672 screenPreviews: [], 1673 projectId: '', 1674 }; 1675 var figmaConnectPoll = null; 1676 var box = form.querySelector('[data-guest-dropzone]') || form.querySelector('.fgs-prompt__box') || form; 1677 var examplesRow = document.querySelector('[data-guest-examples-row]'); 1678 var examplesLabel = document.querySelector('[data-guest-examples-label]'); 1679 var refreshBtn = document.querySelector('[data-guest-examples-refresh]'); 1680 var chipsEl = form.querySelector('[data-guest-attach-chips]'); 1681 var attachRoot = form.querySelector('[data-guest-attach]'); 1682 var attachToggle = form.querySelector('[data-guest-attach-toggle]'); 1683 var attachMenu = form.querySelector('[data-guest-attach-menu]'); 1684 var fileInput = form.querySelector('[data-guest-file-input]'); 1685 var imageInput = form.querySelector('[data-guest-image-input]'); 1686 var zipInput = form.querySelector('[data-guest-zip-input]'); 1687 var githubModal = document.querySelector('[data-guest-github-modal]'); 1688 var githubRepo = document.querySelector('[data-guest-github-repo]'); 1689 var githubBranch = document.querySelector('[data-guest-github-branch]'); 1690 var githubName = document.querySelector('[data-guest-github-name]'); 1691 var githubStatus = document.querySelector('[data-guest-github-status]'); 1692 var githubConnectBtn = document.querySelector('[data-guest-github-connect]'); 1693 var githubSubmitBtn = document.querySelector('[data-guest-github-submit]'); 1694 var githubError = document.querySelector('[data-guest-github-error]'); 1695 var githubLead = document.querySelector('[data-guest-github-lead]'); 1696 var githubChoice = document.querySelector('[data-guest-github-choice]'); 1697 var githubFields = document.querySelector('[data-guest-github-fields]'); 1698 var githubFoot = document.querySelector('[data-guest-github-foot]'); 1699 var githubConnectRow = document.querySelector('[data-guest-github-connect-row]'); 1700 var githubBack = document.querySelector('[data-guest-github-back]'); 1701 var githubVisibility = null; 1702 var githubInstalled = false; 1703 var linkDialog = form.querySelector('[data-guest-link-dialog]'); 1704 var linkInput = form.querySelector('[data-guest-link-input]'); 1705 var linkLabel = form.querySelector('[data-guest-link-label]'); 1706 var linkCancel = form.querySelector('[data-guest-link-cancel]'); 1707 var linkConfirm = form.querySelector('[data-guest-link-confirm]'); 1708 var submitting = false; 1709 var stagedFiles = []; 1710 var stagedLinks = []; 1711 var pendingLinkKind = null; 1712 1713 var EXAMPLE_POOL = [ 1714 'Food delivery app with live order tracking', 1715 'Habit tracker with streaks and weekly insights', 1716 'Salon booking app with calendar and reminders', 1717 'Expense tracker with charts and budgets', 1718 'Quiz app with leaderboards and shareable results', 1719 'Social feed app with profiles and messaging', 1720 'Booking app for a clinic with calendar and reminders',
1721 'Fitness app with workouts, streaks, and progress charts', 1722 ]; 1723 1724 var BUILD_STATUS = [ 1725 'Understanding your ideaâ¦', 1726 'Preparing your workspaceâ¦', 1727 'Uploading referencesâ¦', 1728 'Opening your projectâ¦', 1729 'Almost thereâ¦', 1730 'Still opening your projectâ¦', 1731 ]; 1732 1733 var FIGMA_BUILD_STATUS = [ 1734 'Creating your Flutter workspaceâ¦', 1735 'Connecting to your Figma accountâ¦', 1736 'Reading your Figma fileâ¦', 1737 'Listing screens and framesâ¦', 1738 'Exporting screen PNGsâ¦', 1739 'Downloading icons and buttonsâ¦', 1740 'Writing figma/OVERVIEW.mdâ¦', 1741 'Still pulling from Figmaâ¦', 1742 ]; 1743 1744 var activeBuildStatus = BUILD_STATUS; 1745 1746 function uuid() { 1747 try { 1748 if (window.crypto && typeof window.crypto.randomUUID === 'function') { 1749 return window.crypto.randomUUID(); 1750 } 1751 } catch (e) {} 1752 return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { 1753 var r = (Math.random() * 16) | 0; 1754 var v = c === 'x' ? r : (r & 0x3) | 0x8; 1755 return v.toString(16); 1756 }); 1757 } 1758 1759 function isGenericCreatePrompt(prompt) { 1760 var cleaned = String(prompt || '').trim(); 1761 if (!cleaned) return true; 1762 if (/^(please\s+|kindly\s+|dear\s+)?(can you\s+|could you\s+|help me\s+|help\s+|i want to\s+|i need to\s+)?(create|build|make|design|develop|generate)\s+(me\s+)?(an?\s+|the\s+)?(app|application|mobile app|flutter app|android app|ios app)(\s+(similar|like|from|based on|using|with|to)(\s+to)?\s+(the\s+)?(attached\s+)?(image|screenshot|mockup|reference|screen\s*flow|ui|design|provided\s+image|screen))?[\s.!?,]*$/i.test(cleaned)) { 1763 return true; 1764 } 1765 var polite = cleaned.replace(/^(please\s+|kindly\s+|dear\s+)/i, '').trim(); 1766 var stripped = polite.replace(/^(please\s+|kindly\s+|dear\s+)?(can you\s+|could you\s+|help me\s+|i want to\s+|i need to\s+)?(create|build|make|design|implement|add|fix|update|improve|optimize|generate|write|turn|develop)\s+((this|that)\s+into\s+)?(an|a|the|this|that|my)?\s*/i, '').trim(); 1767 var stop = { a:1, an:1, and:1, app:1, application:1, attached:1, build:1, create:1, design:1, flow:1, for:1, from:1, image:1, kindly:1, like:1, make:1, me:1, mobile:1, mockup:1, my:1, of:1, on:1, please:1, reference:1, screen:1, screenshot:1, similar:1, the:1, this:1, to:1, with:1 }; 1768 var words = stripped.replace(/[^\p{L}\p{N}\s-]/gu, ' ').split(/\s+/).filter(Boolean).filter(function (w) { return !stop[w.toLowerCase()]; }).slice(0, 6); 1769 if (words.length) return false; 1770 var tokens = cleaned.toLowerCase().replace(/[^\p{L}\p{N}\s-]/gu, ' ').split(/\s+/).filter(Boolean); 1771 return tokens.length <= 8; 1772 } 1773 1774 function projectNameFromPrompt(prompt, opts) { 1775 opts = opts || {}; 1776 var hasImages = !!opts.hasImages; 1777 var cleaned = String(prompt || '').trim(); 1778 if (!cleaned) return 'Untitled project'; 1779 if (isGenericCreatePrompt(cleaned)) { 1780 return hasImages ? 'Untitled project' : 'Untitled project'; 1781 } 1782 var polite = cleaned.replace(/^(please\s+|kindly\s+|dear\s+)/i, '').trim(); 1783 var stripped = polite.replace(/^(please\s+|kindly\s+|dear\s+)?(can you\s+|could you\s+|help me\s+|i want to\s+|i need to\s+)?(create|build|make|design|implement|add|fix|update|improve|optimize|generate|write|turn|develop)\s+((this|that)\s+into\s+)?(an|a|the|this|that|my)?\s*/i, '').trim(); 1784 var stop = { a:1, an:1, and:1, app:1, application:1, attached:1, build:1, create:1, design:1, flow:1, for:1, from:1, image:1, kindly:1, like:1, make:1, me:1, mobile:1, mockup:1, my:1, of:1, on:1, please:1, reference:1, screen:1, screenshot:1, similar:1, the:1, this:1, to:1, with:1 }; 1785 var words = stripped.replace(/[^\p{L}\p{N}\s-]/gu, ' ').split(/\s+/).filter(Boolean).filter(function (w) { return !stop[w.toLowerCase()]; }).slice(0, 6); 1786 var title = words.map(function (w) { return w.charAt(0).toUpperCase() + w.slice(1).toLowerCase(); }).join(' '); 1787 if (title) return title.length > 48 ? title.slice(0, 47).trim() + 'â¦' : title; 1788 if (hasImages || isGenericCreatePrompt(cleaned)) return 'Untitled project'; 1789 var head = cleaned.split(/\s+/).slice(0, 8).join(' '); 1790 if (!head) return 'Untitled project'; 1791 return head.length > 48 ? head.slice(0, 47).trim() + 'â¦' : head; 1792 } 1793 1794 function stripLegacyFlutterCreateWrapper(brief) {
1795 // Older clients appended Skip Quick setup / quality-bar recipes. Cut them 1796 // so stashed guest intents and long-cached JS still pass user words only. 1797 var text = String(brief || '').trim(); 1798 if (!text) return text; 1799 var markers = [ 1800 /\nThis is a new Flutter app from FlutterGo[^\n]*/i, 1801 /\nSkip Quick setup and every clarifying question-form[^\n]*/i, 1802 /\nRequired quality bar \(already in skills[^\n]*/i, 1803 /\nInfer the app baseline from my idea[^\n]*/i, 1804 /\nFirst actions: TodoWrite[^\n]*/i, 1805 ]; 1806 var cut = text.length; 1807 for (var i = 0; i < markers.length; i++) { 1808 var m = text.match(markers[i]); 1809 if (m && typeof m.index === 'number' && m.index < cut) cut = m.index; 1810 } 1811 var figmaBlockIdx = text.indexOf('Figma file URL(s) in this brief are the'); 1812 if (figmaBlockIdx >= 0 && figmaBlockIdx < cut) cut = figmaBlockIdx; 1813 var out = text.slice(0, cut).trim().replace(/^Product idea:\s*/i, '').trim(); 1814 return out || text; 1815 } 1816 1817 function composeUserBrief(prompt) { 1818 // Pass the user's words through. Append staged links as plain URLs only â 1819 // no FlutterGo recipe / quality-bar essay wrapped around the brief. 1820 var parts = [stripLegacyFlutterCreateWrapper(prompt)]; 1821 stagedLinks.forEach(function (link) { 1822 if (!link || !link.url) return; 1823 var label = link.kind === 'figma' ? 'Figma' 1824 : link.kind === 'website' ? 'Website' 1825 : link.kind === 'github' ? 'GitHub' 1826 : 'Link'; 1827 parts.push(label + ': ' + link.url); 1828 }); 1829 stagedFiles.forEach(function (entry) { 1830 if (!entry || !looksLikeZip(entry.file)) return; 1831 parts.push('ZIP: ' + (entry.file.name || 'upload.zip')); 1832 }); 1833 return parts.filter(Boolean).join('\n\n'); 1834 } 1835 1836 function kickoffPrompt(userBrief, backendProvider) { 1837 // User input only â plus a Figma SoT block when a Figma link is staged 1838 // or present in the brief (mirrors buildFlutterHomeKickoffPrompt / 1839 // FIGMA_REFERENCE_KICKOFF_BLOCK). After extract, startBuilding rewrites 1840 // pendingPrompt with the sequential queue (foundation + screen 0 only). 1841 var brief = stripLegacyFlutterCreateWrapper(userBrief); 1842 var hasFigma = stagedLinks.some(function (link) { 1843 return link && link.kind === 'figma'; 1844 }); 1845 if (!hasFigma && /figma\.com\/(?:design|file|proto)\//i.test(brief)) hasFigma = true; 1846 var out = brief; 1847 if (hasFigma) { 1848 var block = [ 1849 'Figma file URL(s) in this brief are the visual source of truth for the app.', 1850 'Use the figma-extract on disk (`figma/`) when present. Match each screen 1:1 â do not redesign or collapse frames.', 1851 ].join('\n'); 1852 var urls = []; 1853 stagedLinks.forEach(function (link) { 1854 if (link && link.kind === 'figma' && link.url) urls.push(link.url); 1855 }); 1856 var parts = [brief, '', block]; 1857 urls.forEach(function (url) { 1858 if (brief.indexOf(url) === -1) parts.push('Figma: ' + url); 1859 }); 1860 out = parts.filter(Boolean).join('\n'); 1861 } 1862 var zipNames = stagedFiles 1863 .filter(function (entry) { return entry && looksLikeZip(entry.file); }) 1864 .map(function (entry) { return entry.file.name || 'upload.zip'; }); 1865 if (zipNames.length && out.indexOf('Attached ZIP') === -1) { 1866 out = (out ? out + '\n\n' : '') + 1867 'Attached ZIP (' + zipNames.join(', ') + ') is uploaded with this project. Extract it and use the contents as the source or visual reference for the app.'; 1868 } 1869 return appendBackendKickoff(out, backendProvider); 1870 } 1871 1872 function appendBackendKickoff(prompt, backendProvider) { 1873 if (!backendProvider) return prompt || ''; 1874 var block = 1875 backendProvider === 'supabase' 1876 ? 'Backend: Supabase â invoke `flutter-supabase-backend`. Prefer project secrets `SUPABASE_URL` / `SUPABASE_ANON_KEY` when present; never put the service-role key in Flutter.' 1877 : 'Backend: none â do not assume Supabase; use local/mock data unless the user asks for a backend later.'; 1878 var trimmed = String(prompt || '').trim(); 1879 if (!trimmed) return block; 1880 if ( 1881 trimmed.indexOf('Backend: Supabase') !== -1 1882 || trimmed.indexOf('Backend: none') !== -1 1883 ) { 1884 return trimmed; 1885 } 1886 return trimmed + '\n\n' + block; 1887 } 1888 1889 function sequentialFigmaKickoff(userBrief, build) { 1890 var brief = stripLegacyFlutterCreateWrapper(userBrief); 1891 if (!build || !Array.isArray(build.screens) || build.screens.length === 0) { 1892 return kickoffPrompt(userBrief); 1893 } 1894 var screen = build.screens[typeof build.index === 'number' ? build.index : 0] || build.screens[0]; 1895 var total = typeof build.total === 'number' ? build.total : build.screens.length; 1896 var maxPlan = 40; 1897 var planLines = []; 1898 for (var i = 0; i < build.screens.length && i < maxPlan; i++) { 1899 var s = build.screens[i]; 1900 var p = s && s.previewAsset 1901 ? (String(s.previewAsset).indexOf('figma/') === 0 1902 ? s.previewAsset 1903 : 'figma/' + String(s.previewAsset).replace(/^\//, '')) 1904 : 'figma/assets/ (preview for node ' + (s && s.id ? s.id : '?') + ')'; 1905 planLines.push((i + 1) + '. `' + (s.name || 'Untitled') + '` (`' + (s.id || '') + '`) â preview `' + p + '`'); 1906 } 1907 if (build.screens.length > maxPlan) { 1908 planLines.push('⦠+' + (build.screens.length - maxPlan) + ' more in `figma/screens.json` / `figma/screen-plans.json`'); 1909 } 1910 var preview = screen && screen.previewAsset 1911 ? (String(screen.previewAsset).indexOf('figma/') === 0 1912 ? screen.previewAsset 1913 : 'figma/' + String(screen.previewAsset).replace(/^\//, '')) 1914 : null; 1915 var lines = [ 1916 brief, 1917 '', 1918 'Build this Flutter app from the Figma extract already on disk (`figma/`). Match each screen 1:1 â keep the existing product direction from my brief.', 1919 '', 1920 '## Screens (' + Math.max(total, 1) + ')', 1921 '', 1922 ].concat(planLines).concat([ 1923 '', 1924 'Details per screen: `figma/screen-plans.json`.', 1925 '', 1926 '## Start now', 1927 screen 1928 ? ('Begin with foundation + **screen 1**: `' + (screen.name || 'Untitled') + '` (`' + (screen.id || '') + '`)' + (preview ? (' â open `' + preview + '` first, then implement 1:1. Continue through the remaining screens until the plan is done.') : ' â open its preview PNG first. Continue until every screen is done.')) 1929 : 'Begin with foundation + the first screen from the list (open its preview PNG first). Continue until every screen is done.', 1930 ]);
1931 return lines.filter(Boolean).join('\n').trim(); 1932 } 1933 1934 1935 function looksLikeImage(file) { 1936 return file && ((file.type && file.type.indexOf('image/') === 0) || /\.(png|jpe?g|gif|webp|svg|avif|bmp)$/i.test(file.name || '')); 1937 } 1938 1939 function looksLikeZip(file) { 1940 if (!file) return false; 1941 var type = String(file.type || '').toLowerCase(); 1942 if (type === 'application/zip' || type === 'application/x-zip-compressed' || type === 'application/x-zip') { 1943 return true; 1944 } 1945 return /\.zip$/i.test(file.name || ''); 1946 } 1947 1948 function formatBytes(n) { 1949 if (!Number.isFinite(n) || n < 0) return ''; 1950 if (n < 1024) return n + ' B'; 1951 if (n < 1024 * 1024) return Math.round(n / 1024) + ' KB'; 1952 return (n / (1024 * 1024)).toFixed(1) + ' MB'; 1953 } 1954 1955 function normalizeFigmaFileUrl(raw) { 1956 var trimmed = String(raw || '').trim().replace(/[.,;:!?]+$/, ''); 1957 if (!trimmed) return ''; 1958 if (!/(?:^|\/\/)(?:www\.)?figma\.com\/(?:design|file|proto)\//i.test(trimmed)) { 1959 return ''; 1960 } 1961 var withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : 'https://' + trimmed;
1962 try { 1963 var parsed = new URL(withScheme); 1964 if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return ''; 1965 var host = parsed.hostname.replace(/^www\./i, '').toLowerCase(); 1966 if (host !== 'figma.com' && !/\.figma\.com$/i.test(host)) { 1967 return ''; 1968 } 1969 if (!/^\/(?:design|file|proto)\//i.test(parsed.pathname)) return ''; 1970 return parsed.toString(); 1971 } catch (e) { 1972 return ''; 1973 } 1974 } 1975 1976 function scrapeFigmaFileUrlFromText(text) { 1977 var match = String(text || '').match( 1978 /(?:https?:\/\/)?(?:www\.)?figma\.com\/(?:design|file|proto)\/[^\s)\]>'"]+/i, 1979 ); 1980 return match ? normalizeFigmaFileUrl(match[0]) : ''; 1981 } 1982 1983 function normalizeUrl(raw) { 1984 var value = String(raw || '').trim(); 1985 if (!value) return ''; 1986 // Bare Figma shares are common; normalize before the generic https:// prefix. 1987 var figma = normalizeFigmaFileUrl(value); 1988 if (figma) return figma; 1989 if (!/^https?:\/\//i.test(value)) value = 'https://' + value; 1990 try { 1991 var parsed = new URL(value); 1992 if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return ''; 1993 return parsed.toString(); 1994 } catch (e) { 1995 return ''; 1996 } 1997 } 1998 1999 function detectLinkKind(url) { 2000 try { 2001 var host = new URL(url).hostname.replace(/^www\./, '').toLowerCase(); 2002 if (host === 'figma.com' || host.endsWith('.figma.com')) return 'figma'; 2003 if (host === 'github.com' || host === 'gist.github.com') return 'github'; 2004 return 'website'; 2005 } catch (e) { 2006 return 'website'; 2007 } 2008 } 2009 2010 function linkTitle(kind, url) { 2011 try { 2012 var u = new URL(url); 2013 if (kind === 'figma') return 'Figma · ' + (u.pathname.split('/').filter(Boolean)[1] || u.hostname); 2014 if (kind === 'github') { 2015 var parts = u.pathname.split('/').filter(Boolean); 2016 return parts.length >= 2 ? parts[0] + '/' + parts[1] : u.hostname; 2017 } 2018 return u.hostname.replace(/^www\./, ''); 2019 } catch (e) { 2020 return url; 2021 } 2022 } 2023 2024 function closeAttachMenu() { 2025 if (!attachMenu || !attachToggle) return; 2026 attachMenu.hidden = true; 2027 attachToggle.setAttribute('aria-expanded', 'false'); 2028 } 2029 2030 function openAttachMenu() { 2031 if (!attachMenu || !attachToggle) return; 2032 attachMenu.hidden = false; 2033 attachToggle.setAttribute('aria-expanded', 'true'); 2034 } 2035 2036 function closeLinkDialog() { 2037 if (!linkDialog) return; 2038 linkDialog.hidden = true; 2039 pendingLinkKind = null; 2040 if (linkInput) linkInput.value = ''; 2041 } 2042 2043 function openLinkDialog(kind) { 2044 pendingLinkKind = kind || 'website'; 2045 closeAttachMenu(); 2046 if (!linkDialog) return; 2047 if (linkLabel) { 2048 linkLabel.textContent = pendingLinkKind === 'figma' 2049 ? 'Paste a Figma file link' 2050 : 'Paste a website URL'; 2051 } 2052 if (linkInput) { 2053 linkInput.placeholder = pendingLinkKind === 'figma' 2054 ? 'https://www.figma.com/file/â¦' 2055 : 'https://example.com'; 2056 linkInput.value = ''; 2057 } 2058 linkDialog.hidden = false; 2059 if (linkInput) linkInput.focus(); 2060 } 2061 2062 function renderAttachChips() { 2063 if (!chipsEl) return; 2064 chipsEl.textContent = ''; 2065 var hasFiles = stagedFiles.length > 0; 2066 var hasLinks = stagedLinks.length > 0; 2067 if (!hasFiles && !hasLinks) { 2068 chipsEl.hidden = true; 2069 return; 2070 } 2071 chipsEl.hidden = false; 2072 2073 stagedLinks.forEach(function (link, index) { 2074 var chip = document.createElement('div'); 2075 chip.className = 'fgs-attach-chip fgs-attach-chip--link'; 2076 var glyph = document.createElement('span'); 2077 glyph.className = 'fgs-attach-chip__glyph'; 2078 glyph.textContent = link.kind === 'figma' ? 'F' : link.kind === 'github' ? 'G' : 'W'; 2079 chip.appendChild(glyph); 2080 var meta = document.createElement('div'); 2081 meta.className = 'fgs-attach-chip__meta'; 2082 var name = document.createElement('div'); 2083 name.className = 'fgs-attach-chip__name'; 2084 name.textContent = link.title || link.url; 2085 var type = document.createElement('div'); 2086 type.className = 'fgs-attach-chip__type'; 2087 type.textContent = link.kind === 'figma' ? 'Figma link' : link.kind === 'github' ? 'GitHub' : 'Website URL'; 2088 meta.appendChild(name); 2089 meta.appendChild(type); 2090 chip.appendChild(meta); 2091 var remove = document.createElement('button'); 2092 remove.type = 'button'; 2093 remove.className = 'fgs-attach-chip__remove'; 2094 remove.setAttribute('aria-label', 'Remove link'); 2095 remove.textContent = 'Ã'; 2096 remove.addEventListener('click', function () { 2097 stagedLinks.splice(index, 1); 2098 renderAttachChips(); 2099 }); 2100 chip.appendChild(remove); 2101 chipsEl.appendChild(chip); 2102 }); 2103
2104 stagedFiles.forEach(function (entry, index) { 2105 var chip = document.createElement('div'); 2106 chip.className = 'fgs-attach-chip'; 2107 if (entry.previewUrl) { 2108 var img = document.createElement('img'); 2109 img.className = 'fgs-attach-chip__thumb'; 2110 img.src = entry.previewUrl; 2111 img.alt = ''; 2112 chip.appendChild(img); 2113 } else { 2114 var glyph = document.createElement('span'); 2115 glyph.className = 'fgs-attach-chip__glyph'; 2116 glyph.textContent = (entry.file.name || '?').charAt(0).toUpperCase(); 2117 chip.appendChild(glyph); 2118 } 2119 var meta = document.createElement('div'); 2120 meta.className = 'fgs-attach-chip__meta'; 2121 var name = document.createElement('div'); 2122 name.className = 'fgs-attach-chip__name'; 2123 name.textContent = entry.file.name || 'file'; 2124 var type = document.createElement('div'); 2125 type.className = 'fgs-attach-chip__type'; 2126 type.textContent = (looksLikeImage(entry.file) ? 'Image' : looksLikeZip(entry.file) ? 'ZIP' : 'File') + 2127 (entry.file.size ? ' · ' + formatBytes(entry.file.size) : ''); 2128 meta.appendChild(name); 2129 meta.appendChild(type); 2130 chip.appendChild(meta); 2131 var remove = document.createElement('button'); 2132 remove.type = 'button'; 2133 remove.className = 'fgs-attach-chip__remove'; 2134 remove.setAttribute('aria-label', 'Remove ' + (entry.file.name || 'file')); 2135 remove.textContent = 'Ã'; 2136 remove.addEventListener('click', function () { 2137 if (entry.previewUrl) { 2138 try { URL.revokeObjectURL(entry.previewUrl); } catch (e) {} 2139 } 2140 stagedFiles.splice(index, 1); 2141 renderAttachChips(); 2142 }); 2143 chip.appendChild(remove); 2144 chipsEl.appendChild(chip); 2145 }); 2146 } 2147 2148 function stageFiles(fileList) { 2149 var files = Array.prototype.slice.call(fileList || []); 2150 files.forEach(function (file) { 2151 if (!file) return; 2152 var exists = stagedFiles.some(function (entry) { 2153 return entry.file.name === file.name && entry.file.size === file.size && entry.file.lastModified === file.lastModified; 2154 }); 2155 if (exists) return; 2156 var previewUrl = looksLikeImage(file) ? URL.createObjectURL(file) : null; 2157 stagedFiles.push({ file: file, previewUrl: previewUrl }); 2158 }); 2159 renderAttachChips(); 2160 if (files.some(looksLikeZip)) { 2161 showReadyStatus('Your zip is uploaded. Describe the app or click Start building.'); 2162 } 2163 } 2164 2165 function stageLink(kind, rawUrl) { 2166 var url = normalizeUrl(rawUrl); 2167 if (!url) return false; 2168 var resolvedKind = kind || detectLinkKind(url); 2169 if (kind === 'figma' && detectLinkKind(url) !== 'figma') { 2170 // Still allow â user may paste a Figma share host we don't classify. 2171 resolvedKind = 'figma'; 2172 } 2173 var exists = stagedLinks.some(function (entry) { return entry.url === url; }); 2174 if (exists) return true; 2175 stagedLinks.push({ 2176 kind: resolvedKind, 2177 url: url, 2178 title: linkTitle(resolvedKind, url), 2179 }); 2180 renderAttachChips(); 2181 return true; 2182 } 2183 2184 function uploadProjectFiles(projectId, files) { 2185 if (!files.length) return Promise.resolve([]); 2186 var uploaded = []; 2187 var chain = Promise.resolve(); 2188 for (var i = 0; i < files.length; i += 12) { 2189 (function (batch) { 2190 chain = chain.then(function () { 2191 var formData = new FormData(); 2192 batch.forEach(function (file) { formData.append('files', file); }); 2193 return fetch('/api/projects/' + encodeURIComponent(projectId) + '/upload', { 2194 method: 'POST', 2195 credentials: 'include', 2196 body: formData, 2197 }).then(function (resp) { 2198 if (!resp.ok) throw new Error('Could not upload attachments.'); 2199 return resp.json(); 2200 }).then(function (json) { 2201 var list = json && Array.isArray(json.files) ? json.files : []; 2202 list.forEach(function (f) { 2203 uploaded.push({ 2204 path: f.path, 2205 name: f.originalName || f.name, 2206 kind: /\.(png|jpe?g|gif|webp|svg|avif|bmp)$/i.test(f.name || '') ? 'image' : 'file', 2207 size: f.size, 2208 }); 2209 }); 2210 }); 2211 }); 2212 })(files.slice(i, i + 12)); 2213 } 2214 return chain.then(function () { return uploaded; }); 2215 } 2216 2217 function buildCreateBody(prompt, name, projectId, opts) { 2218 opts = opts || {}; 2219 var backendProvider = opts.backendProvider || null; 2220 var figmaLinks = stagedLinks.filter(function (link) { 2221 return link && link.kind === 'figma' && link.url; 2222 }); 2223 var scrapedFromBrief = ''; 2224 if (figmaLinks.length === 0) { 2225 scrapedFromBrief = scrapeFigmaFileUrlFromText(prompt); 2226 } 2227 var body = { 2228 id: projectId, 2229 name: name, 2230 skillId: 'fluttergo-pipeline', 2231 designSystemId: null, 2232 pendingPrompt: kickoffPrompt(prompt, backendProvider), 2233 conversationMode: 'design', 2234 skipDiscoveryBrief: true, 2235 metadata: { 2236 kind: 'prototype', 2237 platform: 'flutter', 2238 platformTargets: ['flutter'], 2239 skipDiscoveryBrief: true, 2240 promptTemplate: { prompt: prompt }, 2241 // The Website chip: the project's product is its website module. 2242 buildTarget: guestBuildTarget() === 'website' ? 'website' : undefined, 2243 referenceLinks: stagedLinks.map(function (link) { 2244 return { kind: link.kind, url: link.url, title: link.title }; 2245 }), 2246 }, 2247 }; 2248 if (backendProvider) { 2249 body.metadata.backendProvider = backendProvider; 2250 body.metadata.backendSetup = { 2251 status: backendProvider === 'none' ? 'skipped' : 'selected', 2252 selectedAt: Date.now(), 2253 }; 2254 } 2255 // Same binding Home uses for "From Figma": od-figma-migration so create 2256 // applies figma-extract + connector OAuth instead of treating the URL as 2257 // decorative prompt text. Target stack is Flutter â FlutterGo's product. 2258 var figmaUrl = figmaLinks.length > 0 2259 ? (normalizeFigmaFileUrl(figmaLinks[0].url) || figmaLinks[0].url) 2260 : scrapedFromBrief; 2261 if (figmaUrl) { 2262 body.pluginId = 'od-figma-migration'; 2263 body.pluginInputs = { 2264 figmaUrl: figmaUrl, 2265 targetStack: 'Flutter (Dart) â Android & iOS', 2266 }; 2267 } 2268 return body; 2269 } 2270 2271 function resizeInput() { 2272 input.style.height = 'auto';
2273 // Floor was 96px, which left the prompt card visibly half-empty beside a 2274 // one-line placeholder. 68px still clears ~3 lines before it grows. 2275 // 2276 // Only what the visitor has typed grows the box. A textarea's 2277 // scrollHeight counts the placeholder too, and the animated placeholder 2278 // is a whole sentence: on a phone it wraps to six lines, so an empty 2279 // composer opened at the 220px cap and ate half the fold before anyone 2280 // had typed anything. 2281 var next = input.value ? Math.min(Math.max(input.scrollHeight, 68), 220) : 68; 2282 input.style.height = next + 'px'; 2283 } 2284 2285 function setPrompt(text) { 2286 input.value = text; 2287 resizeInput(); 2288 input.focus(); 2289 } 2290 2291 function renderExamples() { 2292 if (!examplesRow) return; 2293 var shuffled = EXAMPLE_POOL.slice().sort(function () { return Math.random() - 0.5; }); 2294 var pick = shuffled.slice(0, 3); 2295 examplesRow.textContent = ''; 2296 pick.forEach(function (text) { 2297 var btn = document.createElement('button'); 2298 btn.type = 'button'; 2299 btn.className = 'fgs-example-chip'; 2300 btn.setAttribute('data-guest-prompt-example', ''); 2301 btn.setAttribute('data-example', text); 2302 btn.textContent = text; 2303 btn.addEventListener('click', function () { 2304 setPrompt(text); 2305 }); 2306 examplesRow.appendChild(btn); 2307 }); 2308 } 2309 2310 Array.prototype.forEach.call(document.querySelectorAll('[data-guest-tile]'), function (tile) { 2311 tile.addEventListener('click', function () { 2312 Array.prototype.forEach.call(document.querySelectorAll('[data-guest-tile]'), function (el) { 2313 el.classList.remove('is-active'); 2314 }); 2315 tile.classList.add('is-active'); 2316 var text = (tile.getAttribute('data-example') || '').trim(); 2317 if (text) setPrompt(text); 2318 }); 2319 }); 2320 2321 if (refreshBtn) { 2322 refreshBtn.addEventListener('click', function () { 2323 refreshBtn.classList.add('is-spinning'); 2324 renderExamples(); 2325 window.setTimeout(function () { 2326 refreshBtn.classList.remove('is-spinning'); 2327 }, 220); 2328 }); 2329 } 2330 2331 if (attachToggle && attachMenu) { 2332 attachToggle.addEventListener('click', function (event) { 2333 event.preventDefault(); 2334 event.stopPropagation(); 2335 if (attachMenu.hidden) openAttachMenu(); 2336 else closeAttachMenu(); 2337 }); 2338 document.addEventListener('click', function (event) { 2339 if (!attachRoot) return; 2340 if (!attachRoot.contains(event.target)) { 2341 closeAttachMenu(); 2342 if (linkDialog && !linkDialog.contains(event.target)) closeLinkDialog(); 2343 } 2344 }); 2345 document.addEventListener('keydown', function (event) { 2346 if (event.key === 'Escape') { 2347 closeAttachMenu(); 2348 closeLinkDialog(); 2349 } 2350 }); 2351 } 2352 2353 Array.prototype.forEach.call(form.querySelectorAll('[data-guest-attach-action]'), function (item) { 2354 item.addEventListener('click', function (event) { 2355 var action = item.getAttribute('data-guest-attach-action'); 2356 if (action === 'files') { 2357 event.preventDefault(); 2358 closeAttachMenu(); 2359 if (fileInput) fileInput.click(); 2360 } else if (action === 'images') { 2361 event.preventDefault(); 2362 closeAttachMenu(); 2363 if (imageInput) imageInput.click(); 2364 } else if (action === 'figma' || action === 'website') { 2365 event.preventDefault(); 2366 openLinkDialog(action); 2367 } else if (action === 'github') { 2368 event.preventDefault(); 2369 closeAttachMenu(); 2370 openGithubModal(); 2371 } else if (action === 'zip') { 2372 event.preventDefault(); 2373 closeAttachMenu(); 2374 if (zipInput) zipInput.click(); 2375 } else { 2376 closeAttachMenu(); 2377 } 2378 }); 2379 }); 2380 2381 if (linkCancel) { 2382 linkCancel.addEventListener('click', function (event) { 2383 event.preventDefault(); 2384 closeLinkDialog(); 2385 }); 2386 } 2387 if (linkConfirm) { 2388 linkConfirm.addEventListener('click', function (event) { 2389 event.preventDefault(); 2390 var ok = stageLink(pendingLinkKind || 'website', linkInput ? linkInput.value : ''); 2391 if (!ok && linkInput) { 2392 linkInput.focus(); 2393 return; 2394 } 2395 closeLinkDialog(); 2396 }); 2397 } 2398 if (linkInput) { 2399 linkInput.addEventListener('keydown', function (event) { 2400 if (event.key !== 'Enter') return; 2401 event.preventDefault(); 2402 if (linkConfirm) linkConfirm.click(); 2403 }); 2404 } 2405 2406 if (fileInput) { 2407 fileInput.addEventListener('change', function () { 2408 stageFiles(fileInput.files); 2409 fileInput.value = ''; 2410 }); 2411 } 2412 if (imageInput) { 2413 imageInput.addEventListener('change', function () { 2414 stageFiles(imageInput.files); 2415 imageInput.value = ''; 2416 }); 2417 } 2418 2419 if (box) { 2420 ['dragenter', 'dragover'].forEach(function (type) { 2421 box.addEventListener(type, function (event) { 2422 event.preventDefault(); 2423 event.stopPropagation(); 2424 box.classList.add('is-dragover'); 2425 }); 2426 }); 2427 ['dragleave', 'drop'].forEach(function (type) { 2428 box.addEventListener(type, function (event) { 2429 event.preventDefault(); 2430 event.stopPropagation(); 2431 box.classList.remove('is-dragover'); 2432 }); 2433 }); 2434 box.addEventListener('drop', function (event) { 2435 var files = event.dataTransfer && event.dataTransfer.files; 2436 if (files && files.length) stageFiles(files); 2437 }); 2438 } 2439 2440 input.addEventListener('paste', function (event) { 2441 var clipboard = event.clipboardData; 2442 if (!clipboard) return; 2443 var files = []; 2444 // Prefer clipboard.files (Firefox / some macOS paths), then items. 2445 if (clipboard.files && clipboard.files.length) { 2446 Array.prototype.forEach.call(clipboard.files, function (file) { 2447 if (file) files.push(file); 2448 }); 2449 } else if (clipboard.items) { 2450 Array.prototype.forEach.call(clipboard.items, function (item) { 2451 if (item.kind === 'file') { 2452 var asFile = item.getAsFile(); 2453 if (asFile) files.push(asFile); 2454 } 2455 }); 2456 } 2457 if (files.length) { 2458 event.preventDefault(); 2459 stageFiles(files); 2460 return; 2461 } 2462 var text = clipboard.getData('text') || clipboard.getData('text/plain') || ''; 2463 if (text) { 2464 var trimmedPaste = String(text).trim(); 2465 var figmaPaste = scrapeFigmaFileUrlFromText(trimmedPaste); 2466 if (figmaPaste && /^[\s]*((?:https?:\/\/)?(?:www\.)?figma\.com\/\S+)[\s]*$/i.test(trimmedPaste)) { 2467 event.preventDefault(); 2468 stageLink('figma', figmaPaste); 2469 return; 2470 } 2471 var match = trimmedPaste.match(/^https?:\/\/\S+$/i); 2472 if (match) { 2473 event.preventDefault(); 2474 stageLink(detectLinkKind(match[0]), match[0]); 2475 } 2476 } 2477 }); 2478 2479 input.addEventListener('input', resizeInput); 2480 input.addEventListener('keydown', function (event) { 2481 if (event.key !== 'Enter' || event.shiftKey) return; 2482 event.preventDefault(); 2483 if (typeof form.requestSubmit === 'function') form.requestSubmit(); 2484 else form.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true })); 2485 }); 2486 2487 var statusTimer = null; 2488 function startBuildingUI(opts) { 2489 opts = opts || {}; 2490 activeBuildStatus = opts.figma ? FIGMA_BUILD_STATUS : BUILD_STATUS; 2491 if (!submit) return; 2492 submit.disabled = true; 2493 submit.classList.add('is-building'); 2494 var spinner = submit.querySelector('.fgs-prompt__spinner'); 2495 if (spinner) spinner.hidden = false;
2496 if (figmaConnectBtn) figmaConnectBtn.hidden = true; 2497 if (statusRow) statusRow.hidden = false; 2498 if (statusEl) { 2499 statusEl.hidden = false; 2500 statusEl.classList.remove('is-error'); 2501 statusEl.style.color = ''; 2502 statusEl.textContent = activeBuildStatus[0]; 2503 statusEl.removeAttribute('data-figma-phase'); 2504 } 2505 if (opts.figma) { 2506 showFigmaProgressPanel('Creating your Flutter workspaceâ¦'); 2507 } else { 2508 hideFigmaProgressPanel(); 2509 } 2510 var step = 0; 2511 clearInterval(statusTimer); 2512 statusTimer = setInterval(function () { 2513 step += 1; 2514 if (!statusEl) { 2515 clearInterval(statusTimer); 2516 return; 2517 } 2518 // Prefer live Figma phase from the server when present. 2519 if (statusEl.getAttribute('data-figma-phase') === '1') return; 2520 var idx = Math.min(step, activeBuildStatus.length - 1); 2521 statusEl.textContent = activeBuildStatus[idx]; 2522 }, 1200); 2523 } 2524 2525 function showFigmaProgressPanel(phase) { 2526 if (!figmaProgress) return; 2527 figmaProgress.hidden = false; 2528 if (figmaPhaseEl) figmaPhaseEl.textContent = phase || 'Connecting to your Figma accountâ¦'; 2529 if (figmaCountsEl) figmaCountsEl.textContent = ''; 2530 if (figmaInventoryEl) figmaInventoryEl.innerHTML = ''; 2531 } 2532 2533 function hideFigmaProgressPanel() { 2534 if (!figmaProgress) return; 2535 figmaProgress.hidden = true; 2536 if (figmaPhaseEl) figmaPhaseEl.textContent = ''; 2537 if (figmaCountsEl) figmaCountsEl.textContent = ''; 2538 if (figmaInventoryEl) figmaInventoryEl.innerHTML = ''; 2539 if (figmaViewAllBtn) figmaViewAllBtn.hidden = true; 2540 closeFigmaExportsModal(); 2541 } 2542 2543 function projectRawPath(projectId, relPath) { 2544 var safe = String(relPath || '') 2545 .split('/') 2546 .filter(Boolean) 2547 .map(function (seg) { return encodeURIComponent(seg); }) 2548 .join('/'); 2549 return '/api/projects/' + encodeURIComponent(projectId) + '/raw/' + safe; 2550 } 2551 2552 function escapeHtml(value) { 2553 return String(value == null ? '' : value) 2554 .replace(/&/g, '&') 2555 .replace(/</g, '<') 2556 .replace(/>/g, '>') 2557 .replace(/"/g, '"'); 2558 } 2559 2560 function renderFigmaInventory(screenNames, screenCount, assetCount) { 2561 var names = Array.isArray(screenNames) 2562 ? screenNames.filter(function (n) { return typeof n === 'string' && n.trim(); }) 2563 : []; 2564 var totalScreens = typeof screenCount === 'number' && screenCount > 0 2565 ? screenCount 2566 : names.length; 2567 var totalAssets = typeof assetCount === 'number' && assetCount > 0 ? assetCount : 0; 2568 lastFigmaInventory = { 2569 screenNames: names, 2570 screenCount: totalScreens, 2571 assetCount: totalAssets, 2572 phase: (figmaPhaseEl && figmaPhaseEl.textContent) || lastFigmaInventory.phase || '', 2573 screenPreviews: Array.isArray(lastFigmaInventory.screenPreviews) 2574 ? lastFigmaInventory.screenPreviews 2575 : [], 2576 projectId: lastFigmaInventory.projectId || '', 2577 }; 2578 2579 if (figmaCountsEl) { 2580 var parts = []; 2581 if (totalScreens > 0) { 2582 parts.push(totalScreens + ' screen' + (totalScreens === 1 ? '' : 's')); 2583 } 2584 if (totalAssets > 0) { 2585 parts.push(totalAssets + ' asset' + (totalAssets === 1 ? '' : 's')); 2586 } 2587 figmaCountsEl.textContent = parts.join(' · '); 2588 } 2589 if (figmaViewAllBtn) { 2590 figmaViewAllBtn.hidden = !(totalScreens > 0 || names.length > 0 || totalAssets > 0); 2591 figmaViewAllBtn.textContent = totalScreens > 12 || names.length > 12 2592 ? 'View all ' + Math.max(totalScreens, names.length) + ' exports'
2593 : 'View all exports'; 2594 } 2595 if (figmaInventoryEl) { 2596 var show = names.slice(0, 12); 2597 var html = ''; 2598 for (var i = 0; i < show.length; i++) { 2599 html += '<li style="animation-delay:' + (i * 35) + 'ms">' + 2600 escapeHtml(show[i]) + '</li>'; 2601 } 2602 if (Math.max(totalScreens, names.length) > show.length) { 2603 var more = Math.max(totalScreens, names.length) - show.length; 2604 html += '<li class="is-more" data-guest-figma-view-all-chip role="button" tabindex="0">+' + 2605 more + ' more</li>'; 2606 } 2607 figmaInventoryEl.innerHTML = html; 2608 } 2609 // Keep the open inventory dialog in sync as names/previews arrive. 2610 if (figmaExportsModal && !figmaExportsModal.hidden) { 2611 openFigmaExportsModal(); 2612 } 2613 } 2614 2615 function guessFigmaPreviewPath(screen) { 2616 if (!screen) return ''; 2617 var preview = typeof screen.previewAsset === 'string' ? screen.previewAsset.trim() : ''; 2618 if (preview) { 2619 if (preview.indexOf('figma/') === 0) return preview; 2620 if (preview.indexOf('assets/') === 0) return 'figma/' + preview; 2621 return 'figma/assets/' + preview.replace(/^\//, ''); 2622 } 2623 var id = typeof screen.id === 'string' ? screen.id.trim() : ''; 2624 if (!id) return ''; 2625 // figma-extract writes screen PNGs as `<nodeId with : â ->.png`. 2626 return 'figma/assets/' + id.replace(/:/g, '-') + '.png'; 2627 } 2628 2629 function openFigmaExportsModal() { 2630 if (!figmaExportsModal) return; 2631 var names = lastFigmaInventory.screenNames || []; 2632 var previews = Array.isArray(lastFigmaInventory.screenPreviews) 2633 ? lastFigmaInventory.screenPreviews 2634 : []; 2635 var totalScreens = lastFigmaInventory.screenCount || names.length; 2636 var totalAssets = lastFigmaInventory.assetCount || 0; 2637 var projectId = lastFigmaInventory.projectId || ''; 2638 var bodyEl = figmaExportsModal.querySelector('.guest-figma-exports__body'); 2639 var priorScroll = bodyEl ? bodyEl.scrollTop : 0; 2640 if (figmaExportsLead) { 2641 figmaExportsLead.textContent = lastFigmaInventory.phase 2642 ? lastFigmaInventory.phase 2643 : 'Everything FlutterGo pulled from your file so far.'; 2644 } 2645 if (figmaExportsStats) { 2646 figmaExportsStats.innerHTML = 2647 '<div class="guest-figma-exports__stat"><strong>' + totalScreens + 2648 '</strong><span>Screens</span></div>' + 2649 '<div class="guest-figma-exports__stat"><strong>' + totalAssets + 2650 '</strong><span>Assets (icons / buttons / previews)</span></div>'; 2651 } 2652 if (figmaExportsScreens) { 2653 if (!names.length && !previews.length) { 2654 figmaExportsScreens.innerHTML = 2655 '<li><div class="guest-figma-exports__meta"><span class="name">No screen names yet â wait until Figma pull finishes, or reconnect Figma and try again.</span></div></li>'; 2656 } else { 2657 var listHtml = ''; 2658 var rows = previews.length ? previews : names.map(function (name) { 2659 return { name: name, previewPath: '' }; 2660 }); 2661 for (var i = 0; i < rows.length; i++) { 2662 var row = rows[i]; 2663 var idx = String(i + 1).padStart(2, '0'); 2664 var thumb = ''; 2665 if (projectId && row.previewPath) { 2666 thumb = 2667 '<img class="guest-figma-exports__thumb" src="' + 2668 projectRawPath(projectId, row.previewPath) + 2669 '" alt="' + escapeHtml(row.name) + ' preview" loading="lazy" ' + 2670 'onerror="this.classList.add(\'is-missing\');this.nextElementSibling&&this.nextElementSibling.classList.add(\'is-on\')" />'; 2671 } 2672 listHtml += 2673 '<li class="guest-figma-exports__screen">' + 2674 '<div class="guest-figma-exports__frame">' + 2675 '<span class="guest-figma-exports__badge">' + idx + '</span>' + 2676 thumb + 2677 '<div class="guest-figma-exports__placeholder' + (thumb ? '' : ' is-on') + '">' + 2678 (thumb ? 'Loading previewâ¦' : 'Preview pending') + 2679 '</div>' + 2680 '</div>' + 2681 '<div class="guest-figma-exports__meta">' + 2682 '<span class="idx">' + idx + '</span>' + 2683 '<span class="name">' + escapeHtml(row.name) + '</span>' + 2684 '</div></li>'; 2685 } 2686 if (totalScreens > rows.length) { 2687 listHtml += 2688 '<li class="guest-figma-exports__more" role="note">' + 2689 '+' + (totalScreens - rows.length) + 2690 ' more screens â full list in figma/OVERVIEW.md after open' + 2691 '</li>'; 2692 } 2693 figmaExportsScreens.innerHTML = listHtml; 2694 } 2695 } 2696 if (figmaExportsAssets) { 2697 if (totalAssets > 0) { 2698 figmaExportsAssets.textContent = 2699 totalAssets + ' PNG files downloaded into figma/assets/ â screen previews plus icons and buttons (capped for large files).'; 2700 } else if (names.length > 0) { 2701 figmaExportsAssets.textContent =
2702 'Screens were listed, but asset download is still empty â reconnect Figma if icons/previews are missing after the project opens.'; 2703 } else { 2704 figmaExportsAssets.textContent = 2705 'Asset download is still in progress, or none have landed yet.'; 2706 } 2707 } 2708 figmaExportsModal.hidden = false; 2709 try { 2710 document.documentElement.classList.add('guest-figma-exports-open'); 2711 } catch (e) {} 2712 if (bodyEl) { 2713 bodyEl.scrollTop = priorScroll; 2714 } 2715 } 2716 2717 function closeFigmaExportsModal() { 2718 if (!figmaExportsModal) return; 2719 figmaExportsModal.hidden = true; 2720 try { 2721 document.documentElement.classList.remove('guest-figma-exports-open'); 2722 } catch (e) {} 2723 } 2724 2725 if (figmaViewAllBtn) { 2726 figmaViewAllBtn.addEventListener('click', function (event) { 2727 event.preventDefault(); 2728 openFigmaExportsModal(); 2729 }); 2730 } 2731 if (figmaInventoryEl) { 2732 figmaInventoryEl.addEventListener('click', function (event) { 2733 var chip = event.target && event.target.closest 2734 ? event.target.closest('[data-guest-figma-view-all-chip]') 2735 : null; 2736 if (!chip) return; 2737 event.preventDefault(); 2738 openFigmaExportsModal(); 2739 }); 2740 figmaInventoryEl.addEventListener('keydown', function (event) { 2741 if (event.key !== 'Enter' && event.key !== ' ') return; 2742 var chip = event.target && event.target.closest 2743 ? event.target.closest('[data-guest-figma-view-all-chip]') 2744 : null; 2745 if (!chip) return; 2746 event.preventDefault(); 2747 openFigmaExportsModal(); 2748 }); 2749 } 2750 if (figmaExportsModal) { 2751 figmaExportsModal.addEventListener('click', function (event) { 2752 var dismiss = event.target && event.target.closest 2753 ? event.target.closest('[data-guest-figma-exports-dismiss]') 2754 : null; 2755 if (dismiss) { 2756 event.preventDefault(); 2757 closeFigmaExportsModal(); 2758 } 2759 }); 2760 document.addEventListener('keydown', function (event) { 2761 if (event.key === 'Escape' && figmaExportsModal && !figmaExportsModal.hidden) { 2762 closeFigmaExportsModal(); 2763 } 2764 }); 2765 } 2766 2767 function setFigmaPhase(text, meta) { 2768 meta = meta || {}; 2769 if (text) lastFigmaInventory.phase = text; 2770 if (figmaProgress) { 2771 figmaProgress.hidden = false; 2772 if (figmaPhaseEl && text) figmaPhaseEl.textContent = text; 2773 renderFigmaInventory(meta.screenNames, meta.screenCount, meta.assetCount); 2774 } 2775 if (!statusEl || !text) return; 2776 statusEl.setAttribute('data-figma-phase', '1'); 2777 statusEl.classList.remove('is-error'); 2778 statusEl.style.color = ''; 2779 statusEl.textContent = text; 2780 } 2781 2782 function isFigmaConnectError(message) { 2783 return /connect your figma|figma account|CONNECTOR_NOT_CONNECTED|CONNECTOR_AUTH_CONFIG_REQUIRED|FIGMA_EXTRACT_FAILED|without (?:file )?access|composio|figma connection|authorization|not enabled on this server|auth_configs/i.test( 2784 String(message || ''), 2785 ); 2786 } 2787 2788 function isFigmaUnavailableError(message) { 2789 var text = String(message || ''); 2790 // Only treat as "Composio off" when that is the actual fault â not every 2791 // error that happens to mention the word Composio (e.g. scoped-key 2792 // auth_configs permission failures). 2793 if (/auth_configs/i.test(text) && /permission|write access|read access/i.test(text)) { 2794 return false; 2795 } 2796 return /CONNECTOR_AUTH_CONFIG_REQUIRED|composio is not configured|not enabled on this server|admin â connectors|admin-configured composio/i.test( 2797 text, 2798 ); 2799 } 2800 2801 function isComposioNotConfiguredError(message) { 2802 var text = String(message || ''); 2803 return /composio is not configured|admin-configured composio|require a signed-in session with admin-configured composio/i.test( 2804 text, 2805 ); 2806 } 2807 2808 function isAuthConfigsPermissionError(message) { 2809 var text = String(message || ''); 2810 return /auth_configs/i.test(text) && /permission|write access|read access|cannot create auth configs/i.test(text); 2811 } 2812 2813 function markFigmaConnectAction(action) { 2814 var btn = form.querySelector('[data-guest-figma-connect]'); 2815 if (!btn) return;
2816 if (action === 'integrations') { 2817 btn.setAttribute('data-figma-action', 'integrations'); 2818 } else { 2819 btn.removeAttribute('data-figma-action'); 2820 } 2821 } 2822 2823 function resetBuildingUI(message, options) { 2824 submitting = false; 2825 clearInterval(statusTimer); 2826 if (statusEl) statusEl.removeAttribute('data-figma-phase'); 2827 hideFigmaProgressPanel(); 2828 if (submit) { 2829 submit.disabled = false; 2830 submit.classList.remove('is-building'); 2831 var spinner = submit.querySelector('.fgs-prompt__spinner'); 2832 if (spinner) spinner.hidden = true; 2833 } 2834 var showFigmaConnect = Boolean( 2835 options && options.figmaConnect != null 2836 ? options.figmaConnect 2837 : isFigmaConnectError(message), 2838 ); 2839 var connectLabel = 2840 options && typeof options.connectLabel === 'string' && options.connectLabel.trim() 2841 ? options.connectLabel.trim() 2842 : 'Connect Figma'; 2843 // Keep the real API/setup message â overwriting it made Connect look broken. 2844 var displayMessage = message || ''; 2845 if (statusRow) statusRow.hidden = !displayMessage; 2846 if (statusEl) { 2847 if (displayMessage) { 2848 statusEl.hidden = false; 2849 statusEl.textContent = displayMessage; 2850 statusEl.classList.add('is-error'); 2851 statusEl.style.color = '#b91c1c'; 2852 } else { 2853 statusEl.hidden = true; 2854 statusEl.textContent = ''; 2855 statusEl.classList.remove('is-error'); 2856 statusEl.style.color = ''; 2857 } 2858 } 2859 var btn = form.querySelector('[data-guest-figma-connect]'); 2860 if (btn) { 2861 figmaConnectBtn = btn; 2862 btn.hidden = !showFigmaConnect; 2863 btn.disabled = false; 2864 btn.textContent = connectLabel; 2865 } 2866 } 2867 2868 /** 2869 * Visible acknowledgement that a Start building click landed, held for as 2870 * long as the session probe runs. Returns its own undo, so the caller 2871 * cannot leave the button stuck disabled on any branch. 2872 */ 2873 var PROBE_STATUS_TEXT = 'Checking your accountâ¦'; 2874 2875 function startProbeFeedback() { 2876 if (submit) { 2877 submit.disabled = true; 2878 submit.classList.add('is-building'); 2879 var spinner = submit.querySelector('.fgs-prompt__spinner'); 2880 if (spinner) spinner.hidden = false; 2881 } 2882 if (statusRow) statusRow.hidden = false; 2883 if (statusEl) { 2884 statusEl.hidden = false; 2885 statusEl.classList.remove('is-error'); 2886 statusEl.style.color = ''; 2887 statusEl.textContent = PROBE_STATUS_TEXT; 2888 } 2889 var done = false; 2890 return function endProbeFeedback() { 2891 if (done) return; 2892 done = true; 2893 if (submit) { 2894 submit.disabled = false; 2895 submit.classList.remove('is-building'); 2896 var sp = submit.querySelector('.fgs-prompt__spinner'); 2897 if (sp) sp.hidden = true; 2898 } 2899 // Only clear the line we wrote â the next step may already have 2900 // replaced it with real build status or a real error. 2901 if (statusEl && statusEl.textContent === PROBE_STATUS_TEXT) { 2902 statusEl.hidden = true; 2903 statusEl.textContent = ''; 2904 if (statusRow) statusRow.hidden = true; 2905 } 2906 }; 2907 } 2908 2909 function showReadyStatus(message) { 2910 submitting = false; 2911 clearInterval(statusTimer); 2912 if (statusEl) statusEl.removeAttribute('data-figma-phase'); 2913 hideFigmaProgressPanel(); 2914 if (submit) { 2915 submit.disabled = false; 2916 submit.classList.remove('is-building'); 2917 var spinner = submit.querySelector('.fgs-prompt__spinner'); 2918 if (spinner) spinner.hidden = true; 2919 } 2920 if (statusRow) statusRow.hidden = !message; 2921 if (statusEl) { 2922 if (message) { 2923 statusEl.hidden = false; 2924 statusEl.textContent = message; 2925 statusEl.classList.remove('is-error'); 2926 statusEl.style.color = ''; 2927 } else { 2928 statusEl.hidden = true; 2929 statusEl.textContent = ''; 2930 statusEl.classList.remove('is-error'); 2931 statusEl.style.color = ''; 2932 } 2933 } 2934 var connectBtn = form.querySelector('[data-guest-figma-connect]'); 2935 if (connectBtn) connectBtn.hidden = true; 2936 } 2937 2938 function readApiErrorMessage(body, fallback) { 2939 var err = body && body.error; 2940 if (!err) return fallback; 2941 if (typeof err === 'string' && err.trim()) return err; 2942 if (typeof err.message === 'string' && err.message.trim()) return err.message; 2943 return fallback; 2944 } 2945 2946 function connectFigmaFromLanding(clickedBtn) { 2947 var btn = clickedBtn || form.querySelector('[data-guest-figma-connect]') || figmaConnectBtn; 2948 if (!btn) return; 2949 figmaConnectBtn = btn;
2950 if (btn.getAttribute('data-figma-action') === 'integrations') { 2951 window.location.assign('/integrations'); 2952 return; 2953 } 2954 btn.disabled = true; 2955 btn.textContent = 'Connectingâ¦'; 2956 if (statusRow) statusRow.hidden = false; 2957 if (statusEl) { 2958 statusEl.hidden = false; 2959 statusEl.classList.remove('is-error'); 2960 statusEl.style.color = ''; 2961 statusEl.textContent = 'Checking Figma connectionâ¦'; 2962 } 2963 2964 function fail(message, connectLabel) { 2965 resetBuildingUI(message, { 2966 figmaConnect: true, 2967 connectLabel: connectLabel || 'Connect Figma', 2968 }); 2969 } 2970 2971 ensureSession() 2972 .then(function () { 2973 return fetch('/api/connectors/figma', { 2974 credentials: 'include', 2975 cache: 'no-store', 2976 }).then(function (r) { 2977 if (r.status === 401) throw new Error('AUTH_REQUIRED'); 2978 if (!r.ok) return null; 2979 return r.json(); 2980 }); 2981 }) 2982 .then(function (detail) { 2983 var connector = detail && detail.connector ? detail.connector : detail;
2984 var auth = connector && connector.auth ? connector.auth : null; 2985 if (connector && connector.status === 'connected') { 2986 if (statusEl) { 2987 statusEl.classList.remove('is-error'); 2988 statusEl.style.color = ''; 2989 statusEl.textContent = 'Figma connected â click Start building.'; 2990 } 2991 btn.hidden = true; 2992 btn.disabled = false; 2993 btn.textContent = 'Connect Figma'; 2994 return null; 2995 } 2996 // auth.configured=false only means the Figma toolkit auth-config has 2997 // not been prepared yet â prepare() below creates it when the 2998 // Composio API key is present. Only bail to Integrations when 2999 // Composio itself is off. 3000 if (auth && auth.configured === false) { 3001 return fetch('/api/connectors/composio/config', { 3002 credentials: 'include', 3003 cache: 'no-store', 3004 }).then(function (r) { 3005 return r.ok ? r.json() : { configured: false }; 3006 }).then(function (cfg) { 3007 if (!cfg || cfg.configured !== true) { 3008 btn.setAttribute('data-figma-action', 'integrations'); 3009 fail( 3010 'Figma is not enabled on this server yet (Composio is not configured). Open Integrations for setup, then connect your Figma account before Start building.', 3011 'Open Integrations', 3012 ); 3013 return null; 3014 } 3015 if (statusEl) statusEl.textContent = 'Opening Figma authorizationâ¦'; 3016 return fetch('/api/connectors/auth-configs/prepare', { 3017 method: 'POST', 3018 credentials: 'include', 3019 headers: { 'Content-Type': 'application/json' }, 3020 body: JSON.stringify({ connectorIds: ['figma'] }), 3021 }).then(function (r) { 3022 if (r.status === 401) throw new Error('AUTH_REQUIRED'); 3023 if (!r.ok) { 3024 return r.json().then(function (body) { 3025 throw new Error(readApiErrorMessage(body, 'Could not prepare Figma connection.')); 3026 }, function () { 3027 throw new Error('Could not prepare Figma connection.'); 3028 }); 3029 } 3030 return r.json(); 3031 }); 3032 }); 3033 } 3034 if (statusEl) statusEl.textContent = 'Opening Figma authorizationâ¦'; 3035 return fetch('/api/connectors/auth-configs/prepare', { 3036 method: 'POST', 3037 credentials: 'include', 3038 headers: { 'Content-Type': 'application/json' }, 3039 body: JSON.stringify({ connectorIds: ['figma'] }), 3040 }).then(function (r) { 3041 if (r.status === 401) throw new Error('AUTH_REQUIRED'); 3042 if (!r.ok) { 3043 return r.json().then(function (body) { 3044 throw new Error(readApiErrorMessage(body, 'Could not prepare Figma connection.')); 3045 }, function () { 3046 throw new Error('Could not prepare Figma connection.'); 3047 }); 3048 } 3049 return r.json(); 3050 }); 3051 }) 3052 .then(function (prep) { 3053 if (!prep) return null; 3054 var result = prep.results ? prep.results.figma : null; 3055 if (result && result.status === 'custom_required') { 3056 throw new Error(result.message || 'Figma needs more setup in Integrations.'); 3057 } 3058 if (result && result.status === 'error') { 3059 throw new Error(result.message || 'Could not prepare Figma connection.'); 3060 } 3061 return fetch('/api/connectors/figma/connect', { 3062 method: 'POST', 3063 credentials: 'include', 3064 }).then(function (r) { 3065 if (r.status === 401) throw new Error('AUTH_REQUIRED'); 3066 if (!r.ok) { 3067 return r.json().then(function (body) { 3068 throw new Error(readApiErrorMessage(body, 'Could not start Figma connection.')); 3069 }, function () { 3070 throw new Error('Could not start Figma connection.'); 3071 }); 3072 } 3073 return r.json(); 3074 }); 3075 }) 3076 .then(function (json) { 3077 if (!json) return; 3078 if (json.auth && json.auth.kind === 'connected') { 3079 if (statusEl) { 3080 statusEl.classList.remove('is-error'); 3081 statusEl.style.color = ''; 3082 statusEl.textContent = 'Figma connected â click Start building.'; 3083 } 3084 btn.hidden = true; 3085 btn.disabled = false;
3086 btn.textContent = 'Connect Figma'; 3087 return; 3088 } 3089 var url = json.auth && json.auth.redirectUrl; 3090 if (!url) throw new Error('No Figma authorization URL returned.'); 3091 var popup = window.open(url, 'fluttergo-figma-connect', 'width=980,height=820'); 3092 if (!popup) { 3093 // Popup blocked â continue in this tab. 3094 window.location.assign(url); 3095 return; 3096 } 3097 if (statusEl) { 3098 statusEl.textContent = 'Waiting for Figma authorizationâ¦'; 3099 } 3100 btn.disabled = false; 3101 btn.textContent = 'Connect Figma'; 3102 var polls = 0; 3103 if (figmaConnectPoll) clearInterval(figmaConnectPoll); 3104 figmaConnectPoll = setInterval(function () { 3105 polls += 1; 3106 fetch('/api/connectors/status', { 3107 credentials: 'include', 3108 cache: 'no-store', 3109 }) 3110 .then(function (r) { return r.ok ? r.json() : null; }) 3111 .then(function (data) { 3112 var figma = data && data.statuses ? data.statuses.figma : null; 3113 var status = figma && typeof figma.status === 'string' ? figma.status : ''; 3114 if (status === 'connected') { 3115 clearInterval(figmaConnectPoll); 3116 figmaConnectPoll = null; 3117 if (statusEl) { 3118 statusEl.classList.remove('is-error'); 3119 statusEl.style.color = ''; 3120 statusEl.textContent = 'Figma connected â click Start building.'; 3121 } 3122 btn.hidden = true; 3123 btn.disabled = false; 3124 btn.textContent = 'Connect Figma'; 3125 } else if (polls >= 45) { 3126 clearInterval(figmaConnectPoll); 3127 figmaConnectPoll = null; 3128 fail('Figma authorization timed out. Click Connect Figma to try again.'); 3129 } 3130 }) 3131 .catch(function () {}); 3132 }, 2000); 3133 }) 3134 .catch(function (err) { 3135 if (err && err.message === 'AUTH_REQUIRED') { 3136 resetBuildingUI(); 3137 goAuthGate('Connect Figma to build from your design file'); 3138 return; 3139 } 3140 var msg = 3141 err && err.message 3142 ? err.message 3143 : 'Could not connect Figma. Try again from Integrations.'; 3144 if (isComposioNotConfiguredError(msg)) { 3145 btn.setAttribute('data-figma-action', 'integrations'); 3146 fail( 3147 'Figma is not enabled on this server yet (Composio is not configured). Ask an admin to save a Project API key under Admin â Connectors, then connect your Figma account.', 3148 'Open Integrations', 3149 ); 3150 return; 3151 } 3152 if (isAuthConfigsPermissionError(msg)) { 3153 btn.setAttribute('data-figma-action', 'integrations'); 3154 fail( 3155 msg, 3156 'Open Integrations', 3157 ); 3158 return; 3159 } 3160 fail(msg); 3161 }); 3162 } 3163 3164 // Delegated click â survives duplicate markup / late DOM and always hits 3165 // the button the user actually pressed. 3166 document.addEventListener('click', function (event) { 3167 var target = event.target; 3168 if (!target || !target.closest) return; 3169 var btn = target.closest('[data-guest-figma-connect]'); 3170 if (!btn || !form.contains(btn)) return; 3171 event.preventDefault(); 3172 event.stopPropagation(); 3173 connectFigmaFromLanding(btn); 3174 }); 3175 3176 function goWorkplace(projectId, conversationId) { 3177 var path = '/projects/' + encodeURIComponent(projectId); 3178 if (conversationId) { 3179 path += '/conversations/' + encodeURIComponent(conversationId); 3180 } 3181 window.location.assign(path); 3182 } 3183 3184 var FLUTTER_ZIP_MAX_BYTES = 220 * 1024 * 1024; 3185 var githubConnectPoll = null; 3186 3187 function setGithubError(message) { 3188 if (!githubError) return; 3189 if (message) { 3190 githubError.hidden = false; 3191 githubError.textContent = message; 3192 } else { 3193 githubError.hidden = true; 3194 githubError.textContent = ''; 3195 } 3196 } 3197 3198 function setGithubStatus(text, tone) { 3199 if (!githubStatus) return; 3200 githubStatus.textContent = text; 3201 githubStatus.classList.remove('is-ok', 'is-wait'); 3202 if (tone) githubStatus.classList.add(tone); 3203 } 3204 3205 function closeGithubModal() { 3206 if (!githubModal) return; 3207 githubModal.hidden = true; 3208 document.body.style.overflow = ''; 3209 githubVisibility = null; 3210 if (githubConnectPoll) { 3211 clearInterval(githubConnectPoll); 3212 githubConnectPoll = null; 3213 } 3214 setGithubError(''); 3215 syncGithubModalUi(); 3216 } 3217 3218 function syncGithubModalUi() { 3219 if (githubChoice) githubChoice.hidden = Boolean(githubVisibility); 3220 if (githubFields) githubFields.hidden = !githubVisibility; 3221 if (githubFoot) githubFoot.hidden = !githubVisibility; 3222 if (githubConnectRow) githubConnectRow.hidden = githubVisibility !== 'private'; 3223 if (githubBack) githubBack.hidden = !githubVisibility; 3224 if (githubLead) { 3225 githubLead.textContent = githubVisibility === 'public' 3226 ? 'Paste a public GitHub link. No GitHub login needed.' 3227 : githubVisibility === 'private' 3228 ? 'Connect GitHub to clone a private repository.' 3229 : 'Public repos need a link only. Private repos need GitHub connected.'; 3230 } 3231 } 3232 3233 function setGithubVisibility(kind) { 3234 githubVisibility = kind === 'public' || kind === 'private' ? kind : null; 3235 setGithubError(''); 3236 syncGithubModalUi(); 3237 if (githubVisibility === 'private') { 3238 setGithubStatus('Checking GitHub connectionâ¦', 'is-wait'); 3239 ensureSession() 3240 .then(function () { return refreshGithubStatus(); }) 3241 .catch(function () { 3242 githubVisibility = null; 3243 syncGithubModalUi(); 3244 closeGithubModal(); 3245 goAuthGate('Import a private GitHub repository'); 3246 }); 3247 return; 3248 } 3249 if (githubVisibility === 'public' && githubRepo) { 3250 window.setTimeout(function () { githubRepo.focus(); }, 40); 3251 } 3252 } 3253 3254 function refreshGithubStatus() { 3255 return fetch('/api/github/app/status', { credentials: 'include', cache: 'no-store' }) 3256 .then(function (r) { return r.ok ? r.json() : null; }) 3257 .then(function (st) { 3258 if (st && st.installed) { 3259 githubInstalled = true; 3260 setGithubStatus( 3261 st.accountLogin ? ('Connected as ' + st.accountLogin) : 'GitHub connected', 3262 'is-ok', 3263 ); 3264 if (githubConnectBtn) githubConnectBtn.textContent = 'Refresh'; 3265 return st; 3266 } 3267 githubInstalled = false;
3268 if (st && st.githubAccountRestricted) { 3269 setGithubStatus('GitHub account restricted', ''); 3270 setGithubError(st.message || 'This GitHub account cannot install the app.'); 3271 return st; 3272 } 3273 setGithubStatus('Connect GitHub to import a private repository', ''); 3274 if (githubConnectBtn) githubConnectBtn.textContent = 'Connect GitHub'; 3275 return st; 3276 }) 3277 .catch(function () { 3278 githubInstalled = false; 3279 setGithubStatus('Could not check GitHub status', ''); 3280 return null; 3281 }); 3282 } 3283 3284 function openGithubModal() { 3285 if (!githubModal) return; 3286 githubVisibility = null; 3287 githubInstalled = false; 3288 githubModal.hidden = false; 3289 document.body.style.overflow = 'hidden'; 3290 setGithubError(''); 3291 syncGithubModalUi(); 3292 } 3293 3294 function connectGithubFromModal() { 3295 setGithubError(''); 3296 setGithubStatus('Waiting for GitHubâ¦', 'is-wait'); 3297 if (githubConnectBtn) githubConnectBtn.disabled = true; 3298 ensureSession() 3299 .then(function () { 3300 return fetch('/api/github/app/authorize-url', { credentials: 'include', cache: 'no-store' }) 3301 .then(function (r) { return r.ok ? r.json() : null; }) 3302 .then(function (json) { 3303 var url = json && json.url; 3304 if (!url) { 3305 return fetch('/api/github/app/install-url', { credentials: 'include', cache: 'no-store' }) 3306 .then(function (r) { return r.ok ? r.json() : null; }) 3307 .then(function (install) { 3308 if (!install || !install.url) throw new Error(json && json.error ? json.error : 'GitHub connect is unavailable.'); 3309 return install.url; 3310 }); 3311 } 3312 return url; 3313 }); 3314 }) 3315 .then(function (url) { 3316 window.open(url, 'fluttergo-github-connect', 'width=980,height=820'); 3317 var polls = 0; 3318 if (githubConnectPoll) clearInterval(githubConnectPoll); 3319 githubConnectPoll = setInterval(function () { 3320 polls += 1; 3321 refreshGithubStatus().then(function (st) { 3322 if (st && st.installed) { 3323 clearInterval(githubConnectPoll); 3324 githubConnectPoll = null; 3325 if (githubConnectBtn) githubConnectBtn.disabled = false; 3326 } else if (polls >= 72) { 3327 clearInterval(githubConnectPoll); 3328 githubConnectPoll = null; 3329 if (githubConnectBtn) githubConnectBtn.disabled = false; 3330 setGithubError('GitHub connect timed out. Finish the popup, then click Connect again.'); 3331 setGithubStatus('Not connected', ''); 3332 } 3333 }); 3334 }, 2500); 3335 }) 3336 .catch(function (err) { 3337 if (githubConnectBtn) githubConnectBtn.disabled = false; 3338 setGithubStatus('Not connected', ''); 3339 setGithubError(err && err.message ? err.message : 'Could not open GitHub connect.'); 3340 }); 3341 } 3342 3343 function importGithubRepo() { 3344 var repo = githubRepo ? String(githubRepo.value || '').trim() : ''; 3345 if (!githubVisibility) { 3346 setGithubError('Choose public or private first.'); 3347 return; 3348 } 3349 if (githubVisibility === 'private' && !githubInstalled) { 3350 setGithubError('Connect GitHub to import a private repository.'); 3351 return; 3352 } 3353 if (!repo) { 3354 setGithubError('Enter a GitHub repository (owner/repo or URL).'); 3355 if (githubRepo) githubRepo.focus(); 3356 return; 3357 } 3358 var branch = githubBranch ? String(githubBranch.value || '').trim() : ''; 3359 var name = githubName ? String(githubName.value || '').trim() : ''; 3360 setGithubError(''); 3361 if (githubSubmitBtn) { 3362 githubSubmitBtn.disabled = true; 3363 githubSubmitBtn.textContent = 'Importingâ¦'; 3364 } 3365 ensureSession() 3366 .then(function () { 3367 var body = { repo: repo, requireFlutter: true }; 3368 if (branch) body.branch = branch; 3369 if (name) body.name = name; 3370 return fetch('/api/import/github', { 3371 method: 'POST', 3372 credentials: 'include', 3373 headers: { 'Content-Type': 'application/json' }, 3374 body: JSON.stringify(body), 3375 }); 3376 }) 3377 .then(function (r) { 3378 return r.json().then(function (json) { 3379 return { ok: r.ok, status: r.status, json: json }; 3380 }, function () { 3381 return { ok: r.ok, status: r.status, json: null }; 3382 }); 3383 }) 3384 .then(function (result) { 3385 if (!result.ok) { 3386 var code = result.json && result.json.error && result.json.error.code; 3387 var msg = 3388 result.json && result.json.error && typeof result.json.error.message === 'string' 3389 ? result.json.error.message 3390 : 'The GitHub repository could not be imported.'; 3391 if (code === 'GITHUB_CONNECT_REQUIRED') { 3392 setGithubError('Connect GitHub first, then import again.'); 3393 setGithubStatus('GitHub connection required', ''); 3394 } else { 3395 setGithubError(msg); 3396 } 3397 throw new Error(msg); 3398 } 3399 var projectId = 3400 result.json && result.json.project && typeof result.json.project.id === 'string' 3401 ? result.json.project.id 3402 : ''; 3403 if (!projectId) throw new Error('Import succeeded but no project id was returned.'); 3404 var conversationId = 3405 result.json && typeof result.json.conversationId === 'string' 3406 ? result.json.conversationId 3407 : ''; 3408 var entryFile = 3409 result.json && typeof result.json.entryFile === 'string' 3410 ? result.json.entryFile 3411 : ''; 3412 closeGithubModal();
3413 var path = '/projects/' + encodeURIComponent(projectId); 3414 if (conversationId) path += '/conversations/' + encodeURIComponent(conversationId); 3415 if (entryFile) path += (path.indexOf('?') === -1 ? '?' : '&') + 'file=' + encodeURIComponent(entryFile); 3416 window.location.assign(path); 3417 }) 3418 .catch(function (err) { 3419 if (githubSubmitBtn) { 3420 githubSubmitBtn.disabled = false; 3421 githubSubmitBtn.textContent = 'Import repository'; 3422 } 3423 if (err && err.message === 'AUTH_REQUIRED') { 3424 closeGithubModal(); 3425 goAuthGate('Import a Flutter app from GitHub'); 3426 return; 3427 } 3428 if (!githubError || githubError.hidden) { 3429 setGithubError(err && err.message ? err.message : 'Import failed.'); 3430 } 3431 }); 3432 } 3433 3434 function isZipOnlyStaging() { 3435 if (stagedLinks.length) return false; 3436 if (stagedFiles.length !== 1) return false; 3437 return looksLikeZip(stagedFiles[0] && stagedFiles[0].file); 3438 } 3439 3440 function stageUploadedZip(file) { 3441 if (!file) return; 3442 if (file.size > FLUTTER_ZIP_MAX_BYTES) { 3443 resetBuildingUI( 3444 'This ZIP is too large (' + 3445 (file.size / (1024 * 1024)).toFixed(1) + 3446 ' MB). Limit is 220 MB.', 3447 ); 3448 return; 3449 } 3450 if (!looksLikeZip(file)) { 3451 resetBuildingUI('Please upload a .zip file.'); 3452 return; 3453 } 3454 stageFiles([file]); 3455 showReadyStatus('Your zip is uploaded. Describe the app or click Start building.'); 3456 try { input.focus(); } catch (e) {} 3457 } 3458 3459 function postFlutterZip(file, prompt) { 3460 return new Promise(function (resolve, reject) { 3461 var xhr = new XMLHttpRequest(); 3462 var formData = new FormData(); 3463 formData.append('file', file); 3464 if (prompt) formData.append('prompt', prompt); 3465 xhr.upload.addEventListener('progress', function (event) { 3466 if (!event.lengthComputable || !statusEl) return; 3467 var pct = Math.min(100, Math.round((event.loaded / event.total) * 100)); 3468 statusEl.hidden = false; 3469 statusEl.classList.remove('is-error'); 3470 statusEl.style.color = ''; 3471 statusEl.textContent = pct >= 100 3472 ? 'Extracting your zipâ¦' 3473 : 'Uploading your zip⦠' + pct + '%'; 3474 }); 3475 xhr.addEventListener('load', function () { 3476 var payload = null; 3477 try { payload = xhr.responseText ? JSON.parse(xhr.responseText) : null; } catch (e) {} 3478 if (xhr.status === 401) { 3479 reject(new Error('AUTH_REQUIRED')); 3480 return; 3481 } 3482 if (xhr.status < 200 || xhr.status >= 300) { 3483 var msg = 3484 payload && payload.error 3485 ? (typeof payload.error === 'string' ? payload.error : payload.error.message) 3486 : 'Flutter ZIP import failed (' + xhr.status + ').'; 3487 reject(new Error(msg || 'Flutter ZIP import failed.')); 3488 return; 3489 } 3490 resolve(payload); 3491 }); 3492 xhr.addEventListener('error', function () { 3493 reject(new Error('Network error while uploading the ZIP.')); 3494 }); 3495 xhr.open('POST', '/api/import/flutter-zip'); 3496 xhr.withCredentials = true; 3497 xhr.send(formData); 3498 }); 3499 } 3500 3501 function importOrCreateFromStagedZip(brief, displayPrompt) { 3502 var file = stagedFiles[0] && stagedFiles[0].file; 3503 if (!file) { 3504 promptBackendChoice(brief, displayPrompt).then(function (choice) { 3505 if (!choice || !choice.provider) return; 3506 beginCreateProjectFromBrief(brief, displayPrompt, choice); 3507 }); 3508 return; 3509 } 3510 submitting = true; 3511 startBuildingUI(); 3512 if (statusRow) statusRow.hidden = false; 3513 if (statusEl) { 3514 statusEl.hidden = false; 3515 statusEl.classList.remove('is-error'); 3516 statusEl.style.color = ''; 3517 statusEl.textContent = 'Extracting your zipâ¦'; 3518 } 3519 postFlutterZip(file, brief) 3520 .then(function (data) { 3521 var projectId = 3522 data && data.project && typeof data.project.id === 'string' 3523 ? data.project.id 3524 : ''; 3525 if (!projectId) throw new Error('Import succeeded but no project id was returned.'); 3526 var conversationId = 3527 data && typeof data.conversationId === 'string' ? data.conversationId : ''; 3528 try { 3529 sessionStorage.removeItem('fg:guest-intent-autosend'); 3530 if (brief) {
3531 sessionStorage.setItem('od:auto-send-prompt:' + projectId, brief); 3532 } 3533 } catch (e) {} 3534 goWorkplace(projectId, conversationId); 3535 }) 3536 .catch(function (err) { 3537 submitting = false; 3538 if (err && err.message === 'AUTH_REQUIRED') { 3539 resetBuildingUI(); 3540 goAuthGate(brief); 3541 return; 3542 } 3543 var msg = err && err.message ? String(err.message) : ''; 3544 if (/not a Flutter app/i.test(msg)) { 3545 if (submit) { 3546 submit.disabled = false; 3547 submit.classList.remove('is-building'); 3548 } 3549 clearInterval(statusTimer); 3550 promptBackendChoice(brief, displayPrompt).then(function (choice) { 3551 if (!choice || !choice.provider) { 3552 showReadyStatus('Your zip is uploaded. Describe the app or click Start building.'); 3553 return; 3554 } 3555 beginCreateProjectFromBrief(brief, displayPrompt, choice); 3556 }); 3557 return; 3558 } 3559 resetBuildingUI( 3560 msg || 'The zip could not be imported.', 3561 ); 3562 }); 3563 } 3564 3565 /** True only for a real signed-up customer account (not guest / Local owner). */ 3566 function isRegisteredUser(user) { 3567 return isCustomerAccount(user); 3568 } 3569 3570 /** Registered session only â guest minting removed. */ 3571 function ensureSession() { 3572 return fetchAuthSession().then(function (session) { 3573 if (isRegisteredUser(session && session.user)) return session; 3574 throw new Error('AUTH_REQUIRED'); 3575 }); 3576 } 3577 3578 /* 3579 * Bounded on purpose. 3580 * 3581 * Every Start building click waits on this probe before it does anything 3582 * at all, and `fetch` against a stalled connection never settles â it does 3583 * not reject, it just never calls you back. An unbounded probe therefore 3584 * turned the entire homepage inert: the click was accepted, nothing was 3585 * painted, nothing navigated, no error appeared, and every further click 3586 * joined the same silence. Reproduced against production by holding 3587 * /api/auth/session open; it is pixel-identical to the reported bug. 3588 * 3589 * A probe that has not answered inside the deadline resolves as "no 3590 * session", which routes the visitor to the auth gate â somewhere â rather 3591 * than to nowhere. 3592 */ 3593 var SESSION_PROBE_TIMEOUT_MS = 8000; 3594 3595 function fetchAuthSession() { 3596 var controller = typeof AbortController !== 'undefined' ? new AbortController() : null; 3597 var init = { credentials: 'include', cache: 'no-store' }; 3598 if (controller) init.signal = controller.signal; 3599 var timer = null; 3600 var deadline = new Promise(function (resolve) { 3601 timer = setTimeout(function () { 3602 if (controller) { 3603 try { controller.abort(); } catch (e) {} 3604 } 3605 resolve(null); 3606 }, SESSION_PROBE_TIMEOUT_MS); 3607 }); 3608 var probe = fetch('/api/auth/session', init) 3609 .then(function (r) { return r.ok ? r.json() : null; }) 3610 .catch(function () { return null; }); 3611 return Promise.race([probe, deadline]).then(function (session) { 3612 clearTimeout(timer); 3613 return session; 3614 }); 3615 } 3616 3617 function stashBuildIntent(brief) { 3618 try { 3619 sessionStorage.setItem('fg:guest-intent-prompt', brief); 3620 sessionStorage.setItem('fg:guest-intent-target', guestBuildTarget()); 3621 sessionStorage.setItem('fg:guest-intent-arrived', '1'); 3622 sessionStorage.setItem('fg:guest-intent-mode', 'flutter'); 3623 sessionStorage.removeItem('fg:guest-intent-autosend'); 3624 sessionStorage.setItem( 3625 'fg:guest-intent-has-images', 3626 stagedFiles.some(function (entry) { 3627 return looksLikeImage(entry && entry.file); 3628 }) ? '1' : '0', 3629 ); 3630 sessionStorage.setItem( 3631 'fg:guest-intent-links', 3632 JSON.stringify(stagedLinks || []), 3633 ); 3634 } catch (e) {} 3635 void persistStagedAttachmentsForResume(); 3636 } 3637 3638 function openStagedAttachmentDb() { 3639 return new Promise(function (resolve, reject) { 3640 try { 3641 var req = indexedDB.open('fg-guest-staged-attachments', 1);
3642 req.onupgradeneeded = function () { 3643 var db = req.result; 3644 if (!db.objectStoreNames.contains('pending')) { 3645 db.createObjectStore('pending'); 3646 } 3647 }; 3648 req.onsuccess = function () { resolve(req.result); }; 3649 req.onerror = function () { reject(req.error || new Error('idb open failed')); }; 3650 } catch (e) { 3651 reject(e); 3652 } 3653 }); 3654 } 3655 3656 function persistStagedAttachmentsForResume() { 3657 if (!stagedFiles.length && !stagedLinks.length) { 3658 return clearPersistedStagedAttachments(); 3659 } 3660 return openStagedAttachmentDb() 3661 .then(function (db) { 3662 return Promise.all( 3663 stagedFiles.map(function (entry) { 3664 var file = entry && entry.file; 3665 if (!file) return null; 3666 return file.arrayBuffer().then(function (buf) { 3667 return { 3668 name: file.name, 3669 type: file.type || 'application/octet-stream', 3670 lastModified: file.lastModified || Date.now(), 3671 buffer: buf, 3672 }; 3673 }); 3674 }), 3675 ).then(function (serialized) { 3676 return new Promise(function (resolve, reject) { 3677 var tx = db.transaction('pending', 'readwrite'); 3678 tx.objectStore('pending').put( 3679 { 3680 files: serialized.filter(Boolean), 3681 links: stagedLinks.slice(), 3682 savedAt: Date.now(), 3683 }, 3684 'resume', 3685 ); 3686 tx.oncomplete = function () { resolve(); }; 3687 tx.onerror = function () { reject(tx.error); }; 3688 }); 3689 }); 3690 }) 3691 .catch(function () {}); 3692 } 3693 3694 function clearPersistedStagedAttachments() { 3695 return openStagedAttachmentDb() 3696 .then(function (db) { 3697 return new Promise(function (resolve) { 3698 var tx = db.transaction('pending', 'readwrite'); 3699 tx.objectStore('pending').delete('resume'); 3700 tx.oncomplete = function () { resolve(); }; 3701 tx.onerror = function () { resolve(); }; 3702 }); 3703 }) 3704 .catch(function () {}); 3705 } 3706 3707 function restorePersistedStagedAttachments() { 3708 return openStagedAttachmentDb() 3709 .then(function (db) { 3710 return new Promise(function (resolve) { 3711 var tx = db.transaction('pending', 'readonly'); 3712 var req = tx.objectStore('pending').get('resume'); 3713 req.onsuccess = function () { resolve(req.result || null); }; 3714 req.onerror = function () { resolve(null); }; 3715 }); 3716 }) 3717 .then(function (payload) { 3718 if (!payload) return false; 3719 if (Array.isArray(payload.links) && payload.links.length && stagedLinks.length === 0) { 3720 stagedLinks = payload.links.slice(); 3721 } 3722 if (Array.isArray(payload.files) && payload.files.length && stagedFiles.length === 0) { 3723 payload.files.forEach(function (item) { 3724 if (!item || !item.buffer) return; 3725 try { 3726 var file = new File([item.buffer], item.name || 'attachment', { 3727 type: item.type || 'application/octet-stream', 3728 lastModified: item.lastModified || Date.now(), 3729 }); 3730 var previewUrl = looksLikeImage(file) ? URL.createObjectURL(file) : ''; 3731 stagedFiles.push({ file: file, previewUrl: previewUrl }); 3732 } catch (e) {} 3733 }); 3734 } 3735 try { 3736 if (typeof renderAttachChips === 'function') renderAttachChips(); 3737 } catch (e) {} 3738 return stagedFiles.length > 0 || stagedLinks.length > 0; 3739 }) 3740 .catch(function () { return false; }); 3741 } 3742 3743 function takeStashedBuildPrompt() { 3744 try { 3745 var prompt = sessionStorage.getItem('fg:guest-intent-prompt'); 3746 sessionStorage.removeItem('fg:guest-intent-autosend'); 3747 return prompt && String(prompt).trim() ? String(prompt).trim() : ''; 3748 } catch (e) { 3749 return ''; 3750 } 3751 } 3752 3753 function takeStashedLinks() {
3754 try { 3755 var raw = sessionStorage.getItem('fg:guest-intent-links'); 3756 sessionStorage.removeItem('fg:guest-intent-links'); 3757 if (!raw) return []; 3758 var parsed = JSON.parse(raw); 3759 return Array.isArray(parsed) ? parsed : []; 3760 } catch (e) { 3761 return []; 3762 } 3763 } 3764 3765 function authGateResumeUrl(brief) { 3766 // File uploads still resume in the landing importer, which owns their 3767 // IndexedDB payload and upload flow. This handoff carries text prompts. 3768 if (stagedFiles.length) return Promise.resolve('/?resumeBuild=1'); 3769 if (!brief) return Promise.resolve('/home'); 3770 // The opaque id travels in `next` through password/provider auth and 3771 // the verification email, including when it opens in a different tab. 3772 // sessionStorage is the fallback if saving the intent is unavailable. 3773 var controller = typeof AbortController !== 'undefined' ? new AbortController() : null; 3774 var timer; 3775 var deadline = new Promise(function (resolve) { 3776 timer = setTimeout(function () { 3777 if (controller) controller.abort(); 3778 resolve('/home'); 3779 }, 4000); 3780 }); 3781 var request = fetch('/api/guest-intents', { 3782 method: 'POST', 3783 credentials: 'same-origin', 3784 headers: { 'Content-Type': 'application/json' }, 3785 signal: controller ? controller.signal : undefined, 3786 body: JSON.stringify({ prompt: brief, source: 'landing', template: guestIntentTemplate() }), 3787 }).then(function (response) { 3788 return response.ok ? response.json() : null; 3789 }).then(function (result) { 3790 var id = result && result.intentId; 3791 return typeof id === 'string' && /^[\w-]{1,128}$/.test(id) 3792 ? '/home?intent=' + encodeURIComponent(id) 3793 : '/home'; 3794 }).catch(function () { return '/home'; }); 3795 return Promise.race([request, deadline]).finally(function () { clearTimeout(timer); }); 3796 } 3797 3798 /* 3799 * The home â signup loop breaker is a timestamp, not a sticky flag. 3800 * 3801 * It used to be the literal '1', cleared only by a later home load that 3802 * happened to take the "not resuming" branch of resumeBuildAfterAuth(). A 3803 * visitor who opened signup, came back without finishing and stayed on the 3804 * page left it set â and from then on every Start building click fell into 3805 * the loop breaker below and did nothing but write one line of red text. 3806 * Permanent state is the wrong shape for "we just bounced": a short window 3807 * is long enough to catch the bounce and cannot outlive it. 3808 */ 3809 var AUTH_GATE_TTL_MS = 60000; 3810 3811 function authGateIsActive() { 3812 try { 3813 var raw = sessionStorage.getItem('fg:auth-gate-active'); 3814 if (!raw) return false; 3815 var at = Number(raw); 3816 // '1' is the legacy sticky value. Treating it as expired is what heals 3817 // a browser that is already stuck, on its very next click. 3818 if (!isFinite(at) || at <= 1 || Date.now() - at > AUTH_GATE_TTL_MS) { 3819 clearAuthGate(); 3820 return false; 3821 } 3822 return true; 3823 } catch (e) { 3824 return false; 3825 } 3826 } 3827 3828 /** 3829 * Logged-out Start building â signup page (new visitors convert there; 3830 * sign-in is one click away). Restore the prompt for review in Home. 3831 * Guarded so home â signup cannot blink in a redirect loop. 3832 */ 3833 function goAuthGate(brief) { 3834 // Never redirect away from the auth pages themselves. 3835 try { 3836 if (location.pathname.indexOf('/onboarding/') === 0) return; 3837 } catch (e) {} 3838 try { 3839 if (authGateIsActive()) { 3840 // Stay put after one bounce â but tell the visitor why Start 3841 // building did not continue. 3842 resetBuildingUI('Sign in to continue building, then we will open your project.'); 3843 return; 3844 } 3845 sessionStorage.setItem('fg:auth-gate-active', String(Date.now())); 3846 } catch (e) {} 3847 if (brief) stashBuildIntent(brief); 3848 submitting = true; 3849 if (submit) submit.disabled = true; 3850 trackLandingFunnel('signup_page_opened', 'hero_prompt', 'get-started'); 3851 authGateResumeUrl(brief).then(function (next) { 3852 window.location.assign('/onboarding/get-started?next=' + encodeURIComponent(next)); 3853 }); 3854 } 3855 3856 function clearAuthGate() {
3857 try { 3858 sessionStorage.removeItem('fg:auth-gate-active'); 3859 } catch (e) {} 3860 } 3861 3862 if (zipInput) { 3863 zipInput.addEventListener('change', function () { 3864 var file = zipInput.files && zipInput.files[0]; 3865 zipInput.value = ''; 3866 if (file) stageUploadedZip(file); 3867 }); 3868 } 3869 3870 if (githubModal) { 3871 Array.prototype.forEach.call(document.querySelectorAll('[data-guest-github-dismiss]'), function (btn) { 3872 btn.addEventListener('click', function (event) { 3873 event.preventDefault(); 3874 closeGithubModal(); 3875 }); 3876 }); 3877 if (githubConnectBtn) { 3878 githubConnectBtn.addEventListener('click', function (event) { 3879 event.preventDefault(); 3880 connectGithubFromModal(); 3881 }); 3882 } 3883 if (githubSubmitBtn) { 3884 githubSubmitBtn.addEventListener('click', function (event) { 3885 event.preventDefault(); 3886 importGithubRepo(); 3887 }); 3888 } 3889 Array.prototype.forEach.call(document.querySelectorAll('[data-guest-github-visibility]'), function (btn) { 3890 btn.addEventListener('click', function (event) { 3891 event.preventDefault(); 3892 setGithubVisibility(btn.getAttribute('data-guest-github-visibility')); 3893 }); 3894 }); 3895 if (githubBack) { 3896 githubBack.addEventListener('click', function (event) { 3897 event.preventDefault(); 3898 githubVisibility = null; 3899 setGithubError(''); 3900 syncGithubModalUi(); 3901 }); 3902 } 3903 if (githubRepo) { 3904 githubRepo.addEventListener('keydown', function (event) { 3905 if (event.key === 'Enter') { 3906 event.preventDefault(); 3907 importGithubRepo(); 3908 } 3909 }); 3910 } 3911 document.addEventListener('keydown', function (event) { 3912 if (event.key === 'Escape' && githubModal && !githubModal.hidden) { 3913 closeGithubModal(); 3914 } 3915 }); 3916 } 3917 3918 function createProjectFromBrief(brief, displayPrompt) { 3919 if (submitting) return; 3920 if (isZipOnlyStaging()) { 3921 importOrCreateFromStagedZip(brief, displayPrompt); 3922 return; 3923 } 3924 promptBackendChoice(brief, displayPrompt).then(function (choice) { 3925 if (!choice || !choice.provider) return; 3926 beginCreateProjectFromBrief(brief, displayPrompt, choice); 3927 }); 3928 } 3929 3930 /** 3931 * Full-page backend setup before landing create. 3932 * Auto-creates a Supabase project from the app brief when linked. 3933 */ 3934 function promptBackendChoice(brief, displayPrompt) { 3935 return new Promise(function (resolve) { 3936 var existing = document.getElementById('fg-backend-picker'); 3937 if (existing) existing.remove(); 3938 var existingStyle = document.getElementById('fg-backend-picker-style'); 3939 if (existingStyle) existingStyle.remove(); 3940 3941 if (!document.getElementById('fg-backend-picker-style')) { 3942 var style = document.createElement('style'); 3943 style.id = 'fg-backend-picker-style'; 3944 style.textContent = [ 3945 '#fg-backend-picker{position:fixed;inset:0;z-index:260;display:flex;align-items:flex-start;justify-content:center;', 3946 'padding:clamp(40px,8vh,72px) 20px 32px;overflow:auto;color:#e9eef9;', 3947 'background:radial-gradient(60% 40% at 50% 0%,rgba(80,198,253,.08),transparent 55%),rgba(4,6,12,.72);', 3948 'backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px)}', 3949 '#fg-backend-picker *{box-sizing:border-box}', 3950 '#fg-backend-picker [hidden]{display:none!important}', 3951 '.fg-bp-card{position:relative;width:100%;max-width:520px;margin:0 auto;border-radius:20px;overflow:hidden;', 3952 'background:radial-gradient(90% 55% at 12% -10%,rgba(91,200,255,.07),transparent 50%),', 3953 'radial-gradient(70% 45% at 100% 0%,rgba(80,198,253,.06),transparent 45%),#0a101b;', 3954 'border:1px solid rgba(148,170,220,.14);box-shadow:0 1px 0 rgba(233,238,249,.04) inset,0 28px 64px rgba(0,0,0,.55);', 3955 'display:flex;flex-direction:column;min-height:0}', 3956 '.fg-bp-head{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;padding:22px 24px 0}', 3957 '.fg-bp-kicker{margin:0 0 8px;font:500 10.5px/1.2 "JetBrains Mono",ui-monospace,Menlo,monospace;', 3958 'letter-spacing:.22em;text-transform:uppercase;
3958color:#77839b}', 3959 '.fg-bp-head h1{margin:0;max-width:16ch;font:italic 400 clamp(26px,3.4vw,32px)/1.15 Fraunces,Georgia,"Times New Roman",serif;', 3960 'letter-spacing:-.02em;color:#e9eef9}', 3961 '.fg-bp-close{appearance:none;width:34px;height:34px;margin-top:2px;border-radius:999px;', 3962 'border:1px solid rgba(148,170,220,.16);background:transparent;color:#94a0b6;cursor:pointer;font:inherit;font-size:18px;line-height:1}', 3963 '.fg-bp-close:hover{background:rgba(148,170,220,.06);border-color:rgba(148,170,220,.28);color:#e9eef9}', 3964 '.fg-bp-body{padding:14px 24px 8px;display:flex;flex-direction:column;gap:18px}', 3965 '.fg-bp-lead{margin:0;max-width:42ch;font:400 14.5px/1.55 Inter,system-ui,sans-serif;color:#94a0b6}', 3966 '.fg-bp-options{display:flex;flex-direction:column;gap:0;border-top:1px solid rgba(148,170,220,.1);border-bottom:1px solid rgba(148,170,220,.1)}', 3967 '.fg-bp-option{position:relative;border:0;background:transparent;overflow:hidden;', 3968 'transition:background 180ms cubic-bezier(.23,1,.32,1)}', 3969 '.fg-bp-option+.fg-bp-option{border-top:1px solid rgba(148,170,220,.1)}', 3970 '.fg-bp-option[data-active="true"]{background:linear-gradient(90deg,rgba(80,198,253,.08),rgba(80,198,253,.02) 42%,transparent)}', 3971 '.fg-bp-option[data-active="true"]::before{content:"";position:absolute;left:0;top:10px;bottom:10px;width:2px;border-radius:2px;background:#50C6FD}', 3972 '.fg-bp-option-head{display:flex;width:100%;align-items:flex-start;gap:14px;padding:16px 4px 16px 14px;text-align:left;', 3973 'background:transparent;border:0;color:inherit;cursor:pointer;font:inherit}', 3974 '.fg-bp-radio{flex:0 0 auto;width:18px;height:18px;margin-top:3px;border-radius:999px;border:1.5px solid rgba(148,170,220,.35);', 3975 'display:grid;place-items:center}', 3976 '.fg-bp-radio i{width:8px;height:8px;border-radius:999px;background:transparent;opacity:0;transform:scale(.6);', 3977 'transition:opacity 140ms cubic-bezier(.23,1,.32,1),transform 180ms cubic-bezier(.23,1,.32,1),background 140ms}', 3978 '.fg-bp-option[data-active="true"] .fg-bp-radio{border-color:#50C6FD;box-shadow:0 0 0 3px rgba(80,198,253,.12)}', 3979 '.fg-bp-option[data-active="true"] .fg-bp-radio i{background:#50C6FD;opacity:1;transform:scale(1)}', 3980 '.fg-bp-logo{flex:0 0 auto;width:34px;height:34px;border-radius:11px;display:grid;place-items:center;', 3981 'background:rgba(148,170,220,.06);border:1px solid rgba(148,170,220,.1);overflow:hidden}', 3982 '.fg-bp-logo img{width:18px;height:18px;display:block}', 3983 '.fg-bp-logo--skip{font:italic 400 16px/1 Fraunces,Georgia,serif;color:#94a0b6}', 3984 '.fg-bp-copy{min-width:0;flex:1 1 auto;padding-right:8px}', 3985 '.fg-bp-copy strong{display:block;font:600 15px/1.3 Inter,system-ui,sans-serif;letter-spacing:-.01em;color:#e9eef9}', 3986 '.fg-bp-copy em{display:block;margin-top:3px;font:400 13px/1.45 Inter,system-ui,sans-serif;font-style:normal;color:#77839b}', 3987 '.fg-bp-badge{margin-left:auto;flex:0 0 auto;margin-top:2px;font:500 10.5px/1 "JetBrains Mono",ui-monospace,Menlo,monospace;', 3988 'letter-spacing:.04em;color:#77839b;white-space:nowrap;padding:0;border:0;background:transparent;border-radius:0}', 3989 '.fg-bp-badge[data-on="true"]{color:#34d399;background:transparent;border:0}', 3990 '.fg-bp-detail{display:none;padding:0 4px 18px 46px}', 3991 '.fg-bp-option[data-active="true"] .fg-bp-detail{display:block}', 3992 '.fg-bp-detail p{margin:0 0 10px;font:400 13px/1.5 Inter,system-ui,sans-serif;color:#94a0b6}', 3993 '.fg-bp-row{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:8px}', 3994 '.fg-bp-select,.fg-bp-input{flex:1 1 180px;min-width:0;height:38px;border-radius:10px;border:1px solid rgba(148,170,220,.18);', 3995 'background:#070b14;color:#e9eef9;padding:0 12px;font:inherit;font-size:13px}',
3996 '.fg-bp-field{display:flex;flex-direction:column;gap:4px;flex:1 1 100%;margin-bottom:8px}', 3997 '.fg-bp-field label{font:600 12px/1.3 Inter,system-ui,sans-serif;color:#c2cde1}', 3998 '.fg-bp-field span{font:400 11.5px/1.4 Inter,system-ui,sans-serif;color:#77839b}', 3999 '.fg-bp-btn{appearance:none;border-radius:10px;padding:8px 14px;font:600 13px/1.2 Inter,system-ui,sans-serif;', 4000 'cursor:pointer;border:1px solid transparent;height:38px}', 4001 '.fg-bp-btn:disabled{opacity:.45;cursor:not-allowed}', 4002 '.fg-bp-btn--ghost{background:transparent;color:#c2cde1;border-color:rgba(148,170,220,.18)}', 4003 '.fg-bp-btn--ghost:hover{background:rgba(148,170,220,.06)}', 4004 '.fg-bp-btn--soft{background:rgba(148,170,220,.08);color:#e9eef9;border-color:rgba(148,170,220,.14)}', 4005 '.fg-bp-btn--soft:hover{background:rgba(148,170,220,.12)}', 4006 '.fg-bp-btn--primary{background:#50C6FD;color:#0A1424;border-color:#50C6FD}', 4007 '.fg-bp-btn--primary:hover{filter:brightness(1.05)}', 4008 '.fg-bp-status{margin:0;font:400 12.5px/1.45 Inter,system-ui,sans-serif;color:#94a0b6;min-height:1.2em}', 4009 '.fg-bp-status[data-ok="true"]{color:#94a0b6}', 4010 '.fg-bp-status[data-err="true"]{color:#fca5a5}', 4011 '.fg-bp-foot{display:flex;justify-content:space-between;align-items:center;gap:16px;padding:16px 24px 20px;', 4012 'border-top:1px solid rgba(148,170,220,.1);background:linear-gradient(180deg,transparent,rgba(4,6,12,.35))}', 4013 '.fg-bp-foot .fg-bp-status{flex:1 1 auto;min-width:0;color:#77839b}', 4014 '.fg-bp-foot-actions{display:flex;gap:10px;flex:0 0 auto}', 4015 '@media (max-width:560px){.fg-bp-foot{flex-direction:column;align-items:stretch}.fg-bp-foot-actions{justify-content:flex-end}', 4016 '.fg-bp-detail{padding-left:14px}}', 4017 ].join(''); 4018 4019 document.head.appendChild(style); 4020 } 4021 4022 var page = document.createElement('div'); 4023 page.id = 'fg-backend-picker'; 4024 page.setAttribute('data-testid', 'landing-backend-picker'); 4025 page.setAttribute('role', 'dialog'); 4026 page.setAttribute('aria-modal', 'true'); 4027 page.setAttribute('aria-labelledby', 'fg-bp-title'); 4028 page.innerHTML = 4029 '<div class="fg-bp-card">' + 4030 '<div class="fg-bp-head">' + 4031 '<div>' + 4032 '<p class="fg-bp-kicker">Before the workspace</p>' + 4033 '<h1 id="fg-bp-title">Where should data live?</h1>' + 4034 '</div>' + 4035 '<button type="button" class="fg-bp-close" data-bp-cancel aria-label="Cancel">Ã</button>' + 4036 '</div>' + 4037 '<div class="fg-bp-body">' + 4038 '<p class="fg-bp-lead">Supabase if you want auth and a database on day one â or skip and wire it later.</p>' + 4039 '<div class="fg-bp-options" role="radiogroup" aria-label="Where should data live?">' + 4040 '<div class="fg-bp-option" data-provider="supabase" data-active="false">' + 4041 '<button type="button" class="fg-bp-option-head" data-select="supabase" role="radio" aria-checked="false">' + 4042 '<span class="fg-bp-radio" aria-hidden="true"><i></i></span>' + 4043 '<span class="fg-bp-logo" aria-hidden="true"><img src="/brand/tech/supabase.svg" alt=""></span>' + 4044 '<span class="fg-bp-copy"><strong>Supabase</strong><em>Auth, Postgres, Storage, and Realtime for this app</em></span>' + 4045 '<span class="fg-bp-badge" data-badge="supabase">Checkingâ¦</span>' + 4046 '</button>' + 4047 '<div class="fg-bp-detail" data-body="supabase">' + 4048 '<p data-sb-copy>Connect your Supabase account â we will create a project for this app once you are linked.</p>' + 4049 '<div class="fg-bp-row" data-sb-connect-row>' + 4050 '<button type="button" class="fg-bp-btn fg-bp-btn--soft" data-sb-connect>Connect Supabase</button>' + 4051 '</div>' + 4052 '<div data-sb-manual hidden>' + 4053 '<p>OAuth is not set on this server. Paste project credentials from Supabase â Project Settings â API.</p>' + 4054 '<div class="fg-bp-field"><label for="fg-bp-sb-url">Project URL</label>' + 4055 '<span>https://xxxxx.supabase.co</span>' + 4056 '<input id="fg-bp-sb-url" class="fg-bp-input" data-sb-url type="url" placeholder="https://xxxxx.supabase.co" autocomplete="off"></div>' + 4057 '<div class="fg-bp-field"><label for="fg-bp-sb-anon">Publishable key</label>' + 4058 '<span>anon / publishable key (not the service-role key)</span>' + 4059 '<input id="fg-bp-sb-anon" class="fg-bp-input" data-sb-anon type="password" placeholder="eyJâ¦" autocomplete="off"></div>' + 4060 '<div class="fg-bp-row"><button type="button" class="fg-bp-btn fg-bp-btn--soft" data-sb-manual-use>Use these credentials</button></div>' + 4061 '</div>' + 4062 '<div class="fg-bp-row" data-sb-projects hidden>' + 4063 '<select class="fg-bp-select" data-sb-select aria-label="Supabase project"></select>' + 4064 '<button type="button" class="fg-bp-btn fg-bp-btn--soft" data-sb-use>Use this project</button>' + 4065 '</div>' + 4066 '<p class="fg-bp-status" data-sb-status></p>' + 4067 '</div>' + 4068 '</div>' + 4069 '<div class="fg-bp-option" data-provider="none" data-active="false">' + 4070 '<button type="button" class="fg-bp-option-head" data-select="none" role="radio" aria-checked="false">' + 4071 '<span class="fg-bp-radio" aria-hidden="true"><i></i></span>' + 4072 '<span class="fg-bp-logo fg-bp-logo--skip" aria-hidden="true">â</span>' + 4073 '<span class="fg-bp-copy"><strong>Skip for now</strong><em>Design the screens first. Link a backend when you need it.</em></span>' + 4074 '<span class="fg-bp-badge">Later</span>' + 4075 '</button>' + 4076 '</div>' + 4077 '</div>' + 4078 '</div>' + 4079 '<div class="fg-bp-foot">' + 4080 '<p class="fg-bp-status" data-bp-foot-status>Pick Supabase, or skip for now.</p>' + 4081 '<div class="fg-bp-foot-actions">' + 4082 '<button type="button" class="fg-bp-btn fg-bp-btn--ghost" data-bp-cancel>Cancel</button>' + 4083 '<button type="button" class="fg-bp-btn fg-bp-btn--primary" data-bp-continue disabled>Open workspace</button>' + 4084 '</div>' + 4085 '</div>' + 4086 '</div>'; 4087 4088 document.body.appendChild(page); 4089 document.body.style.overflow = 'hidden'; 4090 4091 var selected = null; 4092 var supabaseConnected = false;
4093 var supabaseOauthConfigured = false; 4094 var supabaseCredentials = null; 4095 var supabaseProvisioning = false; 4096 var supabaseProvisionFailed = false; 4097 var appProjectName = projectNameFromPrompt(displayPrompt || brief || '', { 4098 hasImages: stagedFiles.length > 0, 4099 }); 4100 var continueBtn = page.querySelector('[data-bp-continue]'); 4101 var footStatus = page.querySelector('[data-bp-foot-status]'); 4102 var sbBadge = page.querySelector('[data-badge="supabase"]'); 4103 var sbStatus = page.querySelector('[data-sb-status]'); 4104 var sbProjects = page.querySelector('[data-sb-projects]'); 4105 var sbSelect = page.querySelector('[data-sb-select]'); 4106 var sbConnectRow = page.querySelector('[data-sb-connect-row]'); 4107 var sbManual = page.querySelector('[data-sb-manual]'); 4108 var sbUrl = page.querySelector('[data-sb-url]'); 4109 var sbAnon = page.querySelector('[data-sb-anon]'); 4110 var closed = false; 4111 var pollTimer = null; 4112 4113 function close(result) { 4114 if (closed) return; 4115 closed = true; 4116 if (pollTimer) clearInterval(pollTimer); 4117 page.remove(); 4118 document.body.style.overflow = ''; 4119 resolve(result); 4120 } 4121 4122 function setBadge(el, on, label) { 4123 el.setAttribute('data-on', on ? 'true' : 'false'); 4124 el.textContent = label; 4125 } 4126 4127 function syncContinue() { 4128 var ready = false; 4129 var msg = 'Pick Supabase, or skip for now.'; 4130 if (selected === 'none') { 4131 ready = true; 4132 msg = 'No backend yet â you will open the workspace and build the UI first.'; 4133 } else if (selected === 'supabase') { 4134 if (supabaseCredentials) { 4135 ready = true; 4136 msg = 'Credentials ready â they will be saved when the app is created.'; 4137 } else if (supabaseProvisioning) { 4138 msg = 'Creating a Supabase project for this appâ¦'; 4139 } else if (supabaseConnected && supabaseOauthConfigured) { 4140 msg = 'Creating a Supabase project for this appâ¦'; 4141 } else if (!supabaseOauthConfigured) { 4142 msg = 'Paste your project URL and publishable key, then continue.'; 4143 } else { 4144 msg = 'Connect Supabase here, then continue.'; 4145 } 4146 } 4147 4148 continueBtn.disabled = !ready; 4149 footStatus.textContent = msg; 4150 footStatus.removeAttribute('data-err'); 4151 footStatus.setAttribute('data-ok', ready ? 'true' : 'false'); 4152 } 4153 4154 function setSelected(provider) { 4155 selected = provider; 4156 page.querySelectorAll('.fg-bp-option').forEach(function (panel) { 4157 var on = panel.getAttribute('data-provider') === provider; 4158 panel.setAttribute('data-active', on ? 'true' : 'false'); 4159 var head = panel.querySelector('[data-select]'); 4160 if (head) head.setAttribute('aria-checked', on ? 'true' : 'false'); 4161 }); 4162 if (provider === 'supabase' && supabaseConnected && supabaseOauthConfigured && !supabaseCredentials) { 4163 autoProvisionSupabaseProject(); 4164 } 4165 syncContinue(); 4166 } 4167 4168 function loadSupabaseStatus() { 4169 return fetch('/api/supabase/oauth/status', { credentials: 'include', cache: 'no-store' }) 4170 .then(function (r) { return r.ok ? r.json() : null; }) 4171 .then(function (status) { 4172 if (!status) { 4173 setBadge(sbBadge, false, 'Unavailable'); 4174 sbStatus.textContent = 'Could not read Supabase status.'; 4175 sbStatus.setAttribute('data-err', 'true'); 4176 return; 4177 } 4178 supabaseOauthConfigured = Boolean(status.configured); 4179 if (!status.configured) { 4180 setBadge(sbBadge, false, 'Paste keys'); 4181 sbStatus.textContent = 'Paste project URL + publishable key below (OAuth is not set on this server).'; 4182 sbStatus.removeAttribute('data-err'); 4183 sbConnectRow.hidden = true; 4184 sbProjects.hidden = true; 4185 sbManual.hidden = false;
4186 syncContinue(); 4187 return; 4188 } 4189 sbManual.hidden = true; 4190 supabaseConnected = Boolean(status.connected); 4191 setBadge(sbBadge, supabaseCredentials ? true : supabaseConnected, supabaseCredentials ? 'Ready' : (supabaseConnected ? (supabaseProvisioning ? 'Creatingâ¦' : 'Linked') : 'Not linked')); 4192 sbConnectRow.hidden = supabaseConnected; 4193 if (supabaseConnected) { 4194 sbStatus.textContent = 'Account connected.'; 4195 sbStatus.setAttribute('data-ok', 'true'); 4196 if (selected === 'supabase' && !supabaseCredentials) { 4197 return autoProvisionSupabaseProject(); 4198 } 4199 return; 4200 } 4201 sbProjects.hidden = true; 4202 sbStatus.textContent = 'Not linked yet â connect to continue with Supabase.'; 4203 syncContinue(); 4204 }) 4205 .catch(function () { 4206 setBadge(sbBadge, false, 'Error'); 4207 sbStatus.textContent = 'Could not read Supabase status.'; 4208 sbStatus.setAttribute('data-err', 'true'); 4209 }); 4210 } 4211 4212 function autoProvisionSupabaseProject() { 4213 if (supabaseProvisioning || supabaseCredentials || !appProjectName) return; 4214 supabaseProvisioning = true; 4215 supabaseProvisionFailed = false; 4216 sbProjects.hidden = true; 4217 sbStatus.textContent = 'Creating a Supabase project for this appâ¦'; 4218 sbStatus.removeAttribute('data-err'); 4219 setBadge(sbBadge, true, 'Creatingâ¦'); 4220 syncContinue(); 4221 return fetch('/api/supabase/projects', { 4222 method: 'POST', 4223 credentials: 'include', 4224 cache: 'no-store', 4225 headers: { 'content-type': 'application/json' }, 4226 body: JSON.stringify({ name: appProjectName }), 4227 }) 4228 .then(function (r) { 4229 return r.json().then(function (body) { 4230 if (!r.ok) { 4231 throw new Error((body && body.error && body.error.message) || 'Could not create Supabase project'); 4232 } 4233 return body; 4234 }); 4235 }) 4236 .then(function (created) { 4237 if (created && created.credentials && created.credentials.url && created.credentials.anonKey) { 4238 supabaseCredentials = { 4239 url: created.credentials.url, 4240 anonKey: created.credentials.anonKey, 4241 }; 4242 sbStatus.textContent = 'Supabase project ready.'; 4243 sbStatus.setAttribute('data-ok', 'true'); 4244 setBadge(sbBadge, true, 'Ready'); 4245 syncContinue(); 4246 return; 4247 } 4248 throw new Error('Project is still starting â pick an existing project below.'); 4249 }) 4250 .catch(function (err) { 4251 supabaseProvisionFailed = true; 4252 supabaseCredentials = null; 4253 sbStatus.textContent = err && err.message ? err.message : 'Could not create Supabase project'; 4254 sbStatus.setAttribute('data-err', 'true'); 4255 setBadge(sbBadge, true, 'Linked'); 4256 return loadSupabaseProjects(); 4257 }) 4258 .finally(function () { 4259 supabaseProvisioning = false; 4260 syncContinue(); 4261 }); 4262 } 4263 4264 function loadSupabaseProjects() { 4265 sbProjects.hidden = false; 4266 sbSelect.innerHTML = '<option value=\"\">Loading projectsâ¦</option>'; 4267 return fetch('/api/supabase/projects', { credentials: 'include', cache: 'no-store' }) 4268 .then(function (r) { 4269 if (!r.ok) throw new Error('Could not list Supabase projects'); 4270 return r.json(); 4271 }) 4272 .then(function (body) { 4273 var projects = body && Array.isArray(body.projects) ? body.projects : []; 4274 if (!projects.length) { 4275 sbSelect.innerHTML = '<option value=\"\">No projects found</option>'; 4276 sbStatus.textContent = 'Connected, but no projects yet â create one in Supabase to continue.'; 4277 syncContinue(); 4278 return; 4279 } 4280 sbSelect.innerHTML = projects.map(function (p) { 4281 var label = (p.name || p.ref || 'Project') + (p.ref ? ' · ' + p.ref : ''); 4282 return '<option value=\"' + String(p.ref).replace(/\"/g, '') + '\">' + label.replace(/</g, '<') + '</option>'; 4283 }).join(''); 4284 sbStatus.textContent = supabaseProvisionFailed 4285 ? 'Automatic setup failed â pick an existing project to continue.' 4286 : 'Pick a project to prefill secrets.'; 4287 syncContinue(); 4288 }) 4289 .catch(function (err) { 4290 sbSelect.innerHTML = '<option value=\"\">Could not load</option>'; 4291 sbStatus.textContent = err && err.message ? err.message : 'Could not list projects'; 4292 sbStatus.setAttribute('data-err', 'true'); 4293 syncContinue(); 4294 }); 4295 } 4296 4297 4298 function startSupabaseOAuth() { 4299 sbStatus.textContent = 'Opening Supabaseâ¦'; 4300 sbStatus.removeAttribute('data-err'); 4301 fetch('/api/supabase/oauth/start', { 4302 method: 'POST', 4303 credentials: 'include', 4304 headers: { 'content-type': 'application/json' }, 4305 body: '{}', 4306 }) 4307 .then(function (r) { 4308 return r.text().then(function (text) { 4309 var body = text ? JSON.parse(text) : null; 4310 if (!r.ok) { 4311 throw new Error((body && body.error && body.error.message) || ('HTTP ' + r.status)); 4312 } 4313 return body; 4314 }); 4315 }) 4316 .then(function (started) { 4317 if (!started || !started.authorizeUrl) throw new Error('No authorize URL returned'); 4318 window.open(started.authorizeUrl, '_blank', 'noopener,noreferrer'); 4319 sbStatus.textContent = 'Finish signing in with Supabase, then this page will refresh.'; 4320 }) 4321 .catch(function (err) { 4322 sbStatus.textContent = err && err.message ? err.message : 'Could not start Supabase connect'; 4323 sbStatus.setAttribute('data-err', 'true'); 4324 }); 4325 } 4326 4327 function useSupabaseProject() { 4328 var ref = sbSelect.value; 4329 if (!ref) return; 4330 sbStatus.textContent = 'Fetching credentialsâ¦'; 4331 fetch('/api/supabase/projects/' + encodeURIComponent(ref) + '/credentials', { 4332 credentials: 'include', 4333 cache: 'no-store', 4334 }) 4335 .then(function (r) { 4336 return r.json().then(function (body) { 4337 if (!r.ok) throw new Error((body && body.error && body.error.message) || 'Could not load credentials'); 4338 return body; 4339 }); 4340 }) 4341 .then(function (creds) { 4342 if (!creds || !creds.anonKey || !creds.url) { 4343 throw new Error(creds && creds.unavailable === 'project_paused' 4344 ? 'That project is paused in Supabase. Resume it, then try again.' 4345 : 'Anon key unavailable for that project.'); 4346 } 4347 supabaseCredentials = { url: creds.url, anonKey: creds.anonKey }; 4348 sbStatus.textContent = 'Project credentials ready.'; 4349 sbStatus.setAttribute('data-ok', 'true'); 4350 syncContinue(); 4351 }) 4352 .catch(function (err) { 4353 supabaseCredentials = null; 4354 sbStatus.textContent = err && err.message ? err.message : 'Could not load credentials'; 4355 sbStatus.setAttribute('data-err', 'true'); 4356 syncContinue(); 4357 }); 4358 } 4359 4360 4361 function onSupabaseMessage(event) { 4362 var data = event && event.data; 4363 if (!data || data.type !== 'supabase-oauth') return; 4364 if (data.ok) { 4365 sbStatus.textContent = 'Connected â creating Supabase projectâ¦'; 4366 loadSupabaseStatus().then(function () { 4367 if (selected === 'supabase') autoProvisionSupabaseProject(); 4368 }); 4369 } else { 4370 sbStatus.textContent = data.message || 'Supabase connect failed'; 4371 sbStatus.setAttribute('data-err', 'true'); 4372 } 4373 } 4374 4375 var bc = null; 4376 try { 4377 bc = new BroadcastChannel('open-design-supabase-oauth'); 4378 bc.onmessage = function (ev) { onSupabaseMessage({ data: ev.data }); }; 4379 } catch (e) {} 4380 window.addEventListener('message', onSupabaseMessage); 4381 pollTimer = setInterval(function () { 4382 if (selected === 'supabase' && !supabaseConnected) loadSupabaseStatus(); 4383 }, 4000); 4384 4385 page.querySelectorAll('[data-select]').forEach(function (btn) { 4386 btn.addEventListener('click', function () { 4387 setSelected(btn.getAttribute('data-select')); 4388 }); 4389 }); 4390 page.querySelector('[data-sb-connect]').addEventListener('click', startSupabaseOAuth); 4391 page.querySelector('[data-sb-use]').addEventListener('click', useSupabaseProject); 4392 page.querySelector('[data-sb-manual-use]').addEventListener('click', function () { 4393 var url = (sbUrl.value || '').trim(); 4394 var anonKey = (sbAnon.value || '').trim(); 4395 if (!url || !anonKey) { 4396 sbStatus.textContent = 'Enter both the project URL and publishable key.'; 4397 sbStatus.setAttribute('data-err', 'true'); 4398 return; 4399 } 4400 if (!/^https:\/\//i.test(url)) { 4401 sbStatus.textContent = 'Project URL must start with https://'; 4402 sbStatus.setAttribute('data-err', 'true'); 4403 return; 4404 } 4405 supabaseCredentials = { url: url, anonKey: anonKey }; 4406 sbStatus.textContent = 'Credentials ready â continue to save them on create.'; 4407 sbStatus.setAttribute('data-ok', 'true'); 4408 sbStatus.removeAttribute('data-err'); 4409 setBadge(sbBadge, true, 'Ready'); 4410 syncContinue(); 4411 }); 4412 function cancelPicker() { 4413 window.removeEventListener('message', onSupabaseMessage); 4414 if (bc) try { bc.close(); } catch (e) {} 4415 close(null); 4416 } 4417 page.querySelectorAll('[data-bp-cancel]').forEach(function (btn) { 4418 btn.addEventListener('click', cancelPicker); 4419 }); 4420 page.addEventListener('click', function (ev) { 4421 if (ev.target === page) cancelPicker(); 4422 }); 4423 continueBtn.addEventListener('click', function () { 4424 if (continueBtn.disabled || !selected) return; 4425 window.removeEventListener('message', onSupabaseMessage); 4426 if (bc) try { bc.close(); } catch (e) {} 4427 var result = { provider: selected }; 4428 if (selected === 'supabase' && supabaseCredentials) { 4429 result.supabaseCredentials = supabaseCredentials; 4430 } 4431 close(result); 4432 }); 4433 4434 loadSupabaseStatus(); 4435 }); 4436 } 4437 4438 function beginCreateProjectFromBrief(brief, displayPrompt, choice) { 4439 if (submitting) return; 4440 submitting = true; 4441 var backendProvider = choice && choice.provider ? choice.provider : null; 4442 var supabaseCredentials = choice && choice.supabaseCredentials ? choice.supabaseCredentials : null; 4443 var hasFigmaLinkEarly = stagedLinks.some(function (link) { 4444 return link && link.kind === 'figma' && link.url; 4445 }) || /figma\.com\/(?:design|file|proto)\//i.test(brief); 4446 startBuildingUI({ figma: hasFigmaLinkEarly }); 4447 trackLandingFunnel('project_creation_started', 'landing_prompt', null); 4448 4449 stashBuildIntent(brief); 4450 4451 fetch('/api/guest-intents', { 4452 method: 'POST', 4453 headers: { 'Content-Type': 'application/json' }, 4454 credentials: 'include', 4455 body: JSON.stringify({ 4456 prompt: brief, 4457 source: 'landing', 4458 template: guestIntentTemplate(), 4459 }), 4460 }).catch(function () {}); 4461 4462 var filesToUpload = stagedFiles.map(function (entry) { return entry.file; }); 4463 var name = projectNameFromPrompt(displayPrompt || brief, { hasImages: filesToUpload.length > 0 }); 4464 var projectId = uuid(); 4465 var createBody = buildCreateBody(brief, name, projectId, { 4466 backendProvider: backendProvider || null, 4467 }); 4468 var hasFigmaLink = hasFigmaLinkEarly || Boolean(createBody.pluginId === 'od-figma-migration'); 4469 if (hasFigmaLink && createBody.pluginId !== 'od-figma-migration') { 4470 var forcedUrl = 4471 (createBody.pluginInputs && createBody.pluginInputs.figmaUrl) || 4472 scrapeFigmaFileUrlFromText(brief); 4473 if (forcedUrl) { 4474 createBody.pluginId = 'od-figma-migration'; 4475 createBody.pluginInputs = { 4476 figmaUrl: forcedUrl, 4477 targetStack: 'Flutter (Dart) â Android & iOS', 4478 }; 4479 } 4480 } 4481 4482 // Create itself is fast (Flutter seed). Figma PNG pull runs in the 4483 // background and is polled separately â do not block create for minutes. 4484 var createAbort = typeof AbortController !== 'undefined' ? new AbortController() : null; 4485 var createTimer = null; 4486 if (createAbort) { 4487 createTimer = setTimeout(function () { 4488 try { createAbort.abort(); } catch (e) {} 4489 }, 45000); 4490 } 4491 4492 function mapCreateError(body, fallback) { 4493 var err = body && body.error; 4494 if (!err) return fallback; 4495 if (typeof err === 'string' && err.trim()) return err; 4496 var code = typeof err.code === 'string' ? err.code : ''; 4497 var msg = typeof err.message === 'string' ? err.message.trim() : ''; 4498 if (code === 'CONNECTOR_AUTH_CONFIG_REQUIRED') { 4499 return msg || 'Figma is not enabled on this server yet (Composio is not configured). Ask an admin to enable Figma under Admin â Connectors, then connect your Figma account before Start building.'; 4500 } 4501 if (code === 'CONNECTOR_NOT_CONNECTED') { 4502 return msg || 'Connect your Figma account first (Integrations â Figma), then try again. We will not start the chat without file access.'; 4503 } 4504 if (code === 'FIGMA_EXTRACT_FAILED') { 4505 return msg || 'Could not pull screens and assets from Figma. Reconnect Figma, confirm the file is shared with your account, then try again.'; 4506 } 4507 if (code === 'VALIDATION_FAILED' && /figma/i.test(msg)) { 4508 return msg; 4509 } 4510 if (code === 'NOT_FOUND' && /figma/i.test(msg)) { 4511 return msg; 4512 } 4513 if (code === 'plugin-not-found' || code === 'missing-input') { 4514 return msg || 'Figma setup is incomplete. Connect Figma and paste a file URL, then try again.'; 4515 } 4516 return msg || fallback; 4517 } 4518 4519 function waitForFigmaExtract(createdId) { 4520 var deadline = Date.now() + 12 * 60 * 1000; 4521 var missingMetaSince = Date.now(); 4522 lastFigmaInventory.projectId = createdId || lastFigmaInventory.projectId || ''; 4523 function poll() { 4524 return fetch('/api/projects/' + encodeURIComponent(createdId), { 4525 credentials: 'include', 4526 cache: 'no-store', 4527 }).then(function (r) { 4528 if (r.status === 404) { 4529 throw new Error( 4530 'Could not pull screens and assets from Figma (extract failed). Reconnect Figma, confirm the file is shared with your account, then try again.', 4531 ); 4532 } 4533 if (!r.ok) { 4534 throw new Error('Could not check Figma extract status. Please try again.'); 4535 } 4536 return r.json(); 4537 }).then(function (body) { 4538 var project = body && body.project ? body.project : body; 4539 var fe = project && project.metadata ? project.metadata.figmaExtract : null; 4540 if (!fe) { 4541 // Extract should have been started with od-figma-migration. If 4542 // metadata never appears, do NOT treat that as success (that used 4543 // to open an empty design/ workspace while the progress UI lied). 4544 if (Date.now() - missingMetaSince > 20000) { 4545 throw new Error( 4546
4546 'Figma extract did not start. Paste a full Figma file URL (figma.com/design/â¦), connect Figma, and try again.', 4547 ); 4548 } 4549 if (Date.now() > deadline) { 4550 throw new Error( 4551 'Pulling screens and assets from Figma timed out. This is usually a temporary Figma rate limit â not because the file is too big (a dozen screens is fine). Wait 2â5 minutes, then try once more.', 4552 ); 4553 } 4554 return new Promise(function (resolve) { 4555 setTimeout(resolve, 1200); 4556 }).then(poll); 4557 } 4558 if (fe.phase) { 4559 setFigmaPhase(String(fe.phase), { 4560 screenNames: fe.screenNames, 4561 screenCount: fe.screenCount, 4562 assetCount: fe.assetCount, 4563 }); 4564 } 4565 // Refresh real frame names + PNG paths while extract is still running 4566 // so "View all exports" can show phone previews as soon as files land. 4567 var enrichPromise = enrichFigmaInventoryFromProject(createdId); 4568 if (fe.status === 'ok' || fe.ok === true) { 4569 setFigmaPhase( 4570 fe.phase || ('Pulled ' + (fe.screenCount || 0) + ' screens'), 4571 { 4572 screenNames: fe.screenNames, 4573 screenCount: fe.screenCount, 4574 assetCount: fe.assetCount, 4575 }, 4576 ); 4577 return enrichPromise.then(function () { 4578 return null; 4579 }); 4580 } 4581 if (fe.status === 'failed' || (fe.ok === false && fe.status !== 'running')) { 4582 throw new Error( 4583 (fe.error && String(fe.error).trim()) 4584 || 'Could not pull screens and assets from Figma. Reconnect Figma and try again.', 4585 ); 4586 } 4587 if (Date.now() > deadline) { 4588 throw new Error( 4589 'Pulling screens and assets from Figma timed out. This is usually a temporary Figma rate limit â not because the file is too big (a dozen screens is fine). Wait 2â5 minutes, then try once more.', 4590 ); 4591 } 4592 return enrichPromise.catch(function () { 4593 return null; 4594 }).then(function () { 4595 return new Promise(function (resolve) { 4596 setTimeout(resolve, 1500); 4597 }); 4598 }).then(poll); 4599 }); 4600 } 4601 setFigmaPhase('Connecting to your Figma accountâ¦'); 4602 return poll(); 4603 } 4604 4605 function enrichFigmaInventoryFromProject(createdId) { 4606 // Prefer live screens.json so "View all exports" lists real frame names 4607 // (and preview paths) even when metadata.screenNames was capped/empty. 4608 return fetch( 4609 projectRawPath(createdId, 'figma/screens.json'), 4610 { credentials: 'include', cache: 'no-store' }, 4611 ).then(function (r) { 4612 if (!r.ok) return null; 4613 return r.json(); 4614 }).then(function (json) { 4615 var screens = json && Array.isArray(json.screens) ? json.screens : []; 4616 if (!screens.length) return null; 4617 var names = []; 4618 var previews = []; 4619 for (var i = 0; i < screens.length; i++) { 4620 var s = screens[i]; 4621 if (!s || typeof s.name !== 'string' || !s.name.trim()) continue; 4622 names.push(s.name.trim()); 4623 previews.push({ 4624 name: s.name.trim(), 4625 previewPath: guessFigmaPreviewPath(s), 4626 }); 4627 } 4628 if (!names.length) return null; 4629 lastFigmaInventory.screenNames = names; 4630 lastFigmaInventory.screenCount = Math.max(lastFigmaInventory.screenCount || 0, names.length); 4631 lastFigmaInventory.screenPreviews = previews; 4632 lastFigmaInventory.projectId = createdId; 4633 renderFigmaInventory( 4634 lastFigmaInventory.screenNames, 4635 lastFigmaInventory.screenCount, 4636 lastFigmaInventory.assetCount, 4637 ); 4638 return null; 4639 }).catch(function () { 4640 return null; 4641 }); 4642 } 4643 4644 function postCreate() { 4645 return fetch('/api/projects', { 4646 method: 'POST', 4647 credentials: 'include', 4648 headers: { 'Content-Type': 'application/json' }, 4649 body: JSON.stringify(createBody), 4650 signal: createAbort ? createAbort.signal : undefined, 4651 }) 4652 .then(function (r) { 4653 if (createTimer) clearTimeout(createTimer); 4654 if (r.status === 401) { 4655 throw new Error('AUTH_REQUIRED'); 4656 } 4657 if (!r.ok) { 4658 return r.json().then(function (body) { 4659 throw new Error(mapCreateError(body, 'Could not start your project. Try again.')); 4660 }, function () { 4661 throw new Error('Could not start your project. Try again.'); 4662 }); 4663 } 4664 return r.json(); 4665 }) 4666 .then(function (data) { 4667 if (!hasFigmaLink) return data; 4668 var createdId = 4669 data && data.project && typeof data.project.id === 'string' 4670 ? data.project.id 4671 : projectId; 4672 return waitForFigmaExtract(createdId).then(function () { 4673 return fetch('/api/projects/' + encodeURIComponent(createdId), { 4674 credentials: 'include', 4675 cache: 'no-store', 4676 }).then(function (r) { 4677 if (!r.ok) return data; 4678 return r.json().then(function (body) { 4679 var project = body && body.project ? body.project : body; 4680 var build = project && project.metadata ? project.metadata.figmaBuild : null; 4681 var brief = 4682 (createBody && createBody.pendingPrompt) || 4683 (data && data.project && data.project.pendingPrompt) || 4684 ''; 4685 if (!build || !brief) { 4686 if (project) data.project = project; 4687 return data; 4688 } 4689 var sequential = sequentialFigmaKickoff(brief, build); 4690 return fetch('/api/projects/' + encodeURIComponent(createdId), { 4691 method: 'PATCH', 4692 credentials: 'include', 4693 headers: { 'Content-Type': 'application/json' }, 4694 body: JSON.stringify({ pendingPrompt: sequential }), 4695 }).then(function (patchResp) { 4696 if (patchResp.ok) { 4697 return patchResp.json().then(function (patched) { 4698 var next = patched && patched.project ? patched.project : project; 4699 data.project = Object.assign({}, next, { 4700 pendingPrompt: sequential, 4701 metadata: Object.assign({}, next.metadata || {}, { figmaBuild: build }), 4702 }); 4703 return data; 4704 }, function () { 4705 data.project = Object.assign({}, project, { pendingPrompt: sequential }); 4706 return data; 4707 }); 4708 } 4709 data.project = Object.assign({}, project, { pendingPrompt: sequential }); 4710 return data; 4711 }, function () { 4712 data.project = Object.assign({}, project, { pendingPrompt: sequential }); 4713 return data; 4714 }); 4715 }, function () { 4716 return data; 4717 }); 4718 }, function () { 4719 return data; 4720 }); 4721 }); 4722 }); 4723 } 4724 4725 // Preflight Figma access so we fail on the landing page with a clear 4726 // Connect CTA instead of starting a freestyle chat that scrapes wrong frames. 4727 var preflight = Promise.resolve(); 4728 if (hasFigmaLink) { 4729 // Always keep the Figma plugin bind â never strip it to "just start".
4730 // Without a connected account, figma-extract cannot read the file. 4731 if (!createBody.pluginId) { 4732 var ensureUrl = 4733 (createBody.pluginInputs && createBody.pluginInputs.figmaUrl) || 4734 scrapeFigmaFileUrlFromText(brief); 4735 if (ensureUrl) { 4736 createBody.pluginId = 'od-figma-migration'; 4737 createBody.pluginInputs = { 4738 figmaUrl: ensureUrl, 4739 targetStack: 'Flutter (Dart) â Android & iOS', 4740 }; 4741 } 4742 } 4743 preflight = fetch('/api/connectors/figma', { 4744 credentials: 'include', 4745 cache: 'no-store', 4746 }) 4747 .then(function (r) { 4748 if (r.status === 401) throw new Error('AUTH_REQUIRED'); 4749 if (!r.ok) return null; 4750 return r.json(); 4751 }) 4752 .then(function (detail) { 4753 var connector = detail && detail.connector ? detail.connector : detail; 4754 if (!connector) { 4755 // Probe failed â still hard-block; daemon gate is the fallback. 4756 throw new Error( 4757 'Connect your Figma account first, then click Start building. We need file access before extracting screens â we will not freestyle from a public scrape.', 4758 ); 4759 } 4760 if (connector.status === 'connected') return null; 4761 throw new Error( 4762 'Connect your Figma account first, then click Start building. We need file access before extracting screens â we will not freestyle from a public scrape.', 4763 ); 4764 }); 4765 } 4766 4767 preflight 4768 .then(function () { 4769 return postCreate(); 4770 }) 4771 .then(function (data) { 4772 var createdId = 4773 data && data.project && typeof data.project.id === 'string' 4774 ? data.project.id 4775 : projectId; 4776 var conversationId = 4777 data && typeof data.conversationId === 'string' ? data.conversationId : ''; 4778 return uploadProjectFiles(createdId, filesToUpload).then(function (uploaded) { 4779 var secretChain = Promise.resolve(); 4780 if (supabaseCredentials && supabaseCredentials.url && supabaseCredentials.anonKey) { 4781 secretChain = fetch('/api/projects/' + encodeURIComponent(createdId) + '/secrets', { 4782 method: 'PUT', 4783 credentials: 'include', 4784 headers: { 'Content-Type': 'application/json' }, 4785 body: JSON.stringify({ kind: 'env', target: 'SUPABASE_URL', value: supabaseCredentials.url }), 4786 }).then(function () { 4787 return fetch('/api/projects/' + encodeURIComponent(createdId) + '/secrets', { 4788 method: 'PUT', 4789 credentials: 'include', 4790 headers: { 'Content-Type': 'application/json' }, 4791 body: JSON.stringify({ kind: 'env', target: 'SUPABASE_ANON_KEY', value: supabaseCredentials.anonKey }), 4792 }); 4793 }).catch(function (err) { 4794 console.warn('Failed to save Supabase credentials on create', err); 4795 }); 4796 } 4797 return secretChain.then(function () { 4798 try { 4799 clearAuthGate(); 4800 sessionStorage.removeItem('fg:guest-intent-autosend'); 4801 sessionStorage.setItem('od:auto-send-first:' + createdId, '1'); 4802 if (createBody.pendingPrompt) { 4803 sessionStorage.setItem('od:auto-send-prompt:' + createdId, createBody.pendingPrompt); 4804 } else { 4805 sessionStorage.removeItem('od:auto-send-prompt:' + createdId); 4806 } 4807 if (uploaded.length) { 4808 sessionStorage.setItem( 4809 'od:auto-send-attachments:' + createdId, 4810 JSON.stringify(uploaded), 4811 ); 4812 } else { 4813 sessionStorage.removeItem('od:auto-send-attachments:' + createdId); 4814 } 4815 } catch (e) {} 4816 void clearPersistedStagedAttachments(); 4817 goWorkplace(createdId, conversationId); 4818 }); 4819 }); 4820 }) 4821 .catch(function (err) { 4822 if (createTimer) clearTimeout(createTimer); 4823 if (err && err.message === 'AUTH_REQUIRED') { 4824 resetBuildingUI(); 4825 goAuthGate(brief); 4826 return; 4827 } 4828 var aborted = 4829 (err && err.name === 'AbortError') 4830 || (createAbort && createAbort.signal && createAbort.signal.aborted); 4831 var message = aborted 4832 ? 'Starting the project took too long. Please try again.' 4833 : (err && err.message ? err.message : 'Could not start your project. Try again.'); 4834 trackLandingFunnel( 4835 'project_creation_error', 4836 'landing_prompt', 4837 String(message).slice(0, 160), 4838 ); 4839 if (isFigmaUnavailableError(message)) { 4840 markFigmaConnectAction('integrations'); 4841 resetBuildingUI(message, { 4842 figmaConnect: true, 4843 connectLabel: 'Open Integrations', 4844 }); 4845 } else if (isFigmaConnectError(message)) { 4846 markFigmaConnectAction('connect'); 4847 resetBuildingUI(message, { 4848 figmaConnect: true, 4849 connectLabel: 'Connect Figma', 4850 }); 4851 } else { 4852 markFigmaConnectAction('connect'); 4853 resetBuildingUI(message); 4854 } 4855 }); 4856 } 4857 4858 form.addEventListener('submit', function (event) { 4859 event.preventDefault(); 4860 if (submitting) return; 4861 var prompt = (input.value || '').trim(); 4862 if (!prompt && stagedFiles.length === 0 && stagedLinks.length === 0) { 4863 input.focus(); 4864 return; 4865 } 4866 if (!prompt) { 4867 prompt = stagedLinks.length 4868 ? 'Build an Android & iOS app from the attached references.' 4869 : 'Build from the attached references.'; 4870 } 4871 var brief = composeUserBrief(prompt); 4872 // Always count the click â even when auth gate redirects next. 4873 trackLandingFunnel('cta_clicked', 'hero_prompt', 'submit'); 4874 4875 // Acknowledge the click BEFORE the session probe, not after it. Nothing 4876 // used to move until the probe answered, so a slow one was 4877 // indistinguishable from a dead button â and a stalled one WAS a dead 4878 // button. 4879 var endProbeFeedback = startProbeFeedback(); 4880 4881 // Production rule: 4882 // - no registered account â Create account (prompt stashed for resume) 4883 // - registered session â create project and enter the workplace 4884 fetchAuthSession() 4885 .then(function (session) { 4886 endProbeFeedback(); 4887 if (!isRegisteredUser(session && session.user)) { 4888 goAuthGate(brief); 4889 return; 4890 } 4891 createProjectFromBrief(brief, prompt); 4892 }) 4893 .catch(function () { 4894 endProbeFeedback(); 4895 goAuthGate(brief); 4896 }); 4897 }); 4898 4899 // After login/signup (?resumeBuild=1 or autosend flag), continue create. 4900 (function resumeBuildAfterAuth() { 4901 var wantResume = false;
4902 try { 4903 wantResume = new URLSearchParams(location.search).get('resumeBuild') === '1'; 4904 if (sessionStorage.getItem('fg:guest-intent-autosend') === '1') wantResume = true; 4905 } catch (e) {} 4906 if (!wantResume) { 4907 // Normal home visit â allow a future Send to open signup again. 4908 clearAuthGate(); 4909 return; 4910 } 4911 4912 // Strip resumeBuild immediately so a failed gate cannot blink home â signup. 4913 try { 4914 var clean = new URL(location.href); 4915 if (clean.searchParams.has('resumeBuild')) { 4916 clean.searchParams.delete('resumeBuild'); 4917 window.history.replaceState({}, '', clean.pathname + clean.search + clean.hash); 4918 } 4919 } catch (e) {} 4920 4921 fetchAuthSession() 4922 .then(function (session) { 4923 if (!isRegisteredUser(session && session.user)) { 4924 // Still signed out â open signup once. Do not loop. 4925 if (authGateIsActive()) { 4926 try { 4927 sessionStorage.removeItem('fg:guest-intent-autosend'); 4928 } catch (e) {} 4929 // Declining THIS automatic redirect is the whole loop breaker. 4930 // Releasing the gate afterwards is what keeps the visitor's next 4931 // deliberate Start building click working â a click is not a loop. 4932 clearAuthGate(); 4933 return; 4934 } 4935 goAuthGate(takeStashedBuildPrompt() || ''); 4936 return; 4937 } 4938 clearAuthGate(); 4939 var saved = takeStashedBuildPrompt(); 4940 if (!saved) return; 4941 var stashedLinks = takeStashedLinks(); 4942 restorePersistedStagedAttachments().then(function () { 4943 if (stashedLinks.length && stagedLinks.length === 0) { 4944 stagedLinks = stashedLinks.slice(); 4945 } 4946 try { 4947 if (typeof renderAttachChips === 'function') renderAttachChips(); 4948 } catch (e) {} 4949 input.value = saved; 4950 input.dispatchEvent(new Event('input', { bubbles: true })); 4951 createProjectFromBrief(saved, saved); 4952 void clearPersistedStagedAttachments(); 4953 }); 4954 }) 4955 .catch(function () {}); 4956 })(); 4957 4958 if (examplesLabel) examplesLabel.textContent = 'Try an example app idea'; 4959 input.placeholder = 'Describe the app you wantâ¦'; 4960 renderExamples(); 4961 resizeInput(); 4962 4963 // Nav "Create a project" â /#guest-hero: scroll hero into view and focus prompt. 4964 (function focusHeroFromHash() { 4965 function go() { 4966 if (location.hash !== '#guest-hero') return; 4967 var hero = document.getElementById('guest-hero'); 4968 if (hero) { 4969 try { 4970 hero.scrollIntoView({ behavior: 'smooth', block: 'start' }); 4971 } catch (e) { 4972 hero.scrollIntoView(true); 4973 } 4974 } 4975 try { 4976 input.focus({ preventScroll: true }); 4977 } catch (e) { 4978 input.focus(); 4979 } 4980 } 4981 go(); 4982 window.addEventListener('hashchange', go); 4983 })(); 4984 })(); 4985 4986 /* ---- Contact sales modal --------------------------------------------- */ 4987 var SALES_EMAIL = '[email protected]'; 4988 4989 function buildSalesMailto(fields) { 4990 var bodyLines = [ 4991 (fields.message || '').trim(), 4992 '', 4993 '---', 4994 fields.name ? 'Name: ' + fields.name : '', 4995 fields.email ? 'Email: ' + fields.email : '', 4996 fields.company ? 'Company: ' + fields.company : '' 4997 ].filter(Boolean); 4998 var params = new URLSearchParams(); 4999 params.set('subject', 'FlutterGo Enterprise inquiry'); 5000 params.set('body', bodyLines.join('\n')); 5001 return 'mailto:' + SALES_EMAIL + '?' + params.toString(); 5002 } 5003 5004 function ensureContactSalesModal() { 5005 var existing = document.getElementById('fg-contact-sales'); 5006 if (existing) return existing; 5007 5008 var backdrop = document.createElement('div'); 5009 backdrop.id = 'fg-contact-sales'; 5010 backdrop.className = 'fgs-contact-backdrop'; 5011 backdrop.hidden = true; 5012 backdrop.innerHTML = 5013 '<div class="fgs-contact-modal" role="dialog" aria-modal="true" aria-labelledby="fg-contact-sales-title">' + 5014 '<h2 id="fg-contact-sales-title" class="fgs-contact-modal__title">Contact sales</h2>' + 5015 '<p class="fgs-contact-modal__lead">Tell us about your team and what you need. We will open your email app with a draft you can send to our sales team.</p>' + 5016 '<form class="fgs-contact-form">' + 5017 '<label class="fgs-contact-field"><span>Name</span><input type="text" name="name" autocomplete="name" /></label>' + 5018 '<label class="fgs-contact-field"><span>Work email</span><input type="email" name="email" autocomplete="email" required /></label>' + 5019 '<label class="fgs-contact-field"><span>Company (optional)</span><input type="text" name="company" autocomplete="organization" /></label>' + 5020 '<label class="fgs-contact-field"><span>How can we help?</span><textarea name="message" rows="5" required placeholder="Team size, usage needs, security requirements, timelineâ¦"></textarea></label>' + 5021 '<p class="fgs-contact-error" role="alert" hidden></p>' + 5022 '<div class="fgs-contact-actions">' + 5023 '<button type="button" class="fgs-btn fgs-btn--ghost fgs-btn--sm" data-contact-cancel>Cancel</button>' + 5024 '<button type="submit" class="fgs-btn fgs-btn--sm">Open email draft</button>' + 5025 '</div>' + 5026 '</form>' + 5027 '</div>'; 5028 5029 document.body.appendChild(backdrop); 5030 5031 var form = backdrop.querySelector('.fgs-contact-form'); 5032 var errorEl = backdrop.querySelector('.fgs-contact-error'); 5033 5034 function closeModal() { 5035 backdrop.hidden = true; 5036 document.body.style.overflow = ''; 5037 } 5038 5039 backdrop.addEventListener('click', function (e) { 5040 if (e.target === backdrop) closeModal(); 5041 }); 5042 backdrop.querySelector('[data-contact-cancel]').addEventListener('click', closeModal); 5043 5044 document.addEventListener('keydown', function (e) { 5045 if (e.key === 'Escape' && !backdrop.hidden) closeModal(); 5046 }); 5047 5048 form.addEventListener('submit', function (e) { 5049 e.preventDefault(); 5050 var data = new FormData(form); 5051 var message = String(data.get('message') || '').trim(); 5052 var email = String(data.get('email') || '').trim(); 5053 if (!message) { 5054 errorEl.textContent = 'Tell us what you need so we can help.'; 5055 errorEl.hidden = false;
5056 return; 5057 } 5058 if (!email) { 5059 errorEl.textContent = 'Add your email so we can reply.'; 5060 errorEl.hidden = false; 5061 return; 5062 } 5063 errorEl.hidden = true; 5064 window.location.href = buildSalesMailto({ 5065 name: String(data.get('name') || '').trim(), 5066 email: email, 5067 company: String(data.get('company') || '').trim(), 5068 message: message 5069 }); 5070 closeModal(); 5071 form.reset(); 5072 }); 5073 5074 return backdrop; 5075 } 5076 5077 function openContactSalesModal(prefill) { 5078 var backdrop = ensureContactSalesModal(); 5079 var form = backdrop.querySelector('.fgs-contact-form'); 5080 if (prefill && prefill.email) form.email.value = prefill.email; 5081 if (prefill && prefill.name) form.name.value = prefill.name; 5082 backdrop.hidden = false; 5083 document.body.style.overflow = 'hidden'; 5084 var focusTarget = form.message || form.email; 5085 if (focusTarget) focusTarget.focus(); 5086 } 5087 5088 document.querySelectorAll('[data-contact-sales]').forEach(function (btn) { 5089 btn.addEventListener('click', function () { 5090 fetch('/api/auth/session', { credentials: 'include', cache: 'no-store' }) 5091 .then(function (resp) { return resp.ok ? resp.json() : null; }) 5092 .then(function (session) { 5093 var user = session && session.user; 5094 openContactSalesModal(user ? { email: user.email, name: user.name } : null); 5095 }) 5096 .catch(function () { openContactSalesModal(null); }); 5097 }); 5098 }); 5099 5100 /* ---- Legal TOC scrollspy + smooth offset ---------------------------- */ 5101 var tocLinks = Array.prototype.slice.call(document.querySelectorAll('.fgs-toc a')); 5102 if (tocLinks.length && 'IntersectionObserver' in window) { 5103 var byId = {}; 5104 tocLinks.forEach(function (a) { 5105 var id = a.getAttribute('href').slice(1); 5106 if (id) byId[id] = a; 5107 }); 5108 var spy = new IntersectionObserver(function (entries) { 5109 entries.forEach(function (en) { 5110 if (en.isIntersecting) { 5111 tocLinks.forEach(function (a) { a.classList.remove('is-active'); }); 5112 var a = byId[en.target.id]; 5113 if (a) a.classList.add('is-active'); 5114 } 5115 }); 5116 }, { rootMargin: '-20% 0px -70% 0px' }); 5117 document.querySelectorAll('.fgs-prose h2[id]').forEach(function (h) { spy.observe(h); }); 5118 } 5119})();
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.