JavaScript must be enabled to play.
Browser lacks capabilities required to play.
Upgrade or switch to another browser.
Loading…
:: Start <<script>> (function () { 'use strict'; var PASSAGE = 'Start'; window.setup = window.setup || {}; State.variables.current_passage = PASSAGE; function safePlay(target) { if (!target || State.passage !== PASSAGE) return; try { if (window.SugarCube && SugarCube.Engine && typeof SugarCube.Engine.play === 'function') { SugarCube.Engine.play(target); } else if (typeof Engine !== 'undefined' && typeof Engine.play === 'function') { Engine.play(target); } else { console.error('[Start] No Engine.play available'); } } catch (err) { console.error('[Start] Failed to route to:', target, err); } } function detectShareModeSafe() { try { if (typeof window.setup.detectShareMode === 'function') { return !!window.setup.detectShareMode(); } var params = new URLSearchParams(window.location.search); var shareToken = params.get('share'); State.variables.share_token = shareToken || null; State.variables.share_mode = !!shareToken; return State.variables.share_mode; } catch (err) { console.warn('[Start] detectShareMode failed', err); State.variables.share_token = null; State.variables.share_mode = false; return false; } } function isAuthenticatedSafe() { try { if (typeof window.setup.checkAuth === 'function') { return !!window.setup.checkAuth(); } return !!( sessionStorage.getItem('access_token') || sessionStorage.getItem('jwt_token') || localStorage.getItem('auth_token') || sessionStorage.getItem('auth_token') ); } catch (err) { console.warn('[Start] checkAuth failed', err); return false; } } function chooseNextPassage() { if (detectShareModeSafe()) { return 'ShareView'; } if (isAuthenticatedSafe()) { return 'Dashboard'; } return 'Login'; } var nextPassage = chooseNextPassage(); State.variables.start_route_target = nextPassage; window.setTimeout(function () { safePlay(nextPassage); }, 0); })(); <</script>> <div class="app-route-loading"> <div class="loading-spinner"></div> <p>Loading ArcEngine…</p> </div> <style> .app-route-loading { min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1rem; padding: 2rem; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; text-align: center; } .app-route-loading .loading-spinner { width: 52px; height: 52px; border: 4px solid rgba(255, 255, 255, 0.28); border-top-color: #ffffff; border-radius: 50%; animation: arcengine-start-spin 0.9s linear infinite; } .app-route-loading p { margin: 0; font-size: 1rem; font-weight: 600; letter-spacing: 0.02em; } @keyframes arcengine-start-spin { to { transform: rotate(360deg); } } </style>
:: StoryInit /* * StoryInit - Hardened State Initialization * * Safe defaults only: * - does NOT clobber existing state * - initializes all variables used by current passages * - safely detects share mode * - does NOT redirect from StoryInit (Start handles routing) * - keeps browser/session reads inside JS, not Twine macros */ /* Ensure setup namespace exists */ <<run window.setup = window.setup || {}>> /* -------------------------------------------------------------------------- Authentication state -------------------------------------------------------------------------- */ <<if typeof $tenant_id === "undefined">><<set $tenant_id = null>><</if>> <<if typeof $user_id === "undefined">><<set $user_id = null>><</if>> <<if typeof $jwt_token === "undefined">><<set $jwt_token = null>><</if>> /* -------------------------------------------------------------------------- Project management state -------------------------------------------------------------------------- */ <<if typeof $current_project === "undefined">><<set $current_project = null>><</if>> <<if !Array.isArray($projects)>><<set $projects = []>><</if>> <<if typeof $loading_projects === "undefined">><<set $loading_projects = false>><</if>> <<if typeof $loading_project === "undefined">><<set $loading_project = false>><</if>> <<if typeof $creating_project === "undefined">><<set $creating_project = false>><</if>> <<if typeof $form_project_name === "undefined">><<set $form_project_name = "">><</if>> <<if typeof $form_target_company === "undefined">><<set $form_target_company = "">><</if>> <<if !$form_errors || typeof $form_errors !== "object">><<set $form_errors = {}>><</if>> /* -------------------------------------------------------------------------- Upload and processing state -------------------------------------------------------------------------- */ <<if typeof $upload_status === "undefined">><<set $upload_status = "idle">><</if>> <<if typeof $document_id === "undefined">><<set $document_id = null>><</if>> <<if typeof $upload_progress === "undefined">><<set $upload_progress = 0>><</if>> <<if typeof $upload_in_progress === "undefined">><<set $upload_in_progress = false>><</if>> <<if typeof $upload_complete === "undefined">><<set $upload_complete = false>><</if>> <<if typeof $selected_file === "undefined">><<set $selected_file = null>><</if>> <<if typeof $validation_error === "undefined">><<set $validation_error = null>><</if>> /* -------------------------------------------------------------------------- Narrative state -------------------------------------------------------------------------- */ <<if typeof $narrative_version === "undefined">><<set $narrative_version = null>><</if>> <<if !Array.isArray($narrative_passages)>><<set $narrative_passages = []>><</if>> <<if typeof $narrative_loaded === "undefined">><<set $narrative_loaded = false>><</if>> <<if typeof $loading_narrative === "undefined">><<set $loading_narrative = false>><</if>> <<if typeof $current_passage_id === "undefined">><<set $current_passage_id = null>><</if>> <<if !Array.isArray($visited_passages)>><<set $visited_passages = []>><</if>> <<if !Array.isArray($passage_history)>><<set $passage_history = []>><</if>> <<if typeof $narrative_error === "undefined">><<set $narrative_error = null>><</if>> /* -------------------------------------------------------------------------- Share mode state -------------------------------------------------------------------------- */ <<if typeof $share_mode === "undefined">><<set $share_mode = false>><</if>> <<if typeof $share_token === "undefined">><<set $share_token = null>><</if>> /* -------------------------------------------------------------------------- Polling state -------------------------------------------------------------------------- */ <<if typeof $polling_active === "undefined">><<set $polling_active = false>><</if>> <<if typeof $polling_status === "undefined">><<set $polling_status = "idle">><</if>> <<if typeof $polling_attempts === "undefined">><<set $polling_attempts = 0>><</if>> <<if typeof $polling_start_time === "undefined">><<set $polling_start_time = null>><</if>> /* -------------------------------------------------------------------------- Share link / project view helpers -------------------------------------------------------------------------- */ <<if typeof $generating_share_link === "undefined">><<set $generating_share_link = false>><</if>> <<if typeof $share_link_generated === "undefined">><<set $share_link_generated = null>><</if>> <<if typeof $share_link_copied === "undefined">><<set $share_link_copied = false>><</if>> /* -------------------------------------------------------------------------- Subscription / account state -------------------------------------------------------------------------- */ <<if typeof $subscription_active === "undefined">><<set $subscription_active = false>><</if>> <<if typeof $account_loaded === "undefined">><<set $account_loaded = false>><</if>> <<if typeof $user_email === "undefined">><<set $user_email = null>><</if>> <<if typeof $account_created === "undefined">><<set $account_created = null>><</if>> <<if typeof $last_login === "undefined">><<set $last_login = null>><</if>> /* -------------------------------------------------------------------------- Error handling state -------------------------------------------------------------------------- */ <<if typeof $error_message === "undefined">><<set $error_message = null>><</if>> <<if typeof $error_code === "undefined">><<set $error_code = null>><</if>> <<if typeof $error_details === "undefined">><<set $error_details = null>><</if>> <<if typeof $error_timestamp === "undefined">><<set $error_timestamp = null>><</if>> /* -------------------------------------------------------------------------- Misc / routing state -------------------------------------------------------------------------- */ <<if typeof $current_passage === "undefined">><<set $current_passage = "Start">><</if>> <<script>> (function () { 'use strict'; window.setup = window.setup || {}; var v = State.variables; function fallbackDetectShareMode() { try { var params = new URLSearchParams(window.location.search); var shareToken = params.get('share'); if (shareToken) { v.share_mode = true; v.share_token = shareToken; return true; } v.share_mode = false; v.share_token = null; return false; } catch (err) { console.warn('[StoryInit] share-mode detection failed:', err); return false; } } try { if (typeof window.setup.detectShareMode === 'function') { window.setup.detectShareMode(); } else { fallbackDetectShareMode(); } } catch (err) { console.warn('[StoryInit] detectShareMode helper failed, using fallback:', err); fallbackDetectShareMode(); } try { var jwtToken = sessionStorage.getItem('jwt_token'); if (jwtToken && !v.jwt_token) { v.jwt_token = jwtToken; } } catch (err) { console.warn('[StoryInit] sessionStorage jwt read failed:', err); } try { if (typeof window.setup.checkAuth === 'function') { window.setup.checkAuth(); } } catch (err) { console.warn('[StoryInit] checkAuth bootstrap failed:', err); } try { if (!v.polling_status || v.polling_status === 'idle') { v.polling_status = v.upload_status || 'idle'; } } catch (err) { console.warn('[StoryInit] polling bootstrap failed:', err); } })(); <</script>>
:: account /* * Account Passage * * Hardened for Twine/SugarCube: * - uses passage-safe init instead of document.ready * - fixes Dashboard route case * - guards auth and missing billing helpers * - safely loads subscription + usage + account info * - provides wrapper functions for buttons */ <<script>> (function () { var PASSAGE = 'account'; window.setup = window.setup || {}; State.variables.current_passage = PASSAGE; function playPassage(name) { if (window.SugarCube && SugarCube.Engine) { SugarCube.Engine.play(name); } else { Engine.play(name); } } function isCurrentPassage() { return State.passage === PASSAGE; } function setText(id, value) { var el = document.getElementById(id); if (el) { el.textContent = value == null || value === '' ? '—' : String(value); } } function setHidden(id, hidden) { var el = document.getElementById(id); if (el) { el.hidden = !!hidden; } } function formatDateSafe(value) { if (!value) return '—'; try { if (window.setup && typeof window.setup.formatDate === 'function') { return window.setup.formatDate(value); } var d = new Date(value); if (isNaN(d.getTime())) return String(value); return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); } catch (err) { return String(value); } } function formatUnixSafe(value) { if (!value) return '—'; try { var num = Number(value); if (!isFinite(num) || num <= 0) return '—'; return new Date(num * 1000).toLocaleString(); } catch (err) { return '—'; } } function renderAccountError(message) { var el = document.getElementById('account-error-banner'); var text = document.getElementById('account-error-text'); if (!el || !text) return; if (message) { text.textContent = String(message); el.hidden = false; } else { text.textContent = ''; el.hidden = true; } } function clearAccountError() { State.variables.error_message = null; renderAccountError(null); } function setLoadingState(loading) { var manageBtn = document.getElementById('manage-billing-btn'); var changeBtn = document.getElementById('change-plan-btn'); var backBtn = document.getElementById('account-back-btn'); if (manageBtn) manageBtn.disabled = !!loading; if (changeBtn) changeBtn.disabled = !!loading; if (backBtn) backBtn.disabled = !!loading; } function updateStatusBadge(subscription) { var badge = document.getElementById('subscription-status-badge'); if (!badge) return; var indicator = badge.querySelector('.status-indicator'); var text = badge.querySelector('.status-text'); var status = (subscription && (subscription.status || subscription.subscription_status)) || (State.variables.subscription_active ? 'active' : 'inactive'); status = String(status || 'inactive').toLowerCase(); if (indicator) { indicator.className = 'status-indicator'; if (status === 'active') { indicator.classList.add('active'); } else if (status === 'trialing' || status === 'trial') { indicator.classList.add('trial'); } else { indicator.classList.add('inactive'); } } if (text) { if (status === 'trialing') { text.textContent = 'Trial'; } else if (status === 'active') { text.textContent = 'Active'; } else if (status === 'canceled' || status === 'cancelled' || status === 'expired') { text.textContent = 'Inactive'; } else { text.textContent = status.charAt(0).toUpperCase() + status.slice(1); } } } function updateAccountInfoFromState() { var token = sessionStorage.getItem('jwt_token') || State.variables.jwt_token || null; var decoded = null; try { if (token && window.setup && typeof window.setup.decodeJWT === 'function') { decoded = window.setup.decodeJWT(token); } } catch (err) { console.warn('[account] Failed to decode JWT:', err); } var email = State.variables.user_email || (decoded && (decoded.email || decoded['cognito:username'])) || State.variables.user_id || '—'; var createdAt = State.variables.account_created || State.variables.user_created_at || State.variables.created_at || null; var lastLogin = State.variables.last_login || State.variables.last_login_at || (decoded && (decoded.auth_time || decoded.iat)) || null; setText('user-email', email); setText('account-created', createdAt ? formatDateSafe(createdAt) : 'Available after first sync'); setText( 'last-login', (typeof lastLogin === 'number' || /^[0-9]+$/.test(String(lastLogin || ''))) ? formatUnixSafe(lastLogin) : formatDateSafe(lastLogin) ); } async function loadAccountDataSafe() { if (!isCurrentPassage()) return; setLoadingState(true); renderAccountError(null); setHidden('account-loading', false); try { if (window.setup && typeof window.setup.checkAuth === 'function') { if (!window.setup.checkAuth()) { playPassage('Login'); return; } } var billing = window.setup ? window.setup.billing : null; var subscription = null; var usage = null; if (billing && typeof billing.loadAccountData === 'function') { await billing.loadAccountData(); } else { if (billing && typeof billing.loadSubscriptionData === 'function') { subscription = await billing.loadSubscriptionData(); } if (billing && typeof billing.loadUsageData === 'function') { usage = await billing.loadUsageData(); } if (billing && typeof billing.updateSubscriptionUI === 'function' && subscription) { billing.updateSubscriptionUI(subscription); } if (billing && typeof billing.updateUsageUI === 'function' && usage) { billing.updateUsageUI(usage); } } if (billing && typeof billing.loadSubscriptionData === 'function') { try { subscription = subscription || await billing.loadSubscriptionData(); } catch (err) { console.warn('[account] Subscription refresh warning:', err); } } updateStatusBadge(subscription || null); updateAccountInfoFromState(); if (billing && typeof billing.openCustomerPortal !== 'function') { var manageBtn = document.getElementById('manage-billing-btn'); if (manageBtn) manageBtn.disabled = true; } State.variables.account_loaded = true; } catch (err) { console.error('[account] Failed to load account data:', err); renderAccountError((err && err.message) ? err.message : 'Unable to load account details.'); updateAccountInfoFromState(); } finally { setHidden('account-loading', true); setLoadingState(false); } } window.setup.accountGoDashboard = function () { playPassage('Dashboard'); }; window.setup.accountGoPricing = function () { playPassage('pricing'); }; window.setup.accountOpenBillingPortal = async function () { try { var billing = window.setup ? window.setup.billing : null; if (!billing || typeof billing.openCustomerPortal !== 'function') { throw new Error('Billing portal is not available yet.'); } await billing.openCustomerPortal(); } catch (err) { console.error('[account] open billing portal failed:', err); renderAccountError((err && err.message) ? err.message : 'Unable to open billing portal.'); } }; window.setup.accountDismissError = function () { clearAccountError(); }; window.setTimeout(function () { if (!isCurrentPassage()) return; loadAccountDataSafe(); }, 0); })(); <</script>> <div class="account-page"> <div class="account-header"> <h1>Account Settings</h1> <p>Manage your subscription and account preferences.</p> </div> <div id="account-loading" class="account-loading" hidden> <div class="account-loading-spinner"></div> <span>Loading account data…</span> </div> <div id="account-error-banner" class="account-error-banner" hidden> <div class="account-error-copy"> <strong>Error:</strong> <span id="account-error-text"></span> </div> <button class="btn btn-secondary btn-small" type="button" onclick="window.setup.accountDismissError()"> Dismiss </button> </div> <div class="account-content"> <div class="account-section"> <div class="section-header"> <h2>Subscription</h2> <div class="subscription-status" id="subscription-status-badge"> <span class="status-indicator"></span> <span class="status-text">Loading...</span> </div> </div> <div class="subscription-details" id="subscription-details"> <div class="detail-grid"> <div class="detail-item"> <label>Current Plan:</label> <span id="current-plan">Loading...</span> </div> <div class="detail-item"> <label>Billing Amount:</label> <span id="billing-amount">Loading...</span> </div> <div class="detail-item"> <label>Next Billing Date:</label> <span id="next-billing-date">Loading...</span> </div> <div class="detail-item"> <label>Payment Method:</label> <span id="payment-method">Loading...</span> </div> </div> <div class="subscription-actions"> <button class="btn btn-primary" id="manage-billing-btn" type="button" onclick="window.setup.accountOpenBillingPortal()"> Manage Billing </button> <button class="btn btn-secondary" id="change-plan-btn" type="button" onclick="window.setup.accountGoPricing()"> Change Plan </button> </div> </div> </div> <div class="account-section"> <div class="section-header"> <h2>Usage</h2> <span class="usage-period">Current billing period</span> </div> <div class="usage-details" id="usage-details"> <div class="usage-item"> <div class="usage-label">Projects</div> <div class="usage-bar"> <div class="usage-progress" id="projects-progress"></div> </div> <div class="usage-text"> <span id="projects-used">0</span> / <span id="projects-limit">1</span> </div> </div> <div class="usage-item"> <div class="usage-label">Documents</div> <div class="usage-bar"> <div class="usage-progress" id="documents-progress"></div> </div> <div class="usage-text"> <span id="documents-used">0</span> / <span id="documents-limit">3</span> </div> </div> <div class="usage-item"> <div class="usage-label">Narratives Generated</div> <div class="usage-bar"> <div class="usage-progress" id="narratives-progress"></div> </div> <div class="usage-text"> <span id="narratives-used">0</span> / <span id="narratives-limit">5</span> </div> </div> </div> </div> <div class="account-section"> <div class="section-header"> <h2>Account Information</h2> </div> <div class="account-info"> <div class="detail-grid"> <div class="detail-item"> <label>Email:</label> <span id="user-email">Loading...</span> </div> <div class="detail-item"> <label>Account Created:</label> <span id="account-created">Loading...</span> </div> <div class="detail-item"> <label>Last Login:</label> <span id="last-login">Loading...</span> </div> </div> </div> </div> </div> <div class="account-actions"> <button class="btn btn-secondary" id="account-back-btn" type="button" onclick="window.setup.accountGoDashboard()"> ← Back to Dashboard </button> </div> </div> <style> .account-page { max-width: 1000px; margin: 0 auto; padding: 2rem; } .account-header { text-align: center; margin-bottom: 2rem; } .account-header h1 { font-size: 2.5rem; color: #1a202c; margin-bottom: 0.5rem; } .account-header p { font-size: 1.2rem; color: #718096; } .account-loading { display: inline-flex; align-items: center; gap: 0.75rem; margin: 0 auto 1.5rem; padding: 0.85rem 1rem; background: #edf2f7; border-radius: 999px; color: #4a5568; } .account-loading-spinner { width: 18px; height: 18px; border: 3px solid #cbd5e0; border-top-color: #4299e1; border-radius: 50%; animation: account-spin 1s linear infinite; } @keyframes account-spin { to { transform: rotate(360deg); } } .account-error-banner { display: flex; justify-content: space-between; align-items: center; gap: 1rem; margin-bottom: 1.5rem; padding: 1rem 1.25rem; background: #fff5f5; border: 1px solid #feb2b2; border-radius: 10px; } .account-error-copy { color: #9b2c2c; } .account-content { display: grid; gap: 2rem; margin-bottom: 3rem; } .account-section { background: white; border: 1px solid #e2e8f0; border-radius: 12px; padding: 2rem; } .section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; padding-bottom: 1rem; border-bottom: 1px solid #e2e8f0; } .section-header h2 { font-size: 1.5rem; color: #1a202c; margin: 0; } .subscription-status { display: flex; align-items: center; gap: 0.5rem; } .status-indicator { width: 12px; height: 12px; border-radius: 50%; background: #48bb78; } .status-indicator.active { background: #48bb78; } .status-indicator.inactive { background: #f56565; } .status-indicator.trial { background: #ed8936; } .status-text { font-weight: 600; color: #1a202c; } .usage-period { font-size: 0.875rem; color: #718096; } .detail-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1rem; margin-bottom: 2rem; } .detail-item { display: flex; justify-content: space-between; align-items: center; gap: 1rem; padding: 0.75rem 0; } .detail-item label { font-weight: 600; color: #4a5568; } .detail-item span { color: #1a202c; font-weight: 500; text-align: right; } .subscription-actions { display: flex; gap: 1rem; } .usage-details { display: grid; gap: 1.5rem; } .usage-item { display: grid; grid-template-columns: 150px 1fr auto; gap: 1rem; align-items: center; } .usage-label { font-weight: 600; color: #4a5568; } .usage-bar { height: 8px; background: #e2e8f0; border-radius: 4px; overflow: hidden; } .usage-progress { height: 100%; width: 0%; background: #4299e1; transition: width 0.3s ease; } .usage-progress.warning { background: #ed8936; } .usage-progress.danger { background: #f56565; } .usage-text { font-size: 0.875rem; color: #718096; white-space: nowrap; } .btn { padding: 0.75rem 1.5rem; border-radius: 8px; font-weight: 600; cursor: pointer; transition: all 0.2s ease; border: none; font-size: 1rem; } .btn-small { padding: 0.5rem 0.85rem; font-size: 0.875rem; } .btn-primary { background: #4299e1; color: white; } .btn-primary:hover:not(:disabled) { background: #3182ce; } .btn-secondary { background: #e2e8f0; color: #4a5568; } .btn-secondary:hover:not(:disabled) { background: #cbd5e0; } .btn:disabled { opacity: 0.6; cursor: not-allowed; } .account-actions { text-align: center; } @media (max-width: 768px) { .detail-grid { grid-template-columns: 1fr; } .subscription-actions { flex-direction: column; } .usage-item { grid-template-columns: 1fr; gap: 0.5rem; } .usage-bar { order: 3; } .usage-text { order: 2; text-align: right; } .section-header { flex-direction: column; align-items: flex-start; gap: 0.75rem; } .account-error-banner { flex-direction: column; align-items: flex-start; } } </style>
:: NarrativeView /* * Narrative View Passage * * Hardened version: * - prevents repeated re-initialization on every rerender * - loads narrative only once per project/share token unless source changes * - fixes Twine variable rendering in HTML * - uses a safe DOM mount for dynamic passage rendering * - hardens back / restart / exit behavior * - avoids duplicate load loops and duplicate history entries * * Requirements: 8.1, 8.2, 8.3, 8.4 */ <<script>> (function () { var PASSAGE = 'NarrativeView'; window.setup = window.setup || {}; State.variables.current_passage = PASSAGE; function playPassage(name) { if (window.SugarCube && SugarCube.Engine) { SugarCube.Engine.play(name); } else { Engine.play(name); } } function escapeHtml(text) { if (window.setup.escapeHtml && typeof window.setup.escapeHtml === 'function') { return window.setup.escapeHtml(text); } var div = document.createElement('div'); div.textContent = String(text == null ? '' : text); return div.innerHTML; } function formatPassageContent(content) { var raw = String(content == null ? '' : content); if (!raw.trim()) return ''; return raw .split(/\n\n+/) .map(function (block) { var trimmed = block.trim(); if (!trimmed) return ''; return '<p>' + escapeHtml(trimmed).replace(/\n/g, '<br>') + '</p>'; }) .join(''); } function getNarrativeSourceKey() { if (State.variables.share_mode) { return 'share:' + String(State.variables.share_token || ''); } var projectId = State.variables.current_project && State.variables.current_project.project_id ? State.variables.current_project.project_id : ''; return 'project:' + projectId; } function getNarrativePassages() { return Array.isArray(State.variables.narrative_passages) ? State.variables.narrative_passages : []; } function getCurrentNarrativeNode() { var passages = getNarrativePassages(); var currentId = State.variables.current_passage_id; if (!currentId) return null; for (var i = 0; i < passages.length; i++) { if (String(passages[i].id) === String(currentId)) { return passages[i]; } } return null; } function getFirstNarrativeNode() { var passages = getNarrativePassages(); if (!passages.length) return null; if (State.variables.narrative_start_passage_id) { for (var i = 0; i < passages.length; i++) { if (String(passages[i].id) === String(State.variables.narrative_start_passage_id)) { return passages[i]; } } } return passages[0]; } if (!State.variables.share_mode && (!window.setup.checkAuth || !window.setup.checkAuth())) { playPassage('Login'); return; } if (!State.variables.share_mode) { if (!State.variables.current_project || !State.variables.current_project.project_id) { playPassage('Dashboard'); return; } } if (typeof State.variables.loading_narrative === 'undefined') State.variables.loading_narrative = false; if (!Array.isArray(State.variables.narrative_passages)) State.variables.narrative_passages = []; if (typeof State.variables.current_passage_id === 'undefined') State.variables.current_passage_id = null; if (!Array.isArray(State.variables.visited_passages)) State.variables.visited_passages = []; if (!Array.isArray(State.variables.passage_history)) State.variables.passage_history = []; if (typeof State.variables.narrative_error === 'undefined') State.variables.narrative_error = null; if (typeof State.variables.narrative_loaded === 'undefined') State.variables.narrative_loaded = false; if (typeof State.variables.narrative_source_key === 'undefined') State.variables.narrative_source_key = null; if (typeof State.variables.narrative_version === 'undefined') State.variables.narrative_version = null; if (typeof State.variables.narrative_start_passage_id === 'undefined') State.variables.narrative_start_passage_id = null; var sourceKey = getNarrativeSourceKey(); if (State.variables.narrative_source_key !== sourceKey) { State.variables.narrative_source_key = sourceKey; State.variables.loading_narrative = false; State.variables.narrative_loaded = false; State.variables.narrative_passages = []; State.variables.current_passage_id = null; State.variables.visited_passages = []; State.variables.passage_history = []; State.variables.narrative_error = null; State.variables.narrative_version = null; State.variables.narrative_start_passage_id = null; } window.setup.startNarrative = function () { var firstNode = getFirstNarrativeNode(); if (!firstNode || !firstNode.id) { State.variables.narrative_error = 'No passages available'; playPassage(PASSAGE); return; } window.setup.navigateToPassage(firstNode.id, { fromHistory: false }); }; window.setup.navigateToPassage = function (passageId, options) { var opts = options || {}; var passages = getNarrativePassages(); var target = null; var i; for (i = 0; i < passages.length; i++) { if (String(passages[i].id) === String(passageId)) { target = passages[i]; break; } } if (!target) { console.error('[NarrativeView] Passage not found:', passageId); State.variables.narrative_error = 'Passage not found'; playPassage(PASSAGE); return; } State.variables.current_passage_id = target.id; State.variables.narrative_error = null; if (!Array.isArray(State.variables.visited_passages)) { State.variables.visited_passages = []; } if (State.variables.visited_passages.indexOf(target.id) === -1) { State.variables.visited_passages.push(target.id); } if (!Array.isArray(State.variables.passage_history)) { State.variables.passage_history = []; } if (!opts.fromHistory) { State.variables.passage_history.push(target.id); } playPassage(PASSAGE); }; window.setup.goToPreviousPassage = function () { var history = Array.isArray(State.variables.passage_history) ? State.variables.passage_history : []; if (history.length <= 1) { return; } history.pop(); State.variables.current_passage_id = history[history.length - 1] || null; State.variables.narrative_error = null; playPassage(PASSAGE); }; window.setup.restartNarrative = function () { State.variables.current_passage_id = null; State.variables.visited_passages = []; State.variables.passage_history = []; State.variables.narrative_error = null; playPassage(PASSAGE); }; window.setup.exitNarrative = function () { State.variables.current_passage_id = null; State.variables.visited_passages = []; State.variables.passage_history = []; State.variables.narrative_error = null; playPassage('ProjectView'); }; window.setup.renderNarrativePassageInto = function (selector) { var mount = document.querySelector(selector); if (!mount) return; var node = getCurrentNarrativeNode(); if (!node) { mount.innerHTML = '<div class="passage-card"><div class="passage-end"><p class="end-message">Passage not found.</p></div></div>'; return; } var html = ''; html += '<div class="passage-card">'; if (node.title) { html += '<h2 class="passage-title">' + escapeHtml(node.title) + '</h2>'; } html += '<div class="passage-content">' + formatPassageContent(node.content) + '</div>'; if (Array.isArray(node.choices) && node.choices.length > 0) { html += '<div class="passage-choices">'; node.choices.forEach(function (choice) { var target = choice && choice.target ? String(choice.target) : ''; var label = choice && choice.text ? String(choice.text) : 'Continue'; var targetJs = JSON.stringify(target); html += ( '<button class="choice-button" type="button" onclick=\'window.setup.navigateToPassage(' + targetJs + ')\' aria-label="' + escapeHtml('Choose: ' + label) + '">' + '<span>' + escapeHtml(label) + '</span>' + '</button>' ); }); html += '</div>'; } else { html += '<div class="passage-end">'; html += '<p class="end-message">You’ve reached the end of this narrative path.</p>'; html += '<button class="btn-primary" type="button" onclick="window.setup.restartNarrative()">Start Over</button>'; html += '</div>'; } html += '</div>'; mount.innerHTML = html; }; window.setup.ensureNarrativeLoaded = function () { if (window.setup._narrativeLoadInFlight) return; if (State.variables.narrative_loaded || State.variables.loading_narrative) return; if (!window.setup.loadNarrative || typeof window.setup.loadNarrative !== 'function') { State.variables.narrative_error = 'Narrative loader is unavailable.'; playPassage(PASSAGE); return; } window.setup._narrativeLoadInFlight = true; State.variables.loading_narrative = true; State.variables.narrative_error = null; (async function () { try { var narrative = await window.setup.loadNarrative(); if (narrative && narrative.version_id) { State.variables.narrative_version = narrative.version_id; } if (narrative && narrative.start_passage_id) { State.variables.narrative_start_passage_id = narrative.start_passage_id; } State.variables.narrative_loaded = true; State.variables.loading_narrative = false; State.variables.narrative_error = null; if (State.passage === PASSAGE) { playPassage(PASSAGE); } } catch (error) { console.error('[NarrativeView] Failed to load narrative:', error); State.variables.narrative_error = error && error.message ? error.message : 'Failed to load narrative'; State.variables.loading_narrative = false; State.variables.narrative_loaded = false; if (State.passage === PASSAGE) { playPassage(PASSAGE); } } finally { window.setup._narrativeLoadInFlight = false; } })(); }; if (!State.variables.narrative_loaded && !State.variables.loading_narrative) { window.setup.ensureNarrativeLoaded(); } window.setTimeout(function () { if (State.passage !== PASSAGE) return; if (!State.variables.loading_narrative && !State.variables.narrative_error && State.variables.current_passage_id) { window.setup.renderNarrativePassageInto('#narrative-current-passage'); } }, 0); })(); <</script>> <div class="narrative-container"> <<if $loading_narrative>> <div class="narrative-loading"> <div class="loading-spinner"></div> <h2>Loading Your Narrative...</h2> <p>Preparing your interactive sales story</p> </div> <<elseif $narrative_error>> <div class="narrative-error"> <div class="error-icon">⚠️</div> <h2>Unable to Load Narrative</h2> <p><<print $narrative_error>></p> <div class="error-actions"> <<if $share_mode>> <button class="btn-primary" type="button" onclick="window.location.reload()"> Try Again </button> <<else>> <button class="btn-primary" type="button" onclick="Engine.play('ProjectView')"> Back to Project </button> <button class="btn-secondary" type="button" onclick="window.location.reload()"> Reload </button> <</if>> </div> </div> <<elseif $narrative_passages.length === 0>> <div class="narrative-empty"> <div class="empty-icon">📖</div> <h2>No Narrative Available</h2> <p>This project doesn't have a generated narrative yet.</p> <<if !$share_mode>> <button class="btn-primary" type="button" onclick="Engine.play('ProjectView')"> Back to Project </button> <</if>> </div> <<else>> <header class="narrative-header"> <div class="header-content"> <<if !$share_mode>> <button class="btn-back" type="button" onclick="window.setup.exitNarrative()" aria-label="Exit narrative"> <svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"> <path fill="currentColor" d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/> </svg> Exit Narrative </button> <<else>> <div class="share-mode-badge"> <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"> <path fill="currentColor" d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/> </svg> Read-Only View </div> <</if>> <div class="narrative-progress"> <span class="progress-text"> Passage <<print $current_passage_id ? $visited_passages.length : 0>> of <<print $narrative_passages.length>> </span> <div class="progress-bar"> <div class="progress-fill" style="<<print 'width: ' + ($narrative_passages.length ? Math.round((($current_passage_id ? $visited_passages.length : 0) / $narrative_passages.length) * 100) : 0) + '%'>>"> </div> </div> </div> </div> </header> <main class="narrative-main"> <div class="narrative-content"> <<if $current_passage_id === null>> <div class="narrative-start"> <div class="start-content"> <h1 class="narrative-title">Interactive Sales Narrative</h1> <<if $current_project>> <div class="narrative-meta"> <div class="meta-item"> <span class="meta-label">Project:</span> <span class="meta-value"><<print $current_project.name>></span> </div> <div class="meta-item"> <span class="meta-label">Target Company:</span> <span class="meta-value"><<print $current_project.target_company>></span> </div> <<if $narrative_version>> <div class="meta-item"> <span class="meta-label">Version:</span> <span class="meta-value detail-mono"><<print $narrative_version>></span> </div> <</if>> </div> <</if>> <p class="start-description"> Navigate through this interactive story by making choices that shape the narrative. Your selections will guide the conversation and reveal different insights about the opportunity. </p> <button class="btn-start" type="button" onclick="window.setup.startNarrative()" aria-label="Begin narrative"> Begin Narrative </button> </div> </div> <<else>> <div id="narrative-current-passage"></div> <</if>> </div> </main> <<if $current_passage_id !== null>> <footer class="narrative-footer"> <div class="footer-content"> <<if $passage_history && $passage_history.length > 1>> <button class="btn-nav btn-back-passage" type="button" onclick="window.setup.goToPreviousPassage()" aria-label="Go to previous passage"> <svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"> <path fill="currentColor" d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/> </svg> Previous </button> <<else>> <div></div> <</if>> <button class="btn-nav btn-restart" type="button" onclick="window.setup.restartNarrative()" aria-label="Restart narrative"> <svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"> <path fill="currentColor" d="M12 5V1L7 6l5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/> </svg> Restart </button> </div> </footer> <</if>> <</if>> </div> <style> .narrative-container { min-height: 100vh; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; flex-direction: column; } .narrative-loading { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 60px 20px; color: white; text-align: center; } .loading-spinner { width: 60px; height: 60px; border: 4px solid rgba(255, 255, 255, 0.3); border-top-color: white; border-radius: 50%; animation: narrative-spin 1s linear infinite; margin-bottom: 24px; } @keyframes narrative-spin { to { transform: rotate(360deg); } } .narrative-loading h2 { font-size: 28px; font-weight: 700; margin: 0 0 12px 0; } .narrative-loading p { font-size: 16px; opacity: 0.9; margin: 0; } .narrative-error { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 60px 20px; color: white; text-align: center; } .error-icon { font-size: 64px; margin-bottom: 24px; } .narrative-error h2 { font-size: 28px; font-weight: 700; margin: 0 0 12px 0; } .narrative-error p { font-size: 16px; opacity: 0.9; margin: 0 0 32px 0; max-width: 500px; } .error-actions { display: flex; gap: 12px; flex-wrap: wrap; justify-content: center; } .narrative-empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 60px 20px; color: white; text-align: center; } .empty-icon { font-size: 64px; margin-bottom: 24px; } .narrative-empty h2 { font-size: 28px; font-weight: 700; margin: 0 0 12px 0; } .narrative-empty p { font-size: 16px; opacity: 0.9; margin: 0 0 32px 0; } .narrative-header { background: rgba(255, 255, 255, 0.95); backdrop-filter: blur(10px); border-bottom: 1px solid rgba(0, 0, 0, 0.1); padding: 16px 24px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); } .header-content { max-width: 900px; margin: 0 auto; display: flex; justify-content: space-between; align-items: center; gap: 20px; } .btn-back { display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; background: transparent; border: 1px solid #e2e8f0; border-radius: 6px; font-size: 14px; color: #4a5568; cursor: pointer; transition: all 0.2s ease; } .btn-back:hover { background: #f7fafc; border-color: #cbd5e0; } .btn-back:focus { outline: 2px solid #667eea; outline-offset: 2px; } .share-mode-badge { display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; background: #edf2f7; border-radius: 6px; font-size: 13px; font-weight: 600; color: #4a5568; } .narrative-progress { display: flex; flex-direction: column; gap: 6px; min-width: 200px; } .progress-text { font-size: 13px; color: #718096; font-weight: 500; text-align: right; } .progress-bar { height: 6px; background: #e2e8f0; border-radius: 3px; overflow: hidden; } .progress-fill { height: 100%; background: linear-gradient(90deg, #667eea 0%, #764ba2 100%); transition: width 0.3s ease; } .narrative-main { flex: 1; padding: 40px 24px; overflow-y: auto; } .narrative-content { max-width: 900px; margin: 0 auto; } .narrative-start { background: white; border-radius: 16px; padding: 60px 40px; box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15); text-align: center; } .start-content { max-width: 600px; margin: 0 auto; } .narrative-title { font-size: 36px; font-weight: 800; color: #1a202c; margin: 0 0 32px 0; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; } .narrative-meta { display: flex; flex-direction: column; gap: 12px; margin-bottom: 32px; padding: 24px; background: #f7fafc; border-radius: 12px; } .meta-item { display: flex; justify-content: space-between; align-items: center; gap: 12px; } .meta-label { font-size: 14px; color: #718096; font-weight: 500; } .meta-value { font-size: 14px; color: #1a202c; font-weight: 600; } .detail-mono { font-family: Monaco, "Courier New", monospace; font-size: 13px; } .start-description { font-size: 16px; line-height: 1.6; color: #4a5568; margin: 0 0 32px 0; } .btn-start { padding: 16px 48px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border: none; border-radius: 12px; font-size: 18px; font-weight: 700; color: white; cursor: pointer; transition: all 0.3s ease; box-shadow: 0 4px 16px rgba(102, 126, 234, 0.4); } .btn-start:hover { transform: translateY(-2px); box-shadow: 0 6px 24px rgba(102, 126, 234, 0.5); } .btn-start:focus { outline: 3px solid rgba(102, 126, 234, 0.5); outline-offset: 2px; } .passage-card { background: white; border-radius: 16px; padding: 48px 40px; box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15); animation: narrative-fade-in 0.4s ease; } @keyframes narrative-fade-in { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } } .passage-title { font-size: 32px; font-weight: 700; color: #1a202c; margin: 0 0 24px 0; } .passage-content { font-size: 18px; line-height: 1.8; color: #2d3748; margin: 0 0 32px 0; } .passage-content p { margin: 0 0 16px 0; } .passage-content p:last-child { margin-bottom: 0; } .passage-choices { display: flex; flex-direction: column; gap: 12px; margin-top: 32px; } .choice-button { padding: 16px 24px; background: white; border: 2px solid #e2e8f0; border-radius: 12px; font-size: 16px; font-weight: 600; color: #2d3748; text-align: left; cursor: pointer; transition: all 0.2s ease; position: relative; overflow: hidden; } .choice-button::before { content: ''; position: absolute; inset: 0 auto 0 0; width: 0; height: 100%; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); transition: width 0.3s ease; z-index: 0; } .choice-button:hover { border-color: #667eea; color: white; } .choice-button:hover::before { width: 100%; } .choice-button span { position: relative; z-index: 1; } .choice-button:focus { outline: 3px solid rgba(102, 126, 234, 0.5); outline-offset: 2px; } .passage-end { text-align: center; padding: 32px 0; } .end-message { font-size: 20px; font-weight: 600; color: #667eea; margin: 0 0 24px 0; } .narrative-footer { background: rgba(255, 255, 255, 0.95); backdrop-filter: blur(10px); border-top: 1px solid rgba(0, 0, 0, 0.1); padding: 16px 24px; box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1); } .footer-content { max-width: 900px; margin: 0 auto; display: flex; justify-content: space-between; align-items: center; } .btn-nav { display: inline-flex; align-items: center; gap: 8px; padding: 10px 20px; background: white; border: 1px solid #e2e8f0; border-radius: 8px; font-size: 14px; font-weight: 600; color: #4a5568; cursor: pointer; transition: all 0.2s ease; } .btn-nav:hover { background: #f7fafc; border-color: #cbd5e0; } .btn-nav:focus { outline: 2px solid #667eea; outline-offset: 2px; } .btn-primary { padding: 12px 32px; background: white; border: 2px solid white; border-radius: 8px; font-size: 16px; font-weight: 600; color: #667eea; cursor: pointer; transition: all 0.2s ease; } .btn-primary:hover { background: rgba(255, 255, 255, 0.9); transform: translateY(-1px); } .btn-secondary { padding: 12px 32px; background: transparent; border: 2px solid white; border-radius: 8px; font-size: 16px; font-weight: 600; color: white; cursor: pointer; transition: all 0.2s ease; } .btn-secondary:hover { background: rgba(255, 255, 255, 0.1); } @media (max-width: 768px) { .header-content { flex-direction: column; align-items: stretch; } .narrative-progress { min-width: 0; } .progress-text { text-align: left; } .narrative-start { padding: 40px 24px; } .narrative-title { font-size: 28px; } .passage-card { padding: 32px 24px; } .passage-title { font-size: 24px; } .passage-content { font-size: 16px; } .footer-content { flex-direction: column; gap: 12px; } .btn-nav { width: 100%; justify-content: center; } .meta-item { flex-direction: column; align-items: flex-start; } } </style>
:: billing-cancel /* * Billing Cancel Passage * * Hardened version: * - fixes passage route case issues * - safely initializes on passage display * - optionally loads current subscription details if billing helpers exist * - avoids assuming the user is always on Free * - keeps simple retry + dashboard flow */ <<script>> (function () { var PASSAGE = 'billing-cancel'; window.setup = window.setup || {}; State.variables.current_passage = PASSAGE; function playPassage(name) { if (window.SugarCube && SugarCube.Engine) { SugarCube.Engine.play(name); } else { Engine.play(name); } } if (typeof State.variables.billing_cancel_loading === 'undefined') { State.variables.billing_cancel_loading = false; } if (typeof State.variables.billing_cancel_loaded === 'undefined') { State.variables.billing_cancel_loaded = false; } if (typeof State.variables.billing_cancel_error === 'undefined') { State.variables.billing_cancel_error = null; } if (typeof State.variables.billing_cancel_plan === 'undefined') { State.variables.billing_cancel_plan = 'Free Plan'; } if (typeof State.variables.billing_cancel_projects === 'undefined') { State.variables.billing_cancel_projects = '1'; } if (typeof State.variables.billing_cancel_documents === 'undefined') { State.variables.billing_cancel_documents = '3 per project'; } if (typeof State.variables.billing_cancel_features === 'undefined') { State.variables.billing_cancel_features = 'Basic narrative generation'; } window.setup.billingCancelApplyState = function (subscription) { subscription = subscription || {}; var planName = subscription.planName || subscription.plan_name || subscription.tier || 'Free Plan'; var limits = subscription.limits || {}; var features = subscription.features || []; State.variables.billing_cancel_plan = String(planName); if (typeof limits.projects !== 'undefined' && limits.projects !== null) { State.variables.billing_cancel_projects = limits.projects === -1 ? 'Unlimited' : String(limits.projects); } else if (/team/i.test(planName)) { State.variables.billing_cancel_projects = 'Unlimited'; } else if (/pro/i.test(planName)) { State.variables.billing_cancel_projects = '10'; } else { State.variables.billing_cancel_projects = '1'; } if (typeof limits.documents !== 'undefined' && limits.documents !== null) { State.variables.billing_cancel_documents = limits.documents === -1 ? 'Unlimited' : String(limits.documents) + ' per project'; } else if (/team/i.test(planName)) { State.variables.billing_cancel_documents = 'Unlimited'; } else if (/pro/i.test(planName)) { State.variables.billing_cancel_documents = '50 per project'; } else { State.variables.billing_cancel_documents = '3 per project'; } if (Array.isArray(features) && features.length) { State.variables.billing_cancel_features = features[0]; } else if (/team/i.test(planName)) { State.variables.billing_cancel_features = 'Premium narrative generation'; } else if (/pro/i.test(planName)) { State.variables.billing_cancel_features = 'Advanced narrative generation'; } else { State.variables.billing_cancel_features = 'Basic narrative generation'; } }; window.setup.billingCancelUpdateDom = function () { if (State.passage !== PASSAGE) return; var loadingEl = document.getElementById('billing-cancel-loading'); var errorEl = document.getElementById('billing-cancel-error'); var planEl = document.getElementById('billing-cancel-plan'); var projectsEl = document.getElementById('billing-cancel-projects'); var documentsEl = document.getElementById('billing-cancel-documents'); var featuresEl = document.getElementById('billing-cancel-features'); if (loadingEl) { loadingEl.hidden = !State.variables.billing_cancel_loading; } if (errorEl) { if (State.variables.billing_cancel_error) { errorEl.textContent = State.variables.billing_cancel_error; errorEl.hidden = false; } else { errorEl.hidden = true; } } if (planEl) { planEl.textContent = State.variables.billing_cancel_plan || 'Free Plan'; } if (projectsEl) { projectsEl.textContent = State.variables.billing_cancel_projects || '1'; } if (documentsEl) { documentsEl.textContent = State.variables.billing_cancel_documents || '3 per project'; } if (featuresEl) { featuresEl.textContent = State.variables.billing_cancel_features || 'Basic narrative generation'; } }; window.setup.billingCancelLoad = async function () { if (State.variables.billing_cancel_loading) return; State.variables.billing_cancel_loading = true; State.variables.billing_cancel_error = null; window.setup.billingCancelUpdateDom(); try { if ( window.setup.billing && typeof window.setup.billing.loadSubscriptionData === 'function' ) { var subscription = await window.setup.billing.loadSubscriptionData(); if (subscription) { window.setup.billingCancelApplyState(subscription); } } State.variables.billing_cancel_loaded = true; } catch (err) { console.error('[billing-cancel] Failed to load subscription details:', err); State.variables.billing_cancel_error = (err && err.message) ? err.message : 'Unable to load current plan details.'; } finally { State.variables.billing_cancel_loading = false; window.setup.billingCancelUpdateDom(); } }; window.setup.billingCancelRetry = function () { playPassage('pricing'); }; window.setup.billingCancelContinue = function () { playPassage('Dashboard'); }; window.setTimeout(function () { if (State.passage !== PASSAGE) return; window.setup.billingCancelUpdateDom(); if (!State.variables.billing_cancel_loaded) { window.setup.billingCancelLoad(); } }, 0); })(); <</script>> <div class="billing-cancel-page"> <div class="cancel-header"> <div class="cancel-icon">⚠</div> <h1>Payment Cancelled</h1> <p>Your subscription was not activated.</p> </div> <div id="billing-cancel-loading" class="billing-cancel-loading" hidden> <div class="billing-cancel-spinner"></div> <div>Checking current plan…</div> </div> <div id="billing-cancel-error" class="billing-cancel-error" hidden></div> <div class="cancel-details"> <div class="cancel-info"> <h3>What happened?</h3> <p>Your payment was cancelled or failed to process. This can happen for several reasons:</p> <ul> <li>Payment was cancelled during checkout</li> <li>Card was declined by your bank</li> <li>Insufficient funds</li> <li>Technical issue during processing</li> </ul> </div> <div class="current-plan"> <h3>Your Current Plan</h3> <div class="plan-summary"> <div id="billing-cancel-plan" class="plan-name"><<print $billing_cancel_plan || 'Free Plan'>></div> <div class="plan-limits"> <div class="limit-item"> <span class="limit-label">Projects:</span> <span id="billing-cancel-projects" class="limit-value"><<print $billing_cancel_projects || '1'>></span> </div> <div class="limit-item"> <span class="limit-label">Documents:</span> <span id="billing-cancel-documents" class="limit-value"><<print $billing_cancel_documents || '3 per project'>></span> </div> <div class="limit-item"> <span class="limit-label">Features:</span> <span id="billing-cancel-features" class="limit-value"><<print $billing_cancel_features || 'Basic narrative generation'>></span> </div> </div> </div> </div> </div> <div class="cancel-actions"> <button class="btn btn-primary" type="button" onclick="window.setup.billingCancelRetry()"> Try Again </button> <button class="btn btn-secondary" type="button" onclick="window.setup.billingCancelContinue()"> Continue to Dashboard </button> </div> <div class="help-section"> <h3>Need Help?</h3> <p>If you're experiencing payment issues, try these solutions:</p> <ul class="help-list"> <li>Check that your card details are correct</li> <li>Ensure your card has sufficient funds</li> <li>Contact your bank if the card was declined</li> <li>Try a different payment method</li> </ul> <p>Still having trouble? <a href="mailto:support@arcengine.com">Contact our support team</a></p> </div> </div> <style> .billing-cancel-page { max-width: 800px; margin: 0 auto; padding: 2rem; text-align: center; } .cancel-header { margin-bottom: 3rem; } .cancel-icon { width: 80px; height: 80px; background: #f56565; color: white; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 2.5rem; font-weight: bold; margin: 0 auto 1.5rem; } .cancel-header h1 { font-size: 2.5rem; color: #1a202c; margin-bottom: 0.5rem; } .cancel-header p { font-size: 1.2rem; color: #718096; } .billing-cancel-loading { display: inline-flex; align-items: center; gap: 0.75rem; margin-bottom: 1.5rem; padding: 0.85rem 1rem; border-radius: 999px; background: #edf2f7; color: #4a5568; } .billing-cancel-spinner { width: 18px; height: 18px; border: 3px solid #cbd5e0; border-top-color: #4299e1; border-radius: 50%; animation: billing-cancel-spin 1s linear infinite; } @keyframes billing-cancel-spin { to { transform: rotate(360deg); } } .billing-cancel-error { max-width: 680px; margin: 0 auto 1.5rem; padding: 1rem 1.25rem; border-radius: 10px; background: #fff5f5; border: 1px solid #feb2b2; color: #9b2c2c; } .cancel-details { display: grid; grid-template-columns: 1fr 1fr; gap: 2rem; margin-bottom: 3rem; text-align: left; } .cancel-info, .current-plan { background: #f7fafc; padding: 1.5rem; border-radius: 8px; } .cancel-info h3, .current-plan h3 { color: #1a202c; margin-bottom: 1rem; font-size: 1.25rem; } .cancel-info p { color: #4a5568; line-height: 1.6; margin-bottom: 1rem; } .cancel-info ul { color: #4a5568; padding-left: 1.5rem; } .cancel-info li { margin-bottom: 0.5rem; } .plan-summary { border: 2px solid #e2e8f0; border-radius: 8px; padding: 1rem; background: white; } .plan-name { font-size: 1.25rem; font-weight: 600; color: #1a202c; margin-bottom: 1rem; } .plan-limits { display: grid; gap: 0.5rem; } .limit-item { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; } .limit-label { font-weight: 600; color: #4a5568; } .limit-value { color: #1a202c; text-align: right; } .cancel-actions { display: flex; gap: 1rem; justify-content: center; margin-bottom: 3rem; } .btn { padding: 0.75rem 1.5rem; border-radius: 8px; font-weight: 600; cursor: pointer; transition: all 0.2s ease; border: none; font-size: 1rem; } .btn-primary { background: #4299e1; color: white; } .btn-primary:hover { background: #3182ce; } .btn-secondary { background: #e2e8f0; color: #4a5568; } .btn-secondary:hover { background: #cbd5e0; } .help-section { background: #f7fafc; padding: 2rem; border-radius: 8px; text-align: left; } .help-section h3 { color: #1a202c; margin-bottom: 1rem; text-align: center; } .help-section p { color: #4a5568; line-height: 1.6; margin-bottom: 1rem; } .help-list { color: #4a5568; padding-left: 1.5rem; margin-bottom: 1rem; } .help-list li { margin-bottom: 0.5rem; } .help-section a { color: #4299e1; text-decoration: none; } .help-section a:hover { text-decoration: underline; } @media (max-width: 768px) { .cancel-details { grid-template-columns: 1fr; } .cancel-actions { flex-direction: column; } .cancel-header h1 { font-size: 2rem; } .limit-item { flex-direction: column; align-items: flex-start; } .limit-value { text-align: left; } } </style>
:: UploadScreen /* * Upload Screen Passage * * File input for document selection. * Validate file type (PDF, TXT) and size (< 50MB) client-side. * Call window.setup.requestUploadUrl to get presigned URL. * Call window.setup.uploadFile to upload to S3. * Display upload progress. * * Requirements: 4.3, 4.4, 4.9 */ <<script>> // Check authentication on entry if (!window.setup.checkAuth()) { Engine.play('Login'); } // Ensure we have a current project if (!State.variables.current_project) { Engine.play('Dashboard'); } // Clear any previous errors window.setup.clearError(); // Initialize upload state State.variables.selected_file = null; State.variables.upload_in_progress = false; State.variables.upload_progress = 0; State.variables.upload_complete = false; State.variables.validation_error = null; <</script>> <div class="upload-screen-container"> <header class="upload-header"> <div class="header-content"> <button class="btn-back" onclick="Engine.play('ProjectView')" aria-label="Back to project view" disabled="$upload_in_progress"> <svg viewBox="0 0 24 24" width="20" height="20"> <path fill="currentColor" d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/> </svg> Back to Project </button> </div> </header> <main class="upload-main"> <div class="upload-content"> <div class="upload-card"> <div class="upload-header-section"> <div class="upload-icon">📤</div> <h1 class="upload-title">Upload Documents</h1> <p class="upload-subtitle"> Upload research documents, sales materials, or company information for <strong>$current_project.name</strong> </p> </div> <<if $error_message>> <div class="error-banner" role="alert"> <div class="error-content"> <strong>Error:</strong> $error_message </div> <button onclick="window.setup.clearError()" class="btn-dismiss" aria-label="Dismiss error"> × </button> </div> <</if>> <<if $validation_error>> <div class="validation-error" role="alert"> <div class="error-content"> <strong>Validation Error:</strong> $validation_error </div> <button onclick="window.setup.clearValidationError()" class="btn-dismiss" aria-label="Dismiss error"> × </button> </div> <</if>> <<if !$upload_complete>> <div class="upload-form"> <div class="file-input-section"> <label for="file-input" class="file-input-label"> <div class="file-input-content"> <<if $selected_file>> <div class="file-selected"> <svg viewBox="0 0 24 24" width="48" height="48" class="file-icon"> <path fill="currentColor" d="M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z"/> </svg> <div class="file-info"> <div class="file-name"><<print $selected_file.name>></div> <div class="file-size"><<print window.setup.formatFileSize($selected_file.size)>></div> <div class="file-type"><<print $selected_file.type || 'Unknown type'>></div> </div> </div> <<else>> <svg viewBox="0 0 24 24" width="64" height="64" class="upload-icon-svg"> <path fill="currentColor" d="M9,16V10H5L12,3L19,10H15V16H9M5,20V18H19V20H5Z"/> </svg> <div class="file-input-text"> <strong>Click to select a file</strong> or drag and drop </div> <div class="file-input-hint"> PDF or TXT files only • Maximum 50MB </div> <</if>> </div> <input type="file" id="file-input" accept=".pdf,.txt,application/pdf,text/plain" onchange="window.setup.handleFileSelect(this)" disabled="$upload_in_progress" aria-label="Select file to upload" style="display: none;" /> </label> <<if $selected_file>> <div class="file-actions"> <button class="btn-change-file" onclick="window.setup.clearSelectedFile()" disabled="$upload_in_progress" aria-label="Change selected file"> Change File </button> </div> <</if>> </div> <<if $upload_in_progress>> <div class="upload-progress-section"> <div class="progress-header"> <span class="progress-label">Uploading...</span> <span class="progress-percentage">$upload_progress%</span> </div> <div class="progress-bar-container"> <div class="progress-bar" style="width: $upload_progress%"></div> </div> <div class="progress-hint"> Please wait while your document is being uploaded to secure storage. </div> </div> <</if>> <div class="upload-actions"> <button class="btn-upload btn-primary" onclick="window.setup.startUpload()" disabled="<<if !$selected_file || $upload_in_progress>>true<<else>>false<</if>>" aria-label="Start upload"> <<if $upload_in_progress>> <span class="btn-spinner"></span> Uploading... <<else>> Upload Document <</if>> </button> <button class="btn-cancel btn-secondary" onclick="Engine.play('ProjectView')" disabled="$upload_in_progress" aria-label="Cancel upload"> Cancel </button> </div> </div> <<else>> <div class="upload-success"> <div class="success-icon">✓</div> <h2 class="success-title">Upload Complete!</h2> <p class="success-message"> Your document has been uploaded successfully and is now being processed. </p> <div class="success-actions"> <button class="btn-view-status btn-primary" onclick="window.setup.startStatusPolling()" aria-label="View processing status"> View Processing Status </button> <button class="btn-upload-another btn-secondary" onclick="window.setup.resetUploadForm()" aria-label="Upload another document"> Upload Another Document </button> </div> </div> <</if>> <div class="upload-info"> <h3 class="info-title">Supported File Types</h3> <ul class="info-list"> <li><strong>PDF</strong> - Portable Document Format (.pdf)</li> <li><strong>TXT</strong> - Plain Text (.txt)</li> </ul> <h3 class="info-title">File Requirements</h3> <ul class="info-list"> <li>Maximum file size: <strong>50MB</strong></li> <li>Files are encrypted during upload and storage</li> <li>Processing typically takes 2-5 minutes</li> </ul> </div> </div> </div> </main> </div> <<script>> // Helper function to handle file selection window.setup.handleFileSelect = function(input) { const file = input.files[0]; if (!file) return; // Clear previous errors window.setup.clearError(); window.setup.clearValidationError(); // Validate file const validation = window.setup.validateFile(file); if (!validation.valid) { State.variables.validation_error = validation.error; State.variables.selected_file = null; input.value = ''; // Clear input Engine.play('UploadScreen'); return; } // Store selected file State.variables.selected_file = file; Engine.play('UploadScreen'); }; // Helper function to clear selected file window.setup.clearSelectedFile = function() { State.variables.selected_file = null; State.variables.validation_error = null; // Clear file input const input = document.getElementById('file-input'); if (input) { input.value = ''; } Engine.play('UploadScreen'); }; // Helper function to clear validation error window.setup.clearValidationError = function() { State.variables.validation_error = null; Engine.play('UploadScreen'); }; // Helper function to format file size window.setup.formatFileSize = function(bytes) { if (bytes === 0) return '0 Bytes'; const k = 1024; const sizes = ['Bytes', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]; }; // Helper function to start upload window.setup.startUpload = async function() { const file = State.variables.selected_file; if (!file) return; const projectId = State.variables.current_project.project_id; // Clear errors window.setup.clearError(); window.setup.clearValidationError(); // Set upload in progress State.variables.upload_in_progress = true; State.variables.upload_progress = 0; Engine.play('UploadScreen'); try { // Step 1: Request presigned URL (10% progress) State.variables.upload_progress = 10; Engine.play('UploadScreen'); const { upload_url, document_id } = await window.setup.requestUploadUrl( projectId, file.name ); // Step 2: Upload file to S3 (90% progress) State.variables.upload_progress = 30; Engine.play('UploadScreen'); await window.setup.uploadFile(upload_url, file); // Step 3: Upload complete (100% progress) State.variables.upload_progress = 100; State.variables.upload_complete = true; State.variables.upload_in_progress = false; // Update project status to uploading State.variables.current_project.status = 'uploading'; Engine.play('UploadScreen'); } catch (error) { console.error('Upload failed:', error); State.variables.upload_in_progress = false; State.variables.upload_progress = 0; window.setup.handleError(error); Engine.play('UploadScreen'); } }; // Helper function to reset upload form window.setup.resetUploadForm = function() { State.variables.selected_file = null; State.variables.upload_in_progress = false; State.variables.upload_progress = 0; State.variables.upload_complete = false; State.variables.validation_error = null; window.setup.clearError(); // Clear file input const input = document.getElementById('file-input'); if (input) { input.value = ''; } Engine.play('UploadScreen'); }; // Helper function to start status polling after upload window.setup.startStatusPolling = function() { // Navigate to project view which will show status polling Engine.play('StatusPolling'); }; <</script>> <style> .upload-screen-container { min-height: 100vh; background: #f7fafc; } .upload-header { background: white; border-bottom: 1px solid #e2e8f0; padding: 16px 24px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); } .header-content { max-width: 900px; margin: 0 auto; } .btn-back { display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; background: transparent; border: 1px solid #e2e8f0; border-radius: 6px; font-size: 14px; color: #4a5568; cursor: pointer; transition: all 0.2s ease; } .btn-back:hover:not(:disabled) { background: #f7fafc; border-color: #cbd5e0; } .btn-back:disabled { opacity: 0.5; cursor: not-allowed; } .btn-back:focus { outline: 2px solid #667eea; outline-offset: 2px; } .btn-back svg { flex-shrink: 0; } .upload-main { padding: 32px 24px; } .upload-content { max-width: 900px; margin: 0 auto; } .upload-card { background: white; border: 1px solid #e2e8f0; border-radius: 12px; padding: 40px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); } .upload-header-section { text-align: center; margin-bottom: 32px; } .upload-icon { font-size: 64px; margin-bottom: 16px; } .upload-title { font-size: 32px; font-weight: 700; color: #1a202c; margin: 0 0 12px 0; } .upload-subtitle { font-size: 16px; color: #718096; margin: 0; line-height: 1.5; } .error-banner, .validation-error { display: flex; justify-content: space-between; align-items: center; padding: 16px; background: #fed7d7; border: 1px solid #fc8181; border-radius: 8px; margin-bottom: 24px; } .error-content { font-size: 14px; color: #742a2a; } .btn-dismiss { padding: 4px 12px; background: transparent; border: none; font-size: 24px; color: #742a2a; cursor: pointer; transition: all 0.2s ease; } .btn-dismiss:hover { background: rgba(0, 0, 0, 0.05); border-radius: 4px; } .upload-form { display: flex; flex-direction: column; gap: 24px; } .file-input-section { display: flex; flex-direction: column; gap: 16px; } .file-input-label { display: block; cursor: pointer; } .file-input-content { border: 2px dashed #cbd5e0; border-radius: 12px; padding: 48px 24px; text-align: center; transition: all 0.2s ease; background: #f7fafc; } .file-input-label:hover .file-input-content { border-color: #667eea; background: #edf2f7; } .upload-icon-svg { color: #a0aec0; margin-bottom: 16px; } .file-input-text { font-size: 16px; color: #4a5568; margin-bottom: 8px; } .file-input-hint { font-size: 14px; color: #a0aec0; } .file-selected { display: flex; align-items: center; gap: 20px; text-align: left; } .file-icon { color: #667eea; flex-shrink: 0; } .file-info { flex: 1; display: flex; flex-direction: column; gap: 4px; } .file-name { font-size: 16px; font-weight: 600; color: #1a202c; word-break: break-word; } .file-size { font-size: 14px; color: #718096; } .file-type { font-size: 13px; color: #a0aec0; font-family: 'Monaco', 'Courier New', monospace; } .file-actions { display: flex; justify-content: center; } .btn-change-file { padding: 8px 16px; background: white; border: 1px solid #e2e8f0; border-radius: 6px; font-size: 14px; color: #4a5568; cursor: pointer; transition: all 0.2s ease; } .btn-change-file:hover:not(:disabled) { background: #f7fafc; border-color: #cbd5e0; } .btn-change-file:disabled { opacity: 0.5; cursor: not-allowed; } .upload-progress-section { padding: 24px; background: #edf2f7; border-radius: 8px; } .progress-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; } .progress-label { font-size: 14px; font-weight: 600; color: #4a5568; } .progress-percentage { font-size: 14px; font-weight: 700; color: #667eea; } .progress-bar-container { width: 100%; height: 8px; background: #cbd5e0; border-radius: 4px; overflow: hidden; margin-bottom: 8px; } .progress-bar { height: 100%; background: linear-gradient(90deg, #667eea 0%, #764ba2 100%); transition: width 0.3s ease; border-radius: 4px; } .progress-hint { font-size: 13px; color: #718096; text-align: center; } .upload-actions { display: flex; gap: 12px; justify-content: center; } .btn-upload, .btn-cancel { padding: 12px 32px; border: none; border-radius: 8px; font-size: 16px; font-weight: 600; cursor: pointer; transition: all 0.2s ease; display: flex; align-items: center; justify-content: center; gap: 8px; } .btn-primary { background: #667eea; color: white; } .btn-primary:hover:not(:disabled) { background: #5568d3; transform: translateY(-1px); box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3); } .btn-secondary { background: white; color: #4a5568; border: 1px solid #e2e8f0; } .btn-secondary:hover:not(:disabled) { background: #f7fafc; border-color: #cbd5e0; } .btn-upload:disabled, .btn-cancel:disabled { opacity: 0.5; cursor: not-allowed; } .btn-upload:focus, .btn-cancel:focus { outline: 2px solid #667eea; outline-offset: 2px; } .btn-spinner { width: 16px; height: 16px; border: 2px solid rgba(255, 255, 255, 0.3); border-top-color: white; border-radius: 50%; animation: spin 0.8s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } .upload-success { text-align: center; padding: 40px 20px; } .success-icon { width: 80px; height: 80px; background: #c6f6d5; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 48px; color: #22543d; margin: 0 auto 24px; } .success-title { font-size: 28px; font-weight: 700; color: #1a202c; margin: 0 0 12px 0; } .success-message { font-size: 16px; color: #718096; margin: 0 0 32px 0; line-height: 1.5; } .success-actions { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; } .btn-view-status, .btn-upload-another { padding: 12px 24px; border: none; border-radius: 8px; font-size: 16px; font-weight: 600; cursor: pointer; transition: all 0.2s ease; } .upload-info { margin-top: 40px; padding-top: 32px; border-top: 1px solid #e2e8f0; } .info-title { font-size: 18px; font-weight: 600; color: #1a202c; margin: 0 0 12px 0; } .info-list { list-style: none; padding: 0; margin: 0 0 24px 0; } .info-list li { font-size: 14px; color: #718096; padding: 8px 0; padding-left: 24px; position: relative; } .info-list li::before { content: '•'; position: absolute; left: 8px; color: #667eea; font-weight: bold; } /* Responsive design */ @media (max-width: 768px) { .upload-card { padding: 24px; } .upload-title { font-size: 24px; } .file-selected { flex-direction: column; text-align: center; } .upload-actions, .success-actions { flex-direction: column; } .btn-upload, .btn-cancel, .btn-view-status, .btn-upload-another { width: 100%; } } </style>
:: billing-success /* * Billing Success Passage * * Hardened version: * - fixes passage route case issues * - removes fragile jQuery ready dependency * - safely loads subscription details after return from Stripe * - falls back cleanly if billing helpers are not available * - avoids broken Account route by using billing portal or pricing fallback */ <<script>> (function () { var PASSAGE = 'billing-success'; window.setup = window.setup || {}; State.variables.current_passage = PASSAGE; function playPassage(name) { if (window.SugarCube && SugarCube.Engine) { SugarCube.Engine.play(name); } else { Engine.play(name); } } if (typeof State.variables.billing_success_loading === 'undefined') { State.variables.billing_success_loading = false; } if (typeof State.variables.billing_success_loaded === 'undefined') { State.variables.billing_success_loaded = false; } if (typeof State.variables.billing_success_error === 'undefined') { State.variables.billing_success_error = null; } if (typeof State.variables.billing_success_plan === 'undefined') { State.variables.billing_success_plan = 'Pro'; } if (typeof State.variables.billing_success_billing === 'undefined') { State.variables.billing_success_billing = '$29/month'; } if (typeof State.variables.billing_success_next_billing === 'undefined') { State.variables.billing_success_next_billing = 'Loading...'; } if (typeof State.variables.billing_success_status === 'undefined') { State.variables.billing_success_status = 'Active'; } if (window.setup.clearError && typeof window.setup.clearError === 'function') { window.setup.clearError(); } window.setup.billingSuccessApplyState = function (subscription) { subscription = subscription || {}; var planName = subscription.planName || subscription.plan_name || subscription.tier || 'Pro'; var amount = subscription.amount; var billingText = 'Custom'; if (typeof amount === 'number' && !isNaN(amount)) { billingText = '$' + amount + '/month'; } else if (subscription.billingText) { billingText = subscription.billingText; } else if (String(planName).toLowerCase().indexOf('team') !== -1) { billingText = '$99/month'; } else if (String(planName).toLowerCase().indexOf('free') !== -1) { billingText = '$0/month'; } else { billingText = '$29/month'; } var nextBilling = subscription.nextBillingDate || subscription.next_billing_date || subscription.currentPeriodEnd || subscription.current_period_end || null; var statusRaw = subscription.status || subscription.subscription_status || 'active'; var statusText = String(statusRaw) .replace(/_/g, ' ') .replace(/\b\w/g, function (c) { return c.toUpperCase(); }); State.variables.billing_success_plan = planName; State.variables.billing_success_billing = billingText; State.variables.billing_success_next_billing = nextBilling ? new Date(nextBilling).toLocaleDateString() : 'TBD'; State.variables.billing_success_status = statusText; }; window.setup.billingSuccessUpdateDom = function () { if (State.passage !== PASSAGE) return; var planEl = document.getElementById('subscription-plan'); var billingEl = document.getElementById('subscription-billing'); var nextBillingEl = document.getElementById('next-billing-date'); var statusEl = document.getElementById('subscription-status'); var errorEl = document.getElementById('billing-success-error'); var loadingEl = document.getElementById('billing-success-loading'); if (planEl) { planEl.textContent = State.variables.billing_success_plan || 'Pro'; } if (billingEl) { billingEl.textContent = State.variables.billing_success_billing || '$29/month'; } if (nextBillingEl) { nextBillingEl.textContent = State.variables.billing_success_next_billing || 'TBD'; } if (statusEl) { var statusText = State.variables.billing_success_status || 'Active'; statusEl.textContent = statusText; statusEl.className = 'status-pill'; if (/active|trial/i.test(statusText)) { statusEl.classList.add('status-active'); } else if (/cancel|expire|past due|inactive|unpaid/i.test(statusText)) { statusEl.classList.add('status-inactive'); } else { statusEl.classList.add('status-neutral'); } } if (errorEl) { if (State.variables.billing_success_error) { errorEl.textContent = State.variables.billing_success_error; errorEl.hidden = false; } else { errorEl.hidden = true; } } if (loadingEl) { loadingEl.hidden = !State.variables.billing_success_loading; } }; window.setup.billingSuccessLoad = async function () { if (State.variables.billing_success_loading) return; State.variables.billing_success_loading = true; State.variables.billing_success_error = null; window.setup.billingSuccessUpdateDom(); try { if ( window.setup.billing && typeof window.setup.billing.loadSubscriptionData === 'function' ) { var subscription = await window.setup.billing.loadSubscriptionData(); if (subscription) { window.setup.billingSuccessApplyState(subscription); } else { State.variables.billing_success_next_billing = 'TBD'; } } else { State.variables.billing_success_error = 'Billing details are not available yet.'; } State.variables.billing_success_loaded = true; } catch (err) { console.error('[billing-success] Failed to load subscription details:', err); State.variables.billing_success_error = (err && err.message) ? err.message : 'Unable to load subscription details.'; } finally { State.variables.billing_success_loading = false; window.setup.billingSuccessUpdateDom(); } }; window.setup.billingSuccessGoDashboard = function () { playPassage('Dashboard'); }; window.setup.billingSuccessManage = async function () { try { if ( window.setup.billing && typeof window.setup.billing.openCustomerPortal === 'function' ) { await window.setup.billing.openCustomerPortal(); return; } } catch (err) { console.error('[billing-success] Failed to open billing portal:', err); State.variables.billing_success_error = (err && err.message) ? err.message : 'Unable to open billing portal.'; window.setup.billingSuccessUpdateDom(); return; } playPassage('pricing'); }; window.setTimeout(function () { if (State.passage !== PASSAGE) return; window.setup.billingSuccessUpdateDom(); if (!State.variables.billing_success_loaded) { window.setup.billingSuccessLoad(); } }, 0); })(); <</script>> <div class="billing-success-page"> <div class="success-header"> <div class="success-icon">✓</div> <h1>Welcome to Pro!</h1> <p>Your subscription has been activated successfully.</p> </div> <div id="billing-success-loading" class="billing-success-loading" hidden> <div class="billing-success-spinner"></div> <div>Loading subscription details…</div> </div> <div id="billing-success-error" class="billing-success-error" hidden></div> <div class="success-details"> <div class="subscription-info"> <h3>Subscription Details</h3> <div class="info-grid"> <div class="info-item"> <label>Plan:</label> <span id="subscription-plan"><<print $billing_success_plan || 'Pro'>></span> </div> <div class="info-item"> <label>Billing:</label> <span id="subscription-billing"><<print $billing_success_billing || '$29/month'>></span> </div> <div class="info-item"> <label>Next billing date:</label> <span id="next-billing-date"><<print $billing_success_next_billing || 'Loading...'>></span> </div> <div class="info-item"> <label>Status:</label> <span id="subscription-status" class="status-pill status-active"><<print $billing_success_status || 'Active'>></span> </div> </div> </div> <div class="features-unlocked"> <h3>Features Now Available</h3> <ul class="feature-list"> <li>10 projects (up from 1)</li> <li>50 documents per project (up from 3)</li> <li>Advanced narrative generation</li> <li>Priority processing</li> <li>Advanced analytics</li> <li>Email support</li> </ul> </div> </div> <div class="success-actions"> <button class="btn btn-primary" type="button" onclick="window.setup.billingSuccessGoDashboard()"> Start Creating Projects </button> <button class="btn btn-secondary" type="button" onclick="window.setup.billingSuccessManage()"> Manage Subscription </button> </div> </div> <style> .billing-success-page { max-width: 800px; margin: 0 auto; padding: 2rem; text-align: center; } .success-header { margin-bottom: 3rem; } .success-icon { width: 80px; height: 80px; background: #48bb78; color: white; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 2.5rem; font-weight: bold; margin: 0 auto 1.5rem; } .success-header h1 { font-size: 2.5rem; color: #1a202c; margin-bottom: 0.5rem; } .success-header p { font-size: 1.2rem; color: #718096; } .billing-success-loading { display: inline-flex; align-items: center; gap: 0.75rem; margin-bottom: 1.5rem; padding: 0.85rem 1rem; border-radius: 999px; background: #edf2f7; color: #4a5568; } .billing-success-spinner { width: 18px; height: 18px; border: 3px solid #cbd5e0; border-top-color: #4299e1; border-radius: 50%; animation: billing-success-spin 1s linear infinite; } @keyframes billing-success-spin { to { transform: rotate(360deg); } } .billing-success-error { max-width: 680px; margin: 0 auto 1.5rem; padding: 1rem 1.25rem; border-radius: 10px; background: #fff5f5; border: 1px solid #feb2b2; color: #9b2c2c; } .success-details { display: grid; grid-template-columns: 1fr 1fr; gap: 2rem; margin-bottom: 3rem; text-align: left; } .subscription-info, .features-unlocked { background: #f7fafc; padding: 1.5rem; border-radius: 8px; } .subscription-info h3, .features-unlocked h3 { color: #1a202c; margin-bottom: 1rem; font-size: 1.25rem; } .info-grid { display: grid; gap: 0.75rem; } .info-item { display: flex; justify-content: space-between; align-items: center; gap: 1rem; } .info-item label { font-weight: 600; color: #4a5568; } .info-item span { color: #1a202c; text-align: right; } .status-pill { display: inline-flex; align-items: center; justify-content: center; min-width: 84px; padding: 0.25rem 0.75rem; border-radius: 999px; font-weight: 600; } .status-active { background: #c6f6d5; color: #22543d !important; } .status-inactive { background: #fed7d7; color: #742a2a !important; } .status-neutral { background: #e2e8f0; color: #4a5568 !important; } .feature-list { list-style: none; padding: 0; margin: 0; } .feature-list li { padding: 0.5rem 0; color: #4a5568; border-bottom: 1px solid #e2e8f0; position: relative; padding-left: 1.2rem; } .feature-list li:last-child { border-bottom: none; } .feature-list li::before { content: "✓"; position: absolute; left: 0; color: #48bb78; font-weight: 700; } .success-actions { display: flex; gap: 1rem; justify-content: center; } .success-actions .btn { padding: 0.75rem 1.5rem; border-radius: 8px; font-weight: 600; cursor: pointer; transition: all 0.2s ease; border: none; font-size: 1rem; } .success-actions .btn-primary { background: #4299e1; color: white; } .success-actions .btn-primary:hover { background: #3182ce; } .success-actions .btn-secondary { background: #e2e8f0; color: #4a5568; } .success-actions .btn-secondary:hover { background: #cbd5e0; } @media (max-width: 768px) { .success-details { grid-template-columns: 1fr; } .success-actions { flex-direction: column; } .success-header h1 { font-size: 2rem; } .info-item { flex-direction: column; align-items: flex-start; } .info-item span { text-align: left; } } </style>
:: Callback /* * OAuth Callback Passage * * Handles OAuth2 callback from Cognito Hosted UI. * Exchanges authorization code for JWT tokens. * Stores tokens in sessionStorage (not localStorage for security). * Decodes JWT to extract user_id and tenant_id. * Navigates to dashboard on success. * * Hardened: * - one-time callback processing guard * - safe redirect to Dashboard * - safe rerender on failure * - friendly error rendering * - safe retry button binding * * Requirements: 1.4, 13.7 */ <<script>> (function () { 'use strict'; var PASSAGE = 'Callback'; window.setup = window.setup || {}; State.variables.current_passage = PASSAGE; if (typeof State.variables.callback_processing === 'undefined') { State.variables.callback_processing = false; } if (typeof State.variables.callback_processed === 'undefined') { State.variables.callback_processed = false; } if (typeof State.variables.callback_success === 'undefined') { State.variables.callback_success = false; } if (typeof State.variables.callback_friendly_error === 'undefined') { State.variables.callback_friendly_error = null; } function safePlay(target) { if (!target) return; window.setTimeout(function () { if (State.passage !== PASSAGE) return; try { if (window.SugarCube && SugarCube.Engine && typeof SugarCube.Engine.play === 'function') { SugarCube.Engine.play(target); } else if (typeof Engine !== 'undefined' && typeof Engine.play === 'function') { Engine.play(target); } else { console.error('[Callback] No Engine.play available'); } } catch (err) { console.error('[Callback] safePlay failed:', err); } }, 0); } function rerenderCallback() { safePlay(PASSAGE); } function setFriendlyError() { if ( State.variables.error_code && window.setup && typeof window.setup.getUserFriendlyErrorMessage === 'function' ) { State.variables.callback_friendly_error = window.setup.getUserFriendlyErrorMessage(State.variables.error_code); } else { State.variables.callback_friendly_error = State.variables.error_message || 'Authentication failed.'; } } if (!State.variables.callback_processing && !State.variables.callback_processed) { State.variables.callback_processing = true; State.variables.callback_success = false; State.variables.callback_friendly_error = null; if (window.setup && typeof window.setup.clearError === 'function') { window.setup.clearError(); } else { State.variables.error_message = null; State.variables.error_code = null; State.variables.error_details = null; State.variables.error_timestamp = null; } (async function () { try { if (!window.setup || typeof window.setup.handleCallback !== 'function') { throw new Error('OAuth callback helper is unavailable.'); } var success = await window.setup.handleCallback(); State.variables.callback_processed = true; State.variables.callback_processing = false; State.variables.callback_success = !!success; if (success) { State.variables.callback_friendly_error = null; safePlay('Dashboard'); return; } setFriendlyError(); rerenderCallback(); } catch (error) { console.error('[Callback] handleCallback failed:', error); State.variables.callback_processed = true; State.variables.callback_processing = false; State.variables.callback_success = false; State.variables.error_message = error.message || 'Authentication failed'; State.variables.error_code = State.variables.error_code || 'AUTH_CALLBACK_ERROR'; setFriendlyError(); rerenderCallback(); } })(); } else if (!State.variables.callback_processing && !State.variables.callback_success) { setFriendlyError(); } })(); <</script>> <div class="callback-page"> <div class="callback-container"> <div class="callback-card"> <div class="callback-content"> <<if $callback_processing>> <div class="spinner" aria-hidden="true"></div> <h2>Completing sign in...</h2> <p class="status-text">Please wait while we authenticate your account.</p> <<elseif $callback_friendly_error>> <div class="error-state"> <div class="error-icon">⚠️</div> <h2>Authentication Failed</h2> <p class="status-text">We couldn’t complete your sign-in request.</p> <div class="error-banner" role="alert" aria-live="assertive"> <h3>Sign-in Error</h3> <p><<print $callback_friendly_error>></p> <div class="error-actions"> <button id="callback-retry-btn" type="button" class="btn-retry"> Try Again </button> </div> </div> </div> <<else>> <div class="spinner" aria-hidden="true"></div> <h2>Finalizing sign in...</h2> <p class="status-text">Redirecting you to your dashboard.</p> <</if>> </div> </div> </div> </div> <<script>> (function () { 'use strict'; var PASSAGE = 'Callback'; function safePlay(target) { if (!target) return; window.setTimeout(function () { if (State.passage !== PASSAGE) return; try { if (window.SugarCube && SugarCube.Engine && typeof SugarCube.Engine.play === 'function') { SugarCube.Engine.play(target); } else if (typeof Engine !== 'undefined' && typeof Engine.play === 'function') { Engine.play(target); } else { console.error('[Callback] No Engine.play available'); } } catch (err) { console.error('[Callback] safePlay failed:', err); } }, 0); } function bindCallbackPassage() { if (State.passage !== PASSAGE) return; var retryBtn = document.getElementById('callback-retry-btn'); if (retryBtn && !retryBtn.dataset.bound) { retryBtn.dataset.bound = '1'; retryBtn.onclick = function () { try { if (window.setup && typeof window.setup.clearError === 'function') { window.setup.clearError(); } else { State.variables.error_message = null; State.variables.error_code = null; State.variables.error_details = null; State.variables.error_timestamp = null; } State.variables.callback_processing = false; State.variables.callback_processed = false; State.variables.callback_success = false; State.variables.callback_friendly_error = null; safePlay('Login'); } catch (err) { console.error('[Callback] retry failed:', err); } }; } } window.setTimeout(bindCallbackPassage, 0); })(); <</script>> <style> .callback-page .callback-container { display: flex; justify-content: center; align-items: center; min-height: 100vh; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 20px; box-sizing: border-box; } .callback-page .callback-card { background: white; border-radius: 12px; box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2); max-width: 450px; width: 100%; padding: 40px; box-sizing: border-box; } .callback-page .callback-content { text-align: center; } .callback-page .spinner { width: 48px; height: 48px; border: 4px solid #e2e8f0; border-top-color: #667eea; border-radius: 50%; animation: callback-spin 1s linear infinite; margin: 0 auto 24px; } @keyframes callback-spin { to { transform: rotate(360deg); } } .callback-page .callback-content h2 { font-size: 24px; font-weight: 700; color: #1a202c; margin: 0 0 8px 0; } .callback-page .status-text { font-size: 14px; color: #718096; margin: 0; } .callback-page .error-state { text-align: center; } .callback-page .error-icon { font-size: 48px; margin-bottom: 16px; } .callback-page .error-banner { margin-top: 24px; padding: 20px; background: #fed7d7; border: 1px solid #fc8181; border-radius: 8px; text-align: left; } .callback-page .error-banner h3 { font-size: 16px; font-weight: 600; color: #742a2a; margin: 0 0 8px 0; } .callback-page .error-banner p { font-size: 14px; color: #742a2a; margin: 0 0 16px 0; } .callback-page .error-actions { display: flex; gap: 12px; } .callback-page .btn-retry { padding: 8px 16px; background: white; border: 1px solid #fc8181; border-radius: 6px; font-size: 14px; font-weight: 600; color: #742a2a; cursor: pointer; transition: all 0.2s ease; } .callback-page .btn-retry:hover { background: #fff5f5; transform: translateY(-1px); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); } .callback-page .btn-retry:active { transform: translateY(0); } .callback-page .btn-retry:focus { outline: 2px solid #667eea; outline-offset: 2px; } @media (max-width: 480px) { .callback-page .callback-card { padding: 24px; } .callback-page .callback-content h2 { font-size: 20px; } .callback-page .spinner { width: 40px; height: 40px; } } </style>
:: CreateProject /* * Create Project Passage * * Hardened version: * - safe auth redirect * - no DOMContentLoaded dependency * - form values persist across rerenders * - validation rerenders correctly * - submit button disables correctly * - subscription gate routes to pricing * - safer error handling / lower chance of duplicate bindings * * Requirements: 3.1 */ <<script>> (function () { var PASSAGE = 'CreateProject'; window.setup = window.setup || {}; State.variables.current_passage = PASSAGE; if (typeof State.variables.creating_project === 'undefined') { State.variables.creating_project = false; } if (typeof State.variables.form_project_name !== 'string') { State.variables.form_project_name = ''; } if (typeof State.variables.form_target_company !== 'string') { State.variables.form_target_company = ''; } if (!State.variables.form_errors || typeof State.variables.form_errors !== 'object') { State.variables.form_errors = {}; } function playPassage(name) { if (window.SugarCube && SugarCube.Engine) { SugarCube.Engine.play(name); } else { Engine.play(name); } } if (!window.setup.checkAuth || !window.setup.checkAuth()) { playPassage('Login'); return; } window.setup.createProjectGo = function (name) { playPassage(name); }; window.setup.createProjectDismissError = function () { if (window.setup.clearError && typeof window.setup.clearError === 'function') { window.setup.clearError(); } playPassage(PASSAGE); }; window.setup.createProjectUpdateField = function (field, value) { if (field === 'name') { State.variables.form_project_name = String(value || ''); if (State.variables.form_errors) { delete State.variables.form_errors.name; } } if (field === 'company') { State.variables.form_target_company = String(value || ''); if (State.variables.form_errors) { delete State.variables.form_errors.company; } } }; window.setup.createProjectSubmit = async function () { if (State.variables.creating_project) { return; } if (window.setup.clearError && typeof window.setup.clearError === 'function') { window.setup.clearError(); } State.variables.form_errors = {}; var projectName = String(State.variables.form_project_name || '').trim(); var targetCompany = String(State.variables.form_target_company || '').trim(); var hasErrors = false; if (!projectName) { State.variables.form_errors.name = 'Project name is required.'; hasErrors = true; } else if (projectName.length < 3) { State.variables.form_errors.name = 'Project name must be at least 3 characters.'; hasErrors = true; } else if (projectName.length > 100) { State.variables.form_errors.name = 'Project name must be 100 characters or fewer.'; hasErrors = true; } if (!targetCompany) { State.variables.form_errors.company = 'Target company is required.'; hasErrors = true; } else if (targetCompany.length < 2) { State.variables.form_errors.company = 'Company name must be at least 2 characters.'; hasErrors = true; } else if (targetCompany.length > 100) { State.variables.form_errors.company = 'Company name must be 100 characters or fewer.'; hasErrors = true; } if (hasErrors) { playPassage(PASSAGE); return; } State.variables.creating_project = true; playPassage(PASSAGE); try { if (window.setup.checkSubscription && typeof window.setup.checkSubscription === 'function') { var active = await window.setup.checkSubscription(); if (!active) { var subErr = new Error('Active subscription required to create projects.'); subErr.status = 403; subErr.code = 'SUBSCRIPTION_REQUIRED'; throw subErr; } } if (!window.setup.createProject || typeof window.setup.createProject !== 'function') { throw new Error('Project creation helper is unavailable.'); } var project = await window.setup.createProject(projectName, targetCompany); State.variables.current_project = project || null; State.variables.creating_project = false; State.variables.form_project_name = ''; State.variables.form_target_company = ''; State.variables.form_errors = {}; if (window.setup.clearError && typeof window.setup.clearError === 'function') { window.setup.clearError(); } playPassage('ProjectView'); } catch (error) { console.error('[CreateProject] Failed to create project:', error); State.variables.creating_project = false; if (error && error.status === 403) { State.variables.subscription_active = false; } if (window.setup.handleError && typeof window.setup.handleError === 'function') { window.setup.handleError(error); } else { State.variables.error_message = (error && error.message) || 'Failed to create project. Please try again.'; State.variables.error_code = (error && error.code) || 'CREATE_PROJECT_FAILED'; } playPassage(PASSAGE); } }; window.setTimeout(function () { if (State.passage !== PASSAGE) return; var form = document.getElementById('create-project-form'); var nameInput = document.getElementById('project-name'); var companyInput = document.getElementById('target-company'); if (nameInput) { nameInput.value = String(State.variables.form_project_name || ''); nameInput.oninput = function () { window.setup.createProjectUpdateField('name', this.value); }; } if (companyInput) { companyInput.value = String(State.variables.form_target_company || ''); companyInput.oninput = function () { window.setup.createProjectUpdateField('company', this.value); }; } if (form) { form.onsubmit = function (event) { event.preventDefault(); window.setup.createProjectSubmit(); }; } }, 0); })(); <</script>> <div class="create-project-container"> <div class="create-project-card"> <div class="card-header"> <button class="btn-back" type="button" onclick="window.setup.createProjectGo('Dashboard')" aria-label="Back to dashboard"> <svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"> <path fill="currentColor" d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/> </svg> Back to Dashboard </button> <h1>Create New Project</h1> <p class="subtitle">Start a new sales initiative with interactive narratives</p> </div> <<if !$subscription_active>> <div class="subscription-warning" role="alert"> <div class="warning-icon">⚠️</div> <div class="warning-content"> <h3>Active Subscription Required</h3> <p>You need an active subscription to create projects. Please subscribe to continue.</p> <button class="btn-subscribe" type="button" onclick="window.setup.createProjectGo('pricing')"> Subscribe Now </button> </div> </div> <<else>> <form id="create-project-form" class="project-form"> <div class="form-group"> <label for="project-name" class="form-label"> Project Name <span class="required">*</span> </label> <input type="text" id="project-name" class="form-input" placeholder="e.g., Q1 2026 Enterprise Deal" maxlength="100" required aria-required="true" aria-describedby="project-name-help" /> <p id="project-name-help" class="form-help"> Give your project a descriptive name to identify this sales initiative. </p> <<if $form_errors and $form_errors.name>> <p class="form-error" role="alert"> <<print window.setup.escapeHtml ? window.setup.escapeHtml($form_errors.name) : $form_errors.name>> </p> <</if>> </div> <div class="form-group"> <label for="target-company" class="form-label"> Target Company <span class="required">*</span> </label> <input type="text" id="target-company" class="form-input" placeholder="e.g., Acme Corporation" maxlength="100" required aria-required="true" aria-describedby="target-company-help" /> <p id="target-company-help" class="form-help"> Enter the name of the company you're targeting with this sales initiative. </p> <<if $form_errors and $form_errors.company>> <p class="form-error" role="alert"> <<print window.setup.escapeHtml ? window.setup.escapeHtml($form_errors.company) : $form_errors.company>> </p> <</if>> </div> <<if $error_message>> <div class="error-banner" role="alert"> <div class="error-content"> <strong>Error:</strong> <<print window.setup.escapeHtml ? window.setup.escapeHtml($error_message) : $error_message>> </div> <button type="button" onclick="window.setup.createProjectDismissError()" class="btn-dismiss" aria-label="Dismiss error"> × </button> </div> <</if>> <div class="form-actions"> <button type="button" class="btn-cancel" onclick="window.setup.createProjectGo('Dashboard')" aria-label="Cancel and return to dashboard"> Cancel </button> <button type="submit" class="btn-create" <<if $creating_project>>disabled<</if>> aria-label="Create project"> <<if $creating_project>> <span class="btn-spinner" aria-hidden="true"></span> Creating... <<else>> Create Project <</if>> </button> </div> </form> <</if>> </div> </div> <style> .create-project-container { min-height: 100vh; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 40px 20px; display: flex; justify-content: center; align-items: center; } .create-project-card { background: white; border-radius: 12px; box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2); max-width: 600px; width: 100%; padding: 40px; } .card-header { margin-bottom: 32px; } .btn-back { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; background: transparent; border: 1px solid #e2e8f0; border-radius: 6px; font-size: 14px; color: #4a5568; cursor: pointer; transition: all 0.2s ease; margin-bottom: 20px; } .btn-back:hover { background: #f7fafc; border-color: #cbd5e0; } .btn-back:focus { outline: 2px solid #667eea; outline-offset: 2px; } .btn-back svg { flex-shrink: 0; } .card-header h1 { font-size: 28px; font-weight: 700; color: #1a202c; margin: 0 0 8px 0; } .subtitle { font-size: 14px; color: #718096; margin: 0; } .subscription-warning { display: flex; gap: 16px; padding: 20px; background: #fef5e7; border: 1px solid #f9e79f; border-radius: 8px; } .warning-icon { font-size: 32px; flex-shrink: 0; } .warning-content h3 { font-size: 16px; font-weight: 600; color: #7d6608; margin: 0 0 8px 0; } .warning-content p { font-size: 14px; color: #7d6608; margin: 0 0 12px 0; } .btn-subscribe { padding: 8px 16px; background: #f39c12; border: none; border-radius: 6px; font-size: 14px; font-weight: 600; color: white; cursor: pointer; transition: all 0.2s ease; } .btn-subscribe:hover { background: #e67e22; } .project-form { display: flex; flex-direction: column; gap: 24px; } .form-group { display: flex; flex-direction: column; gap: 8px; } .form-label { font-size: 14px; font-weight: 600; color: #1a202c; } .required { color: #e53e3e; } .form-input { padding: 12px 16px; border: 1px solid #e2e8f0; border-radius: 8px; font-size: 16px; color: #1a202c; transition: all 0.2s ease; } .form-input:hover { border-color: #cbd5e0; } .form-input:focus { outline: none; border-color: #667eea; box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); } .form-input::placeholder { color: #a0aec0; } .form-help { font-size: 13px; color: #718096; margin: 0; } .form-error { font-size: 13px; color: #e53e3e; margin: 0; font-weight: 500; } .error-banner { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 16px; background: #fed7d7; border: 1px solid #fc8181; border-radius: 8px; } .error-content { font-size: 14px; color: #742a2a; } .btn-dismiss { padding: 4px 12px; background: transparent; border: none; font-size: 24px; color: #742a2a; cursor: pointer; transition: all 0.2s ease; line-height: 1; } .btn-dismiss:hover { background: rgba(0, 0, 0, 0.05); border-radius: 4px; } .form-actions { display: flex; gap: 12px; margin-top: 8px; } .btn-cancel { flex: 1; padding: 12px 24px; background: white; border: 1px solid #e2e8f0; border-radius: 8px; font-size: 16px; font-weight: 600; color: #4a5568; cursor: pointer; transition: all 0.2s ease; } .btn-cancel:hover { background: #f7fafc; border-color: #cbd5e0; } .btn-cancel:focus { outline: 2px solid #667eea; outline-offset: 2px; } .btn-create { flex: 1; padding: 12px 24px; background: #667eea; border: none; border-radius: 8px; font-size: 16px; font-weight: 600; color: white; cursor: pointer; transition: all 0.2s ease; display: inline-flex; align-items: center; justify-content: center; gap: 8px; } .btn-create:hover:not(:disabled) { background: #5568d3; transform: translateY(-1px); box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3); } .btn-create:active:not(:disabled) { transform: translateY(0); } .btn-create:focus { outline: 2px solid #667eea; outline-offset: 2px; } .btn-create:disabled { opacity: 0.6; cursor: not-allowed; } .btn-spinner { width: 16px; height: 16px; border: 2px solid rgba(255, 255, 255, 0.3); border-top-color: white; border-radius: 50%; animation: create-project-spin 0.8s linear infinite; } @keyframes create-project-spin { to { transform: rotate(360deg); } } @media (max-width: 640px) { .create-project-container { padding: 20px; } .create-project-card { padding: 24px; } .card-header h1 { font-size: 24px; } .form-actions { flex-direction: column; } .btn-cancel, .btn-create { width: 100%; } } </style>
:: Dashboard /* * Dashboard Passage * * Displays list of projects from State.variables.projects. * Implements "Create New Project" button. * Calls window.setup.loadProjects on passage entry. * * Requirements: 3.4 */ <<script>> // Load projects on passage entry (async function() { window.setup.clearError(); // Check authentication if (!window.setup.checkAuth()) { Engine.play('Login'); return; } // Load projects State.variables.loading_projects = true; try { await window.setup.loadProjects(); } catch (error) { console.error('Failed to load projects:', error); } finally { State.variables.loading_projects = false; } })(); <</script>> <div class="dashboard-container"> <header class="dashboard-header"> <div class="header-content"> <div class="header-left"> <h1>ArcEngine Sales SaaS</h1> <p class="user-info"> <<if $user_id>> Signed in as <span class="user-id">$user_id</span> <</if>> </p> </div> <div class="header-right"> <button class="btn-logout" onclick="window.setup.logout(); Engine.play('Login');" aria-label="Sign out"> Sign Out </button> </div> </div> </header> <main class="dashboard-main"> <div class="dashboard-content"> <div class="section-header"> <h2>Your Projects</h2> <button class="btn-create-project" onclick="Engine.play('CreateProject')" aria-label="Create new project"> <svg class="icon-plus" viewBox="0 0 24 24" width="20" height="20"> <path fill="currentColor" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/> </svg> Create New Project </button> </div> <<if !$subscription_active>> <div class="subscription-banner" role="alert"> <div class="banner-icon">⚠️</div> <div class="banner-content"> <h3>Subscription Required</h3> <p>You need an active subscription to create projects. Please subscribe to continue.</p> <button class="btn-subscribe" onclick="alert('Stripe checkout integration coming soon')"> Subscribe Now </button> </div> </div> <</if>> <<if $error_message>> <div class="error-banner" role="alert"> <div class="error-content"> <strong>Error:</strong> $error_message </div> <button onclick="window.setup.clearError()" class="btn-dismiss" aria-label="Dismiss error"> × </button> </div> <</if>> <<if $loading_projects>> <div class="loading-state"> <div class="spinner"></div> <p>Loading projects...</p> </div> <<elseif $projects && $projects.length > 0>> <div class="projects-grid"> <<for _i, _project range $projects>> <div class="project-card" data-project-id="_project.project_id"> <div class="project-header"> <h3 class="project-name">_project.name</h3> <span class="project-status status-_project.status"> _project.status </span> </div> <div class="project-details"> <div class="detail-row"> <span class="detail-label">Target Company:</span> <span class="detail-value">_project.target_company</span> </div> <div class="detail-row"> <span class="detail-label">Created:</span> <span class="detail-value"><<print window.setup.formatDate(_project.created_at)>></span> </div> <div class="detail-row"> <span class="detail-label">Last Updated:</span> <span class="detail-value"><<print window.setup.formatDate(_project.updated_at)>></span> </div> </div> <div class="project-actions"> <button class="btn-view-project" onclick="State.variables.current_project = State.variables.projects[_i]; Engine.play('ProjectView');" aria-label="View project _project.name"> View Project </button> </div> </div> <</for>> </div> <<else>> <div class="empty-state"> <div class="empty-icon">📁</div> <h3>No Projects Yet</h3> <p>Create your first project to get started with ArcEngine Sales SaaS.</p> <button class="btn-create-first" onclick="Engine.play('CreateProject')" aria-label="Create your first project"> Create Your First Project </button> </div> <</if>> </div> </main> </div> <style> .dashboard-container { min-height: 100vh; background: #f7fafc; } .dashboard-header { background: white; border-bottom: 1px solid #e2e8f0; padding: 16px 24px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); } .header-content { max-width: 1200px; margin: 0 auto; display: flex; justify-content: space-between; align-items: center; } .header-left h1 { font-size: 24px; font-weight: 700; color: #1a202c; margin: 0 0 4px 0; } .user-info { font-size: 14px; color: #718096; margin: 0; } .user-id { font-weight: 600; color: #4a5568; } .btn-logout { padding: 8px 16px; background: white; border: 1px solid #e2e8f0; border-radius: 6px; font-size: 14px; font-weight: 600; color: #4a5568; cursor: pointer; transition: all 0.2s ease; } .btn-logout:hover { background: #f7fafc; border-color: #cbd5e0; } .btn-logout:focus { outline: 2px solid #667eea; outline-offset: 2px; } .dashboard-main { padding: 32px 24px; } .dashboard-content { max-width: 1200px; margin: 0 auto; } .section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; } .section-header h2 { font-size: 28px; font-weight: 700; color: #1a202c; margin: 0; } .btn-create-project { display: flex; align-items: center; gap: 8px; padding: 10px 20px; background: #667eea; border: none; border-radius: 8px; font-size: 14px; font-weight: 600; color: white; cursor: pointer; transition: all 0.2s ease; } .btn-create-project:hover { background: #5568d3; transform: translateY(-1px); box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3); } .btn-create-project:active { transform: translateY(0); } .btn-create-project:focus { outline: 2px solid #667eea; outline-offset: 2px; } .icon-plus { flex-shrink: 0; } .subscription-banner { display: flex; gap: 16px; padding: 20px; background: #fef5e7; border: 1px solid #f9e79f; border-radius: 8px; margin-bottom: 24px; } .banner-icon { font-size: 32px; flex-shrink: 0; } .banner-content h3 { font-size: 16px; font-weight: 600; color: #7d6608; margin: 0 0 8px 0; } .banner-content p { font-size: 14px; color: #7d6608; margin: 0 0 12px 0; } .btn-subscribe { padding: 8px 16px; background: #f39c12; border: none; border-radius: 6px; font-size: 14px; font-weight: 600; color: white; cursor: pointer; transition: all 0.2s ease; } .btn-subscribe:hover { background: #e67e22; } .error-banner { display: flex; justify-content: space-between; align-items: center; padding: 16px; background: #fed7d7; border: 1px solid #fc8181; border-radius: 8px; margin-bottom: 24px; } .error-content { font-size: 14px; color: #742a2a; } .btn-dismiss { padding: 4px 12px; background: transparent; border: none; font-size: 24px; color: #742a2a; cursor: pointer; transition: all 0.2s ease; } .btn-dismiss:hover { background: rgba(0, 0, 0, 0.05); border-radius: 4px; } .loading-state { text-align: center; padding: 60px 20px; } .spinner { width: 48px; height: 48px; border: 4px solid #e2e8f0; border-top-color: #667eea; border-radius: 50%; animation: spin 1s linear infinite; margin: 0 auto 16px; } @keyframes spin { to { transform: rotate(360deg); } } .loading-state p { font-size: 16px; color: #718096; margin: 0; } .projects-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 24px; } .project-card { background: white; border: 1px solid #e2e8f0; border-radius: 12px; padding: 24px; transition: all 0.2s ease; } .project-card:hover { border-color: #cbd5e0; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); transform: translateY(-2px); } .project-header { display: flex; justify-content: space-between; align-items: start; margin-bottom: 16px; gap: 12px; } .project-name { font-size: 18px; font-weight: 600; color: #1a202c; margin: 0; word-break: break-word; } .project-status { padding: 4px 12px; border-radius: 12px; font-size: 12px; font-weight: 600; text-transform: uppercase; white-space: nowrap; flex-shrink: 0; } .status-idle { background: #e2e8f0; color: #4a5568; } .status-uploading { background: #bee3f8; color: #2c5282; } .status-extracting { background: #feebc8; color: #7c2d12; } .status-generating { background: #d6bcfa; color: #44337a; } .status-ready { background: #c6f6d5; color: #22543d; } .status-failed { background: #fed7d7; color: #742a2a; } .project-details { margin-bottom: 20px; } .detail-row { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #f7fafc; } .detail-row:last-child { border-bottom: none; } .detail-label { font-size: 13px; color: #718096; font-weight: 500; } .detail-value { font-size: 13px; color: #1a202c; font-weight: 600; text-align: right; } .project-actions { display: flex; gap: 8px; } .btn-view-project { flex: 1; padding: 10px 16px; background: #667eea; border: none; border-radius: 6px; font-size: 14px; font-weight: 600; color: white; cursor: pointer; transition: all 0.2s ease; } .btn-view-project:hover { background: #5568d3; } .btn-view-project:focus { outline: 2px solid #667eea; outline-offset: 2px; } .empty-state { text-align: center; padding: 80px 20px; } .empty-icon { font-size: 64px; margin-bottom: 16px; } .empty-state h3 { font-size: 24px; font-weight: 600; color: #1a202c; margin: 0 0 8px 0; } .empty-state p { font-size: 16px; color: #718096; margin: 0 0 24px 0; } .btn-create-first { padding: 12px 24px; background: #667eea; border: none; border-radius: 8px; font-size: 16px; font-weight: 600; color: white; cursor: pointer; transition: all 0.2s ease; } .btn-create-first:hover { background: #5568d3; transform: translateY(-1px); box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3); } .btn-create-first:focus { outline: 2px solid #667eea; outline-offset: 2px; } /* Responsive design */ @media (max-width: 768px) { .dashboard-header { padding: 12px 16px; } .header-content { flex-direction: column; align-items: flex-start; gap: 12px; } .header-left h1 { font-size: 20px; } .section-header { flex-direction: column; align-items: flex-start; gap: 16px; } .section-header h2 { font-size: 24px; } .btn-create-project { width: 100%; justify-content: center; } .projects-grid { grid-template-columns: 1fr; } } </style>
:: ErrorBanner [widget] <<widget "errorBanner">> <<if $error_message>> <<set _userFriendlyMessage = window.setup.getUserFriendlyErrorMessage($error_code)>> <<set _isRetryable = window.setup.isRetryableError($error_code)>> <div class="error-banner" role="alert" aria-live="assertive"> <div class="error-banner-content"> <span class="error-banner-icon">⚠️</span> <div class="error-banner-message"> <strong>Error:</strong> _userFriendlyMessage <<if $error_details>> <details class="error-banner-details"> <summary>Details</summary> <pre><<print JSON.stringify($error_details, null, 2)>></pre> </details> <</if>> </div> <button class="error-banner-close" onclick="window.setup.clearError(); Engine.play(passage());" aria-label="Close error"> × </button> </div> <<if _isRetryable>> <div class="error-banner-actions"> <button class="error-banner-retry" onclick="window.setup.clearError(); Engine.backward();"> Try Again </button> </div> <</if>> </div> <style> .error-banner { background: #ffebee; border: 1px solid #ef5350; border-radius: 4px; padding: 1rem; margin-bottom: 1rem; animation: slideDown 0.3s ease-out; } @keyframes slideDown { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } } .error-banner-content { display: flex; align-items: flex-start; gap: 0.75rem; } .error-banner-icon { font-size: 1.5rem; flex-shrink: 0; } .error-banner-message { flex: 1; color: #c62828; } .error-banner-message strong { display: block; margin-bottom: 0.25rem; } .error-banner-details { margin-top: 0.5rem; font-size: 0.9rem; } .error-banner-details summary { cursor: pointer; color: #d32f2f; } .error-banner-details pre { margin-top: 0.5rem; padding: 0.5rem; background: #fff; border-radius: 2px; font-size: 0.85rem; overflow-x: auto; } .error-banner-close { background: none; border: none; font-size: 1.5rem; color: #c62828; cursor: pointer; padding: 0; width: 24px; height: 24px; flex-shrink: 0; line-height: 1; } .error-banner-close:hover { color: #b71c1c; } .error-banner-actions { margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px solid #ef9a9a; } .error-banner-retry { background: #d32f2f; color: white; border: none; padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; font-size: 0.9rem; } .error-banner-retry:hover { background: #c62828; } </style> <</if>> <</widget>>
:: ErrorDisplay <<set _userFriendlyMessage = window.setup.getUserFriendlyErrorMessage($error_code)>> <<set _isRetryable = window.setup.isRetryableError($error_code)>> <div class="error-container"> <div class="error-icon">⚠️</div> <h2 class="error-title">Something Went Wrong</h2> <div class="error-message"> <p class="error-message-text">_userFriendlyMessage</p> <<if $error_details>> <details class="error-details"> <summary>Technical Details</summary> <pre><<print JSON.stringify($error_details, null, 2)>></pre> </details> <</if>> </div> <div class="error-actions"> <<if $error_code === 'SUBSCRIPTION_REQUIRED'>> <button class="btn btn-primary" onclick="window.location.href='/subscribe'"> Subscribe Now </button> <button class="btn btn-secondary" onclick="window.setup.clearError(); Engine.play('Dashboard');"> Back to Dashboard </button> <<elseif $error_code === 'AUTH_INVALID_TOKEN' || $error_code === 'AUTH_NO_CODE' || $error_code === 'AUTH_TOKEN_EXCHANGE_FAILED'>> <button class="btn btn-primary" onclick="window.setup.initiateLogin();"> Log In Again </button> <<elseif $error_code === 'PROJECT_NOT_FOUND'>> <button class="btn btn-primary" onclick="window.setup.clearError(); Engine.play('Dashboard');"> Back to Dashboard </button> <<elseif _isRetryable>> <button class="btn btn-primary" onclick="window.setup.clearError(); Engine.backward();"> Try Again </button> <button class="btn btn-secondary" onclick="window.setup.clearError(); Engine.play('Dashboard');"> Back to Dashboard </button> <<else>> <button class="btn btn-primary" onclick="window.setup.clearError(); Engine.play('Dashboard');"> Back to Dashboard </button> <</if>> </div> <<if $error_code && $error_timestamp>> <div class="error-metadata"> <small>Error Code: $error_code</small> <small>Time: <<print new Date($error_timestamp).toLocaleString()>></small> </div> <</if>> </div> <style> .error-container { max-width: 600px; margin: 2rem auto; padding: 2rem; background: #fff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); text-align: center; } .error-icon { font-size: 4rem; margin-bottom: 1rem; } .error-title { color: #d32f2f; margin-bottom: 1rem; font-size: 1.5rem; } .error-message { margin-bottom: 2rem; } .error-message-text { color: #333; font-size: 1.1rem; line-height: 1.6; } .error-details { margin-top: 1rem; text-align: left; background: #f5f5f5; padding: 1rem; border-radius: 4px; } .error-details summary { cursor: pointer; font-weight: bold; color: #666; } .error-details pre { margin-top: 0.5rem; font-size: 0.9rem; color: #333; overflow-x: auto; } .error-actions { display: flex; gap: 1rem; justify-content: center; flex-wrap: wrap; } .btn { padding: 0.75rem 1.5rem; border: none; border-radius: 4px; font-size: 1rem; cursor: pointer; transition: all 0.2s; } .btn-primary { background: #1976d2; color: white; } .btn-primary:hover { background: #1565c0; } .btn-secondary { background: #757575; color: white; } .btn-secondary:hover { background: #616161; } .error-metadata { margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #e0e0e0; display: flex; justify-content: space-between; color: #999; font-size: 0.85rem; } @media (max-width: 600px) { .error-container { margin: 1rem; padding: 1.5rem; } .error-actions { flex-direction: column; } .btn { width: 100%; } .error-metadata { flex-direction: column; gap: 0.5rem; } } </style>
:: Login /* * Login Passage * * Displays login screen with "Sign in with Google" button. * Redirects to Cognito Hosted UI for OAuth2 authentication. * * Hardened: * - safe auth redirect if already signed in * - friendly error rendering * - safe button binding instead of fragile inline handlers * - duplicate-click protection * * Requirements: 1.1, 1.2 */ <<script>> (function () { 'use strict'; var PASSAGE = 'Login'; State.variables.current_passage = PASSAGE; function safePlay(target) { if (!target) return; window.setTimeout(function () { if (State.passage !== PASSAGE) return; try { if (window.SugarCube && SugarCube.Engine && typeof SugarCube.Engine.play === 'function') { SugarCube.Engine.play(target); } else if (typeof Engine !== 'undefined' && typeof Engine.play === 'function') { Engine.play(target); } else { console.error('[Login] No Engine.play available'); } } catch (err) { console.error('[Login] safePlay failed:', err); } }, 0); } try { if ( !State.variables.share_mode && window.setup && typeof window.setup.checkAuth === 'function' && window.setup.checkAuth() ) { safePlay('Dashboard'); return; } } catch (err) { console.warn('[Login] auth check failed', err); } if ( State.variables.error_code && window.setup && typeof window.setup.getUserFriendlyErrorMessage === 'function' ) { State.variables.login_error_friendly = window.setup.getUserFriendlyErrorMessage(State.variables.error_code); } else { State.variables.login_error_friendly = State.variables.error_message || null; } })(); <</script>> <div class="login-page"> <div class="login-container"> <div class="login-card"> <div class="login-header"> <h1>ArcEngine Sales SaaS</h1> <p class="tagline">Transform your sales documents into interactive narratives</p> </div> <div class="login-content"> <p class="welcome-text">Sign in to get started</p> <button id="google-login-btn" class="btn-google-login" type="button" aria-label="Sign in with Google"> <svg class="google-icon" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true"> <path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/> <path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/> <path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/> <path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/> </svg> <span>Sign in with Google</span> </button> <<if $login_error_friendly>> <div id="login-error-banner" class="error-banner" role="alert" aria-live="polite"> <p><<print $login_error_friendly>></p> <button id="login-error-dismiss" type="button" class="btn-dismiss">Dismiss</button> </div> <</if>> </div> <div class="login-footer"> <p class="help-text"> By signing in, you agree to our Terms of Service and Privacy Policy. </p> </div> </div> </div> </div> <<script>> (function () { 'use strict'; var PASSAGE = 'Login'; function rerenderLogin() { window.setTimeout(function () { if (State.passage !== PASSAGE) return; try { if (window.SugarCube && SugarCube.Engine && typeof SugarCube.Engine.play === 'function') { SugarCube.Engine.play(PASSAGE); } else if (typeof Engine !== 'undefined' && typeof Engine.play === 'function') { Engine.play(PASSAGE); } else { console.error('[Login] No Engine.play available for rerender'); } } catch (err) { console.error('[Login] rerender failed', err); } }, 0); } function bindLoginPassage() { if (State.passage !== PASSAGE) return; var loginBtn = document.getElementById('google-login-btn'); var dismissBtn = document.getElementById('login-error-dismiss'); if (loginBtn && !loginBtn.dataset.bound) { loginBtn.dataset.bound = '1'; loginBtn.onclick = function () { if (loginBtn.disabled) return; loginBtn.disabled = true; loginBtn.classList.add('is-loading'); try { if (window.setup && typeof window.setup.initiateLogin === 'function') { window.setup.initiateLogin(); return; } throw new Error('Login helper is unavailable.'); } catch (err) { console.error('[Login] initiateLogin failed', err); State.variables.error_code = 'AUTH_LOGIN_INIT_FAILED'; State.variables.error_message = err.message || 'Unable to start login.'; State.variables.login_error_friendly = (window.setup && typeof window.setup.getUserFriendlyErrorMessage === 'function') ? window.setup.getUserFriendlyErrorMessage(State.variables.error_code) : State.variables.error_message; loginBtn.disabled = false; loginBtn.classList.remove('is-loading'); rerenderLogin(); } }; } if (dismissBtn && !dismissBtn.dataset.bound) { dismissBtn.dataset.bound = '1'; dismissBtn.onclick = function () { try { if (window.setup && typeof window.setup.clearError === 'function') { window.setup.clearError(); } else { State.variables.error_message = null; State.variables.error_code = null; State.variables.error_details = null; State.variables.error_timestamp = null; } State.variables.login_error_friendly = null; rerenderLogin(); } catch (err) { console.warn('[Login] dismiss error failed', err); } }; } } window.setTimeout(bindLoginPassage, 0); })(); <</script>> <style> .login-page .login-container { display: flex; justify-content: center; align-items: center; min-height: 100vh; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 20px; box-sizing: border-box; } .login-page .login-card { background: white; border-radius: 12px; box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2); max-width: 450px; width: 100%; padding: 40px; box-sizing: border-box; } .login-page .login-header { text-align: center; margin-bottom: 32px; } .login-page .login-header h1 { font-size: 28px; font-weight: 700; color: #1a202c; margin: 0 0 8px 0; } .login-page .tagline { font-size: 14px; color: #718096; margin: 0; } .login-page .login-content { margin-bottom: 24px; } .login-page .welcome-text { text-align: center; font-size: 16px; color: #4a5568; margin: 0 0 24px 0; } .login-page .btn-google-login { display: flex; align-items: center; justify-content: center; width: 100%; padding: 12px 24px; background: white; border: 2px solid #e2e8f0; border-radius: 8px; font-size: 16px; font-weight: 600; color: #1a202c; cursor: pointer; transition: all 0.2s ease; gap: 12px; } .login-page .btn-google-login:hover { background: #f7fafc; border-color: #cbd5e0; transform: translateY(-1px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); } .login-page .btn-google-login:active { transform: translateY(0); } .login-page .btn-google-login:focus { outline: 2px solid #667eea; outline-offset: 2px; } .login-page .btn-google-login:disabled { opacity: 0.7; cursor: wait; } .login-page .btn-google-login.is-loading { position: relative; } .login-page .google-icon { flex-shrink: 0; } .login-page .error-banner { margin-top: 20px; padding: 12px 16px; background: #fed7d7; border: 1px solid #fc8181; border-radius: 8px; color: #742a2a; } .login-page .error-banner p { margin: 0 0 8px 0; font-size: 14px; } .login-page .btn-dismiss { padding: 6px 12px; background: white; border: 1px solid #fc8181; border-radius: 4px; font-size: 12px; color: #742a2a; cursor: pointer; transition: all 0.2s ease; } .login-page .btn-dismiss:hover { background: #fff5f5; } .login-page .login-footer { text-align: center; padding-top: 24px; border-top: 1px solid #e2e8f0; } .login-page .help-text { font-size: 12px; color: #a0aec0; margin: 0; line-height: 1.5; } @media (max-width: 480px) { .login-page .login-card { padding: 24px; } .login-page .login-header h1 { font-size: 24px; } .login-page .btn-google-login { font-size: 14px; padding: 10px 20px; } } </style>
:: pricing <div class="pricing-page"> <div class="pricing-header"> <h1>Choose Your Plan</h1> <p>Transform your sales documents into compelling narratives</p> </div> <div class="pricing-plans"> <!-- Free Plan --> <div class="plan-card free-plan"> <div class="plan-header"> <h3>Free</h3> <div class="plan-price"> <span class="price">$0</span> <span class="period">/month</span> </div> </div> <div class="plan-features"> <ul> <li>✓ 1 project</li> <li>✓ 3 documents per project</li> <li>✓ Basic narrative generation</li> <li>✓ Share links</li> <li>✓ Community support</li> </ul> </div> <div class="plan-action"> <button class="btn btn-outline" onclick="window.setup.billing.selectFreePlan()"> Current Plan </button> </div> </div> <!-- Pro Plan --> <div class="plan-card pro-plan popular"> <div class="plan-badge">Most Popular</div> <div class="plan-header"> <h3>Pro</h3> <div class="plan-price"> <span class="price">$29</span> <span class="period">/month</span> </div> </div> <div class="plan-features"> <ul> <li>✓ 10 projects</li> <li>✓ 50 documents per project</li> <li>✓ Advanced narrative generation</li> <li>✓ Priority processing</li> <li>✓ Advanced analytics</li> <li>✓ Email support</li> <li>✓ 14-day free trial</li> </ul> </div> <div class="plan-action"> <button class="btn btn-primary" onclick="window.setup.billing.selectPlan('pro')"> Start Free Trial </button> </div> </div> <!-- Team Plan --> <div class="plan-card team-plan"> <div class="plan-header"> <h3>Team</h3> <div class="plan-price"> <span class="price">$99</span> <span class="period">/month</span> </div> </div> <div class="plan-features"> <ul> <li>✓ Unlimited projects</li> <li>✓ Unlimited documents</li> <li>✓ Premium narrative generation</li> <li>✓ Team collaboration</li> <li>✓ Custom branding</li> <li>✓ API access</li> <li>✓ Priority support</li> <li>✓ 14-day free trial</li> </ul> </div> <div class="plan-action"> <button class="btn btn-primary" onclick="window.setup.billing.selectPlan('team')"> Start Free Trial </button> </div> </div> </div> <div class="pricing-faq"> <h2>Frequently Asked Questions</h2> <div class="faq-item"> <h4>Can I change plans anytime?</h4> <p>Yes, you can upgrade or downgrade your plan at any time. Changes take effect immediately, and we'll prorate any charges.</p> </div> <div class="faq-item"> <h4>What happens to my data if I cancel?</h4> <p>Your data remains accessible for 30 days after cancellation. You can export your projects and narratives during this period.</p> </div> <div class="faq-item"> <h4>Do you offer refunds?</h4> <p>We offer a 30-day money-back guarantee for all paid plans. Contact support for assistance.</p> </div> </div> <div class="pricing-actions"> <button class="btn btn-secondary" onclick="Engine.play('dashboard')"> ← Back to Dashboard </button> </div> </div> <style> .pricing-page { max-width: 1200px; margin: 0 auto; padding: 2rem; } .pricing-header { text-align: center; margin-bottom: 3rem; } .pricing-header h1 { font-size: 2.5rem; margin-bottom: 1rem; color: #1a202c; } .pricing-header p { font-size: 1.2rem; color: #718096; } .pricing-plans { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 2rem; margin-bottom: 3rem; } .plan-card { background: white; border: 2px solid #e2e8f0; border-radius: 12px; padding: 2rem; position: relative; transition: all 0.3s ease; } .plan-card:hover { border-color: #4299e1; transform: translateY(-4px); box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1); } .plan-card.popular { border-color: #4299e1; transform: scale(1.05); } .plan-badge { position: absolute; top: -12px; left: 50%; transform: translateX(-50%); background: #4299e1; color: white; padding: 0.5rem 1rem; border-radius: 20px; font-size: 0.875rem; font-weight: 600; } .plan-header h3 { font-size: 1.5rem; margin-bottom: 1rem; color: #1a202c; } .plan-price { margin-bottom: 2rem; } .plan-price .price { font-size: 3rem; font-weight: 700; color: #1a202c; } .plan-price .period { font-size: 1rem; color: #718096; } .plan-features ul { list-style: none; padding: 0; margin-bottom: 2rem; } .plan-features li { padding: 0.5rem 0; color: #4a5568; font-size: 0.95rem; } .plan-features li:before { content: "✓"; color: #48bb78; font-weight: bold; margin-right: 0.5rem; } .btn { width: 100%; padding: 0.75rem 1.5rem; border-radius: 8px; font-weight: 600; text-align: center; cursor: pointer; transition: all 0.2s ease; border: none; font-size: 1rem; } .btn-primary { background: #4299e1; color: white; } .btn-primary:hover { background: #3182ce; } .btn-outline { background: transparent; color: #4299e1; border: 2px solid #4299e1; } .btn-outline:hover { background: #4299e1; color: white; } .btn-secondary { background: #e2e8f0; color: #4a5568; } .btn-secondary:hover { background: #cbd5e0; } .pricing-faq { margin-bottom: 3rem; } .pricing-faq h2 { text-align: center; margin-bottom: 2rem; color: #1a202c; } .faq-item { margin-bottom: 1.5rem; } .faq-item h4 { color: #1a202c; margin-bottom: 0.5rem; } .faq-item p { color: #718096; line-height: 1.6; } .pricing-actions { text-align: center; } @media (max-width: 768px) { .pricing-plans { grid-template-columns: 1fr; } .plan-card.popular { transform: none; } .pricing-header h1 { font-size: 2rem; } } </style>
:: ProjectView /* * Project View Passage * * Displays project details (name, company, status). * Shows upload button. * Shows narrative view button (if status is "ready"). * Shows share link button. * * Requirements: 3.5 */ <<script>> // Check authentication on entry if (!window.setup.checkAuth()) { Engine.play('Login'); } // Ensure we have a current project if (!State.variables.current_project) { Engine.play('Dashboard'); } // Clear any previous errors window.setup.clearError(); // Initialize state State.variables.loading_project = false; State.variables.generating_share_link = false; State.variables.share_link_generated = null; State.variables.share_link_copied = false; <</script>> <div class="project-view-container"> <header class="project-header"> <div class="header-content"> <button class="btn-back" onclick="Engine.play('Dashboard')" aria-label="Back to dashboard"> <svg viewBox="0 0 24 24" width="20" height="20"> <path fill="currentColor" d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/> </svg> Back to Dashboard </button> </div> </header> <main class="project-main"> <div class="project-content"> <<if $current_project>> <div class="project-info-card"> <div class="project-title-section"> <h1 class="project-title">$current_project.name</h1> <span class="project-status-badge status-$current_project.status"> $current_project.status </span> </div> <div class="project-details-grid"> <div class="detail-item"> <span class="detail-label">Target Company</span> <span class="detail-value">$current_project.target_company</span> </div> <div class="detail-item"> <span class="detail-label">Project ID</span> <span class="detail-value detail-mono">$current_project.project_id</span> </div> <div class="detail-item"> <span class="detail-label">Created</span> <span class="detail-value"><<print window.setup.formatDate($current_project.created_at)>></span> </div> <div class="detail-item"> <span class="detail-label">Last Updated</span> <span class="detail-value"><<print window.setup.formatDate($current_project.updated_at)>></span> </div> <<if $current_project.current_version>> <div class="detail-item"> <span class="detail-label">Current Version</span> <span class="detail-value detail-mono">$current_project.current_version</span> </div> <</if>> </div> </div> <<if $error_message>> <div class="error-banner" role="alert"> <div class="error-content"> <strong>Error:</strong> $error_message </div> <button onclick="window.setup.clearError()" class="btn-dismiss" aria-label="Dismiss error"> × </button> </div> <</if>> <div class="actions-section"> <h2 class="section-title">Actions</h2> <div class="actions-grid"> <!-- Upload Document Action --> <div class="action-card"> <div class="action-icon">📄</div> <div class="action-content"> <h3 class="action-title">Upload Documents</h3> <p class="action-description"> Upload research documents, sales materials, or company information to enhance your narrative. </p> <button class="btn-action btn-primary" onclick="Engine.play('UploadScreen')" aria-label="Upload documents"> Upload Documents </button> </div> </div> <!-- View Narrative Action --> <<if $current_project.status === 'ready'>> <div class="action-card"> <div class="action-icon">📖</div> <div class="action-content"> <h3 class="action-title">View Narrative</h3> <p class="action-description"> Experience your interactive sales narrative. Navigate through the story and explore different paths. </p> <button class="btn-action btn-primary" onclick="Engine.play('NarrativeView')" aria-label="View narrative"> View Narrative </button> </div> </div> <<else>> <div class="action-card action-disabled"> <div class="action-icon">📖</div> <div class="action-content"> <h3 class="action-title">View Narrative</h3> <p class="action-description"> <<if $current_project.status === 'idle'>> Upload documents to generate your narrative. <<elseif $current_project.status === 'uploading'>> Document upload in progress... <<elseif $current_project.status === 'extracting'>> Extracting text from documents... <<elseif $current_project.status === 'generating'>> Generating your narrative... <<elseif $current_project.status === 'failed'>> Processing failed. Please try uploading again. <<else>> Processing your documents... <</if>> </p> <button class="btn-action btn-disabled" disabled aria-label="View narrative (not available)"> Not Available </button> </div> </div> <</if>> <!-- Generate Share Link Action --> <div class="action-card"> <div class="action-icon">🔗</div> <div class="action-content"> <h3 class="action-title">Share Link</h3> <p class="action-description"> Generate a shareable link to send your interactive narrative to prospects. </p> <<if $share_link_generated>> <div class="share-link-result"> <input type="text" class="share-link-input" value="$share_link_generated" readonly id="share-link-input" aria-label="Generated share link" /> <button class="btn-copy-link" onclick="window.setup.copyShareLink()" aria-label="Copy share link"> <<if $share_link_copied>> ✓ Copied <<else>> Copy <</if>> </button> </div> <<else>> <button class="btn-action btn-secondary" onclick="window.setup.generateAndDisplayShareLink()" disabled="$generating_share_link" aria-label="Generate share link"> <<if $generating_share_link>> <span class="btn-spinner"></span> Generating... <<else>> Generate Share Link <</if>> </button> <</if>> </div> </div> <!-- Refresh Status Action --> <div class="action-card"> <div class="action-icon">🔄</div> <div class="action-content"> <h3 class="action-title">Refresh Status</h3> <p class="action-description"> Check the current processing status of your project and update the display. </p> <button class="btn-action btn-secondary" onclick="window.setup.refreshProjectStatus()" disabled="$loading_project" aria-label="Refresh project status"> <<if $loading_project>> <span class="btn-spinner"></span> Refreshing... <<else>> Refresh Status <</if>> </button> </div> </div> </div> </div> <!-- Status Timeline (if processing) --> <<if $current_project.status !== 'idle' && $current_project.status !== 'ready' && $current_project.status !== 'failed'>> <div class="status-timeline"> <h2 class="section-title">Processing Status</h2> <div class="timeline"> <div class="timeline-item <<if $current_project.status === 'uploading' || $current_project.status === 'extracting' || $current_project.status === 'generating'>>active<<else>>complete<</if>>"> <div class="timeline-marker"></div> <div class="timeline-content"> <h4>Uploading</h4> <p>Document upload to secure storage</p> </div> </div> <div class="timeline-item <<if $current_project.status === 'extracting' || $current_project.status === 'generating'>>active<<elseif $current_project.status === 'uploading'>>pending<<else>>complete<</if>>"> <div class="timeline-marker"></div> <div class="timeline-content"> <h4>Extracting</h4> <p>Text extraction and analysis</p> </div> </div> <div class="timeline-item <<if $current_project.status === 'generating'>>active<<elseif $current_project.status === 'uploading' || $current_project.status === 'extracting'>>pending<<else>>complete<</if>>"> <div class="timeline-marker"></div> <div class="timeline-content"> <h4>Generating</h4> <p>Creating interactive narrative</p> </div> </div> </div> </div> <</if>> <<else>> <div class="empty-state"> <p>No project selected. Returning to dashboard...</p> </div> <</if>> </div> </main> </div> <<script>> // Helper function to refresh project status window.setup.refreshProjectStatus = async function() { State.variables.loading_project = true; window.setup.clearError(); Engine.play('ProjectView'); try { const projectId = State.variables.current_project.project_id; const updatedProject = await window.setup.getProject(projectId); State.variables.current_project = updatedProject; } catch (error) { console.error('Failed to refresh project:', error); window.setup.handleError(error); } finally { State.variables.loading_project = false; Engine.play('ProjectView'); } }; // Helper function to generate and display share link window.setup.generateAndDisplayShareLink = async function() { State.variables.generating_share_link = true; State.variables.share_link_copied = false; window.setup.clearError(); Engine.play('ProjectView'); try { const projectId = State.variables.current_project.project_id; const shareUrl = await window.setup.generateShareLink(projectId); State.variables.share_link_generated = shareUrl; } catch (error) { console.error('Failed to generate share link:', error); window.setup.handleError(error); } finally { State.variables.generating_share_link = false; Engine.play('ProjectView'); } }; // Helper function to copy share link to clipboard window.setup.copyShareLink = async function() { const shareLink = State.variables.share_link_generated; if (!shareLink) return; const success = await window.setup.copyToClipboard(shareLink); if (success) { State.variables.share_link_copied = true; Engine.play('ProjectView'); // Reset copied state after 3 seconds setTimeout(() => { State.variables.share_link_copied = false; Engine.play('ProjectView'); }, 3000); } }; <</script>> <style> .project-view-container { min-height: 100vh; background: #f7fafc; } .project-header { background: white; border-bottom: 1px solid #e2e8f0; padding: 16px 24px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); } .header-content { max-width: 1200px; margin: 0 auto; } .btn-back { display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; background: transparent; border: 1px solid #e2e8f0; border-radius: 6px; font-size: 14px; color: #4a5568; cursor: pointer; transition: all 0.2s ease; } .btn-back:hover { background: #f7fafc; border-color: #cbd5e0; } .btn-back:focus { outline: 2px solid #667eea; outline-offset: 2px; } .btn-back svg { flex-shrink: 0; } .project-main { padding: 32px 24px; } .project-content { max-width: 1200px; margin: 0 auto; display: flex; flex-direction: column; gap: 32px; } .project-info-card { background: white; border: 1px solid #e2e8f0; border-radius: 12px; padding: 32px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); } .project-title-section { display: flex; justify-content: space-between; align-items: start; gap: 16px; margin-bottom: 24px; padding-bottom: 24px; border-bottom: 1px solid #e2e8f0; } .project-title { font-size: 32px; font-weight: 700; color: #1a202c; margin: 0; word-break: break-word; } .project-status-badge { padding: 6px 16px; border-radius: 16px; font-size: 13px; font-weight: 600; text-transform: uppercase; white-space: nowrap; flex-shrink: 0; } .status-idle { background: #e2e8f0; color: #4a5568; } .status-uploading { background: #bee3f8; color: #2c5282; } .status-extracting { background: #feebc8; color: #7c2d12; } .status-generating { background: #d6bcfa; color: #44337a; } .status-ready { background: #c6f6d5; color: #22543d; } .status-failed { background: #fed7d7; color: #742a2a; } .project-details-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 20px; } .detail-item { display: flex; flex-direction: column; gap: 6px; } .detail-label { font-size: 13px; color: #718096; font-weight: 500; text-transform: uppercase; letter-spacing: 0.5px; } .detail-value { font-size: 16px; color: #1a202c; font-weight: 600; } .detail-mono { font-family: 'Monaco', 'Courier New', monospace; font-size: 14px; } .error-banner { display: flex; justify-content: space-between; align-items: center; padding: 16px; background: #fed7d7; border: 1px solid #fc8181; border-radius: 8px; } .error-content { font-size: 14px; color: #742a2a; } .btn-dismiss { padding: 4px 12px; background: transparent; border: none; font-size: 24px; color: #742a2a; cursor: pointer; transition: all 0.2s ease; } .btn-dismiss:hover { background: rgba(0, 0, 0, 0.05); border-radius: 4px; } .actions-section { background: white; border: 1px solid #e2e8f0; border-radius: 12px; padding: 32px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); } .section-title { font-size: 24px; font-weight: 700; color: #1a202c; margin: 0 0 24px 0; } .actions-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 20px; } .action-card { background: #f7fafc; border: 2px solid #e2e8f0; border-radius: 12px; padding: 24px; transition: all 0.2s ease; } .action-card:hover:not(.action-disabled) { border-color: #cbd5e0; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); transform: translateY(-2px); } .action-disabled { opacity: 0.6; } .action-icon { font-size: 40px; margin-bottom: 16px; } .action-content { display: flex; flex-direction: column; gap: 12px; } .action-title { font-size: 18px; font-weight: 600; color: #1a202c; margin: 0; } .action-description { font-size: 14px; color: #718096; margin: 0; line-height: 1.5; } .btn-action { padding: 10px 20px; border: none; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.2s ease; display: flex; align-items: center; justify-content: center; gap: 8px; } .btn-primary { background: #667eea; color: white; } .btn-primary:hover:not(:disabled) { background: #5568d3; transform: translateY(-1px); box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3); } .btn-secondary { background: white; color: #4a5568; border: 1px solid #e2e8f0; } .btn-secondary:hover:not(:disabled) { background: #f7fafc; border-color: #cbd5e0; } .btn-disabled { background: #e2e8f0; color: #a0aec0; cursor: not-allowed; } .btn-action:focus { outline: 2px solid #667eea; outline-offset: 2px; } .btn-action:disabled { opacity: 0.6; cursor: not-allowed; } .btn-spinner { width: 16px; height: 16px; border: 2px solid rgba(255, 255, 255, 0.3); border-top-color: white; border-radius: 50%; animation: spin 0.8s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } .share-link-result { display: flex; gap: 8px; } .share-link-input { flex: 1; padding: 10px 12px; border: 1px solid #e2e8f0; border-radius: 6px; font-size: 13px; font-family: 'Monaco', 'Courier New', monospace; color: #4a5568; background: white; } .btn-copy-link { padding: 10px 16px; background: #667eea; border: none; border-radius: 6px; font-size: 13px; font-weight: 600; color: white; cursor: pointer; transition: all 0.2s ease; white-space: nowrap; } .btn-copy-link:hover { background: #5568d3; } .status-timeline { background: white; border: 1px solid #e2e8f0; border-radius: 12px; padding: 32px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); } .timeline { display: flex; flex-direction: column; gap: 24px; margin-top: 24px; } .timeline-item { display: flex; gap: 16px; position: relative; } .timeline-item:not(:last-child)::after { content: ''; position: absolute; left: 11px; top: 32px; width: 2px; height: calc(100% + 8px); background: #e2e8f0; } .timeline-item.active::after { background: #667eea; } .timeline-marker { width: 24px; height: 24px; border-radius: 50%; background: #e2e8f0; border: 3px solid white; box-shadow: 0 0 0 2px #e2e8f0; flex-shrink: 0; position: relative; z-index: 1; } .timeline-item.active .timeline-marker { background: #667eea; box-shadow: 0 0 0 2px #667eea; animation: pulse 2s ease-in-out infinite; } .timeline-item.complete .timeline-marker { background: #48bb78; box-shadow: 0 0 0 2px #48bb78; } @keyframes pulse { 0%, 100% { box-shadow: 0 0 0 2px #667eea; } 50% { box-shadow: 0 0 0 6px rgba(102, 126, 234, 0.3); } } .timeline-content h4 { font-size: 16px; font-weight: 600; color: #1a202c; margin: 0 0 4px 0; } .timeline-content p { font-size: 14px; color: #718096; margin: 0; } .timeline-item.active .timeline-content h4 { color: #667eea; } .empty-state { text-align: center; padding: 60px 20px; color: #718096; } /* Responsive design */ @media (max-width: 768px) { .project-title-section { flex-direction: column; align-items: flex-start; } .project-title { font-size: 24px; } .project-details-grid { grid-template-columns: 1fr; } .actions-grid { grid-template-columns: 1fr; } .share-link-result { flex-direction: column; } .btn-copy-link { width: 100%; } } </style>
:: ShareView /* * Share View Passage * * Hardened version: * - share-mode only * - prevents repeated reload loops * - fixes Twine variable rendering * - uses safe DOM rendering for passages * - keeps read-only flow isolated from authenticated flow * - resets correctly when share token changes * * Requirements: 10.5, 10.8, 10.9, 10.12 */ <<script>> (function () { var PASSAGE = 'ShareView'; window.setup = window.setup || {}; State.variables.current_passage = PASSAGE; function playPassage(name) { if (window.SugarCube && SugarCube.Engine) { SugarCube.Engine.play(name); } else { Engine.play(name); } } function escapeHtml(text) { if (window.setup.escapeHtml && typeof window.setup.escapeHtml === 'function') { return window.setup.escapeHtml(text); } var div = document.createElement('div'); div.textContent = String(text == null ? '' : text); return div.innerHTML; } function formatPassageContent(content) { var raw = String(content == null ? '' : content); if (!raw.trim()) return ''; return raw .split(/\n\n+/) .map(function (block) { var trimmed = block.trim(); if (!trimmed) return ''; return '<p>' + escapeHtml(trimmed).replace(/\n/g, '<br>') + '</p>'; }) .join(''); } function getShareSourceKey() { return 'share:' + String(State.variables.share_token || ''); } function getNarrativePassages() { return Array.isArray(State.variables.narrative_passages) ? State.variables.narrative_passages : []; } function getCurrentNarrativeNode() { var passages = getNarrativePassages(); var currentId = State.variables.current_passage_id; if (!currentId) return null; for (var i = 0; i < passages.length; i++) { if (String(passages[i].id) === String(currentId)) { return passages[i]; } } return null; } function getFirstNarrativeNode() { var passages = getNarrativePassages(); if (!passages.length) return null; if (State.variables.narrative_start_passage_id) { for (var i = 0; i < passages.length; i++) { if (String(passages[i].id) === String(State.variables.narrative_start_passage_id)) { return passages[i]; } } } return passages[0]; } if (!State.variables.share_mode || !State.variables.share_token) { playPassage('Login'); return; } if (typeof State.variables.loading_narrative === 'undefined') State.variables.loading_narrative = false; if (!Array.isArray(State.variables.narrative_passages)) State.variables.narrative_passages = []; if (typeof State.variables.current_passage_id === 'undefined') State.variables.current_passage_id = null; if (!Array.isArray(State.variables.visited_passages)) State.variables.visited_passages = []; if (!Array.isArray(State.variables.passage_history)) State.variables.passage_history = []; if (typeof State.variables.narrative_error === 'undefined') State.variables.narrative_error = null; if (typeof State.variables.narrative_loaded === 'undefined') State.variables.narrative_loaded = false; if (typeof State.variables.narrative_source_key === 'undefined') State.variables.narrative_source_key = null; if (typeof State.variables.narrative_version === 'undefined') State.variables.narrative_version = null; if (typeof State.variables.narrative_start_passage_id === 'undefined') State.variables.narrative_start_passage_id = null; var sourceKey = getShareSourceKey(); if (State.variables.narrative_source_key !== sourceKey) { State.variables.narrative_source_key = sourceKey; State.variables.loading_narrative = false; State.variables.narrative_loaded = false; State.variables.narrative_passages = []; State.variables.current_passage_id = null; State.variables.visited_passages = []; State.variables.passage_history = []; State.variables.narrative_error = null; State.variables.narrative_version = null; State.variables.narrative_start_passage_id = null; } window.setup.startNarrative = function () { var firstNode = getFirstNarrativeNode(); if (!firstNode || !firstNode.id) { State.variables.narrative_error = 'No passages available'; playPassage(PASSAGE); return; } window.setup.navigateToPassage(firstNode.id, { fromHistory: false }); }; window.setup.navigateToPassage = function (passageId, options) { var opts = options || {}; var passages = getNarrativePassages(); var target = null; var i; for (i = 0; i < passages.length; i++) { if (String(passages[i].id) === String(passageId)) { target = passages[i]; break; } } if (!target) { console.error('[ShareView] Passage not found:', passageId); State.variables.narrative_error = 'Passage not found'; playPassage(PASSAGE); return; } State.variables.current_passage_id = target.id; State.variables.narrative_error = null; if (!Array.isArray(State.variables.visited_passages)) { State.variables.visited_passages = []; } if (State.variables.visited_passages.indexOf(target.id) === -1) { State.variables.visited_passages.push(target.id); } if (!Array.isArray(State.variables.passage_history)) { State.variables.passage_history = []; } if (!opts.fromHistory) { State.variables.passage_history.push(target.id); } playPassage(PASSAGE); }; window.setup.goToPreviousPassage = function () { var history = Array.isArray(State.variables.passage_history) ? State.variables.passage_history : []; if (history.length <= 1) { return; } history.pop(); State.variables.current_passage_id = history[history.length - 1] || null; State.variables.narrative_error = null; playPassage(PASSAGE); }; window.setup.restartNarrative = function () { State.variables.current_passage_id = null; State.variables.visited_passages = []; State.variables.passage_history = []; State.variables.narrative_error = null; playPassage(PASSAGE); }; window.setup.renderSharePassageInto = function (selector) { var mount = document.querySelector(selector); if (!mount) return; var node = getCurrentNarrativeNode(); if (!node) { mount.innerHTML = '<div class="passage-card"><div class="passage-end"><p class="end-message">Passage not found.</p></div></div>'; return; } var html = ''; html += '<div class="passage-card">'; if (node.title) { html += '<h2 class="passage-title">' + escapeHtml(node.title) + '</h2>'; } html += '<div class="passage-content">' + formatPassageContent(node.content) + '</div>'; if (Array.isArray(node.choices) && node.choices.length > 0) { html += '<div class="passage-choices">'; node.choices.forEach(function (choice) { var target = choice && choice.target ? String(choice.target) : ''; var label = choice && choice.text ? String(choice.text) : 'Continue'; var targetJs = JSON.stringify(target); html += ( '<button class="choice-button" type="button" onclick=\'window.setup.navigateToPassage(' + targetJs + ')\' aria-label="' + escapeHtml('Choose: ' + label) + '">' + '<span>' + escapeHtml(label) + '</span>' + '</button>' ); }); html += '</div>'; } else { html += '<div class="passage-end">'; html += '<p class="end-message">You’ve reached the end of this narrative path.</p>'; html += '<button class="btn-primary-local" type="button" onclick="window.setup.restartNarrative()">Start Over</button>'; html += '</div>'; } html += '</div>'; mount.innerHTML = html; }; window.setup.ensureSharedNarrativeLoaded = function () { if (window.setup._shareNarrativeLoadInFlight) return; if (State.variables.narrative_loaded || State.variables.loading_narrative) return; if (!window.setup.validateShareToken || typeof window.setup.validateShareToken !== 'function') { State.variables.narrative_error = 'Share validator is unavailable.'; playPassage(PASSAGE); return; } window.setup._shareNarrativeLoadInFlight = true; State.variables.loading_narrative = true; State.variables.narrative_error = null; (async function () { try { var token = State.variables.share_token; var narrative = await window.setup.validateShareToken(token); if (narrative && narrative.version_id) { State.variables.narrative_version = narrative.version_id; } if (narrative && narrative.start_passage_id) { State.variables.narrative_start_passage_id = narrative.start_passage_id; } State.variables.narrative_loaded = true; State.variables.loading_narrative = false; State.variables.narrative_error = null; if (State.passage === PASSAGE) { playPassage(PASSAGE); } } catch (error) { console.error('[ShareView] Failed to load shared narrative:', error); State.variables.narrative_error = error && error.message ? error.message : 'Invalid or expired share link'; State.variables.loading_narrative = false; State.variables.narrative_loaded = false; if (State.passage === PASSAGE) { playPassage(PASSAGE); } } finally { window.setup._shareNarrativeLoadInFlight = false; } })(); }; if (!State.variables.narrative_loaded && !State.variables.loading_narrative) { window.setup.ensureSharedNarrativeLoaded(); } window.setTimeout(function () { if (State.passage !== PASSAGE) return; if (!State.variables.loading_narrative && !State.variables.narrative_error && State.variables.current_passage_id) { window.setup.renderSharePassageInto('#share-current-passage'); } }, 0); })(); <</script>> <div class="share-container"> <<if $loading_narrative>> <div class="share-loading"> <div class="loading-spinner"></div> <h2>Loading Shared Narrative...</h2> <p>Preparing your interactive experience</p> </div> <<elseif $narrative_error>> <div class="share-error"> <div class="error-icon">🔒</div> <h2>Unable to Access Narrative</h2> <p><<print $narrative_error>></p> <div class="error-details"> <p class="error-hint">This link may have expired or been revoked.</p> <p class="error-hint">Please contact the person who shared this link with you.</p> </div> <button class="btn-primary" type="button" onclick="window.location.reload()"> Try Again </button> </div> <<elseif $narrative_passages.length === 0>> <div class="share-empty"> <div class="empty-icon">📖</div> <h2>No Content Available</h2> <p>This shared narrative doesn't have any content yet.</p> </div> <<else>> <header class="share-header"> <div class="header-content"> <div class="share-mode-badge"> <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"> <path fill="currentColor" d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/> </svg> Read-Only View </div> <div class="narrative-progress"> <span class="progress-text"> Passage <<print $current_passage_id ? $visited_passages.length : 0>> of <<print $narrative_passages.length>> </span> <div class="progress-bar"> <div class="progress-fill" style="<<print 'width: ' + ($narrative_passages.length ? Math.round((($current_passage_id ? $visited_passages.length : 0) / $narrative_passages.length) * 100) : 0) + '%'>>"> </div> </div> </div> </div> </header> <main class="share-main"> <div class="share-content"> <<if $current_passage_id === null>> <div class="share-start"> <div class="start-content"> <h1 class="share-title">Interactive Sales Narrative</h1> <div class="share-intro"> <p class="intro-text"> You've been invited to explore this interactive sales narrative. Navigate through the story by making choices that shape the conversation. </p> <div class="share-notice"> <svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"> <path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/> </svg> <span>This is a read-only view. Your progress will not be saved.</span> </div> </div> <<if $narrative_version>> <div class="version-badge"> Version: <span class="version-id"><<print $narrative_version>></span> </div> <</if>> <button class="btn-start" type="button" onclick="window.setup.startNarrative()" aria-label="Begin narrative"> Begin Experience </button> </div> </div> <<else>> <div id="share-current-passage"></div> <</if>> </div> </main> <<if $current_passage_id !== null>> <footer class="share-footer"> <div class="footer-content"> <<if $passage_history && $passage_history.length > 1>> <button class="btn-nav btn-back-passage" type="button" onclick="window.setup.goToPreviousPassage()" aria-label="Go to previous passage"> <svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"> <path fill="currentColor" d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/> </svg> Previous </button> <<else>> <div></div> <</if>> <button class="btn-nav btn-restart" type="button" onclick="window.setup.restartNarrative()" aria-label="Restart narrative"> <svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"> <path fill="currentColor" d="M12 5V1L7 6l5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/> </svg> Restart </button> </div> </footer> <</if>> <</if>> </div> <style> .share-container { min-height: 100vh; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; flex-direction: column; } .share-loading { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 60px 20px; color: white; text-align: center; } .loading-spinner { width: 60px; height: 60px; border: 4px solid rgba(255, 255, 255, 0.3); border-top-color: white; border-radius: 50%; animation: share-spin 1s linear infinite; margin-bottom: 24px; } @keyframes share-spin { to { transform: rotate(360deg); } } .share-loading h2 { font-size: 28px; font-weight: 700; margin: 0 0 12px 0; } .share-loading p { font-size: 16px; opacity: 0.9; margin: 0; } .share-error { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 60px 20px; color: white; text-align: center; } .error-icon { font-size: 64px; margin-bottom: 24px; } .share-error h2 { font-size: 28px; font-weight: 700; margin: 0 0 12px 0; } .share-error p { font-size: 16px; opacity: 0.9; margin: 0 0 24px 0; max-width: 500px; } .error-details { margin-bottom: 32px; } .error-hint { font-size: 14px; opacity: 0.8; margin: 8px 0; } .share-empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 60px 20px; color: white; text-align: center; } .empty-icon { font-size: 64px; margin-bottom: 24px; } .share-empty h2 { font-size: 28px; font-weight: 700; margin: 0 0 12px 0; } .share-empty p { font-size: 16px; opacity: 0.9; margin: 0; } .share-header { background: rgba(255, 255, 255, 0.95); backdrop-filter: blur(10px); border-bottom: 1px solid rgba(0, 0, 0, 0.1); padding: 16px 24px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); } .header-content { max-width: 900px; margin: 0 auto; display: flex; justify-content: space-between; align-items: center; gap: 20px; } .share-mode-badge { display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; background: #edf2f7; border-radius: 6px; font-size: 13px; font-weight: 600; color: #4a5568; } .narrative-progress { display: flex; flex-direction: column; gap: 6px; min-width: 200px; } .progress-text { font-size: 13px; color: #718096; font-weight: 500; text-align: right; } .progress-bar { height: 6px; background: #e2e8f0; border-radius: 3px; overflow: hidden; } .progress-fill { height: 100%; background: linear-gradient(90deg, #667eea 0%, #764ba2 100%); transition: width 0.3s ease; } .share-main { flex: 1; padding: 40px 24px; overflow-y: auto; } .share-content { max-width: 900px; margin: 0 auto; } .share-start { background: white; border-radius: 16px; padding: 60px 40px; box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15); text-align: center; } .start-content { max-width: 600px; margin: 0 auto; } .share-title { font-size: 36px; font-weight: 800; color: #1a202c; margin: 0 0 32px 0; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; } .share-intro { margin-bottom: 32px; } .intro-text { font-size: 16px; line-height: 1.6; color: #4a5568; margin: 0 0 24px 0; } .share-notice { display: inline-flex; align-items: center; gap: 8px; padding: 12px 20px; background: #fef5e7; border: 1px solid #f9e79f; border-radius: 8px; font-size: 14px; color: #7d6608; font-weight: 500; } .share-notice svg { flex-shrink: 0; } .version-badge { display: inline-block; padding: 8px 16px; background: #f7fafc; border-radius: 6px; font-size: 13px; color: #718096; margin-bottom: 32px; } .version-id { font-family: Monaco, "Courier New", monospace; font-weight: 600; color: #4a5568; } .btn-start { padding: 16px 48px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border: none; border-radius: 12px; font-size: 18px; font-weight: 700; color: white; cursor: pointer; transition: all 0.3s ease; box-shadow: 0 4px 16px rgba(102, 126, 234, 0.4); } .btn-start:hover { transform: translateY(-2px); box-shadow: 0 6px 24px rgba(102, 126, 234, 0.5); } .btn-start:focus { outline: 3px solid rgba(102, 126, 234, 0.5); outline-offset: 2px; } .passage-card { background: white; border-radius: 16px; padding: 48px 40px; box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15); animation: share-fade-in 0.4s ease; } @keyframes share-fade-in { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } } .passage-title { font-size: 32px; font-weight: 700; color: #1a202c; margin: 0 0 24px 0; } .passage-content { font-size: 18px; line-height: 1.8; color: #2d3748; margin: 0 0 32px 0; } .passage-content p { margin: 0 0 16px 0; } .passage-content p:last-child { margin-bottom: 0; } .passage-choices { display: flex; flex-direction: column; gap: 12px; margin-top: 32px; } .choice-button { padding: 16px 24px; background: white; border: 2px solid #e2e8f0; border-radius: 12px; font-size: 16px; font-weight: 600; color: #2d3748; text-align: left; cursor: pointer; transition: all 0.2s ease; position: relative; overflow: hidden; } .choice-button::before { content: ''; position: absolute; inset: 0 auto 0 0; width: 0; height: 100%; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); transition: width 0.3s ease; z-index: 0; } .choice-button:hover { border-color: #667eea; color: white; } .choice-button:hover::before { width: 100%; } .choice-button span { position: relative; z-index: 1; } .choice-button:focus { outline: 3px solid rgba(102, 126, 234, 0.5); outline-offset: 2px; } .passage-end { text-align: center; padding: 32px 0; } .end-message { font-size: 20px; font-weight: 600; color: #667eea; margin: 0 0 24px 0; } .btn-primary-local { padding: 12px 32px; background: #667eea; border: 2px solid #667eea; border-radius: 8px; font-size: 16px; font-weight: 600; color: white; cursor: pointer; transition: all 0.2s ease; } .btn-primary-local:hover { background: #5568d3; border-color: #5568d3; } .share-footer { background: rgba(255, 255, 255, 0.95); backdrop-filter: blur(10px); border-top: 1px solid rgba(0, 0, 0, 0.1); padding: 16px 24px; box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1); } .footer-content { max-width: 900px; margin: 0 auto; display: flex; justify-content: space-between; align-items: center; } .btn-nav { display: inline-flex; align-items: center; gap: 8px; padding: 10px 20px; background: white; border: 1px solid #e2e8f0; border-radius: 8px; font-size: 14px; font-weight: 600; color: #4a5568; cursor: pointer; transition: all 0.2s ease; } .btn-nav:hover { background: #f7fafc; border-color: #cbd5e0; } .btn-nav:focus { outline: 2px solid #667eea; outline-offset: 2px; } .btn-primary { padding: 12px 32px; background: white; border: 2px solid white; border-radius: 8px; font-size: 16px; font-weight: 600; color: #667eea; cursor: pointer; transition: all 0.2s ease; } .btn-primary:hover { background: rgba(255, 255, 255, 0.9); transform: translateY(-1px); } @media (max-width: 768px) { .header-content { flex-direction: column; align-items: stretch; } .narrative-progress { min-width: 0; } .progress-text { text-align: left; } .share-start { padding: 40px 24px; } .share-title { font-size: 28px; } .passage-card { padding: 32px 24px; } .passage-title { font-size: 24px; } .passage-content { font-size: 16px; } .footer-content { flex-direction: column; gap: 12px; } .btn-nav { width: 100%; justify-content: center; } } </style>
:: StatusPolling /* * Status Polling Passage * * Hardened version: * - does not reset polling state on every rerender * - prevents duplicate polling loops / intervals * - preserves status across passage refreshes * - safely stops polling on ready / failed / timeout * - updates current project status and version * - fixes Twine variable rendering in HTML * * Requirements: 9.4, 9.5, 9.6, 9.7, 9.8 */ <<script>> (function () { var PASSAGE = 'StatusPolling'; window.setup = window.setup || {}; State.variables.current_passage = PASSAGE; function playPassage(name) { if (window.SugarCube && SugarCube.Engine) { SugarCube.Engine.play(name); } else { Engine.play(name); } } if (!window.setup.checkAuth || !window.setup.checkAuth()) { playPassage('Login'); return; } if (!State.variables.current_project || !State.variables.current_project.project_id) { playPassage('Dashboard'); return; } var projectId = State.variables.current_project.project_id; var currentProjectStatus = State.variables.current_project.status || 'uploading'; function normalizeStatus(status) { var raw = String(status || '').toLowerCase().trim(); if (!raw) return 'uploading'; if (raw === 'uploaded' || raw === 'pending' || raw === 'processing' || raw === 'queued') return 'uploading'; if (raw === 'extract' || raw === 'extracting') return 'extracting'; if (raw === 'generate' || raw === 'generating') return 'generating'; if (raw === 'complete' || raw === 'completed' || raw === 'ready') return 'ready'; if (raw === 'error' || raw === 'errored' || raw === 'failed') return 'failed'; if (raw === 'idle') return 'uploading'; return raw; } function clearPollingTimer() { if (window.setup._statusPollingTimer) { clearInterval(window.setup._statusPollingTimer); window.setup._statusPollingTimer = null; } } function rerenderPassage() { if (State.passage !== PASSAGE) return; playPassage(PASSAGE); } if (State.variables.polling_project_id !== projectId) { State.variables.polling_project_id = projectId; State.variables.polling_active = false; State.variables.polling_status = normalizeStatus(currentProjectStatus); State.variables.polling_attempts = 0; State.variables.polling_start_time = Date.now(); State.variables.polling_last_checked_at = null; State.variables.polling_timeout_reached = false; clearPollingTimer(); if (window.setup.clearError && typeof window.setup.clearError === 'function') { window.setup.clearError(); } } else { if (typeof State.variables.polling_active === 'undefined') { State.variables.polling_active = false; } if (typeof State.variables.polling_status === 'undefined' || !State.variables.polling_status) { State.variables.polling_status = normalizeStatus(currentProjectStatus); } else { State.variables.polling_status = normalizeStatus(State.variables.polling_status); } if (typeof State.variables.polling_attempts === 'undefined') { State.variables.polling_attempts = 0; } if (typeof State.variables.polling_start_time === 'undefined' || !State.variables.polling_start_time) { State.variables.polling_start_time = Date.now(); } if (typeof State.variables.polling_last_checked_at === 'undefined') { State.variables.polling_last_checked_at = null; } if (typeof State.variables.polling_timeout_reached === 'undefined') { State.variables.polling_timeout_reached = false; } } window.setup.formatElapsedTime = function (startTime) { var started = Number(startTime) || Date.now(); var elapsed = Math.max(0, Date.now() - started); var seconds = Math.floor(elapsed / 1000); var minutes = Math.floor(seconds / 60); var remainingSeconds = seconds % 60; if (minutes > 0) { return minutes + 'm ' + remainingSeconds + 's'; } return seconds + 's'; }; window.setup.stopStatusPolling = function () { State.variables.polling_active = false; clearPollingTimer(); }; window.setup.stopPollingAndReturn = function () { window.setup.stopStatusPolling(); playPassage('ProjectView'); }; window.setup._statusPollingTick = async function () { if (window.setup._statusPollingInFlight) return; if (!State.variables.current_project || State.variables.current_project.project_id !== projectId) return; var maxAttempts = (window.ArcEngineConfig && window.ArcEngineConfig.polling && window.ArcEngineConfig.polling.maxAttempts) || 120; if (State.variables.polling_attempts >= maxAttempts) { State.variables.polling_active = false; State.variables.polling_timeout_reached = true; clearPollingTimer(); if (window.setup.handleError && typeof window.setup.handleError === 'function') { window.setup.handleError({ message: 'Processing is taking longer than expected. Please check back later.', code: 'PROCESSING_TIMEOUT' }); } else { State.variables.error_message = 'Processing is taking longer than expected. Please check back later.'; State.variables.error_code = 'PROCESSING_TIMEOUT'; } rerenderPassage(); return; } window.setup._statusPollingInFlight = true; try { var status = 'failed'; if (window.setup.pollStatus && typeof window.setup.pollStatus === 'function') { status = await window.setup.pollStatus(projectId); } status = normalizeStatus(status); State.variables.polling_status = status; State.variables.polling_attempts = (Number(State.variables.polling_attempts) || 0) + 1; State.variables.polling_last_checked_at = Date.now(); if (State.variables.current_project) { State.variables.current_project.status = status; if (State.variables.narrative_version) { State.variables.current_project.current_version = State.variables.narrative_version; } State.variables.current_project.updated_at = new Date().toISOString(); } if (status === 'ready' || status === 'failed') { State.variables.polling_active = false; clearPollingTimer(); } rerenderPassage(); } catch (error) { console.error('[StatusPolling] Polling error:', error); State.variables.polling_attempts = (Number(State.variables.polling_attempts) || 0) + 1; State.variables.polling_last_checked_at = Date.now(); var maxAttemptsOnError = (window.ArcEngineConfig && window.ArcEngineConfig.polling && window.ArcEngineConfig.polling.maxAttempts) || 120; if (State.variables.polling_attempts >= maxAttemptsOnError) { State.variables.polling_active = false; State.variables.polling_timeout_reached = true; clearPollingTimer(); if (window.setup.handleError && typeof window.setup.handleError === 'function') { window.setup.handleError({ message: 'Polling timeout. Please return to the project and refresh status later.', code: 'PROCESSING_TIMEOUT' }); } else { State.variables.error_message = 'Polling timeout. Please return to the project and refresh status later.'; State.variables.error_code = 'PROCESSING_TIMEOUT'; } rerenderPassage(); } } finally { window.setup._statusPollingInFlight = false; } }; window.setup.startPollingLoop = function () { var status = normalizeStatus(State.variables.polling_status); var intervalMs = (window.ArcEngineConfig && window.ArcEngineConfig.polling && window.ArcEngineConfig.polling.intervalMs) || 5000; State.variables.polling_status = status; if (status === 'ready' || status === 'failed') { State.variables.polling_active = false; clearPollingTimer(); return; } if (window.setup._statusPollingTimer && State.variables.polling_active) { return; } State.variables.polling_active = true; clearPollingTimer(); window.setup._statusPollingTick(); window.setup._statusPollingTimer = setInterval(function () { if (!State.variables.polling_active) { clearPollingTimer(); return; } window.setup._statusPollingTick(); }, intervalMs); }; window.setTimeout(function () { if (State.passage !== PASSAGE) return; if (State.variables.polling_status === 'ready' || State.variables.polling_status === 'failed') { State.variables.polling_active = false; clearPollingTimer(); return; } if (!State.variables.polling_active || !window.setup._statusPollingTimer) { window.setup.startPollingLoop(); } }, 0); })(); <</script>> <div class="status-polling-container"> <header class="polling-header"> <div class="header-content"> <button class="btn-back" type="button" onclick="window.setup.stopPollingAndReturn()" aria-label="Back to project view"> <svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"> <path fill="currentColor" d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/> </svg> Back to Project </button> </div> </header> <main class="polling-main"> <div class="polling-content"> <div class="polling-card"> <div class="polling-header-section"> <div class="polling-icon-container"> <<if $polling_status === 'ready'>> <div class="status-icon status-success">✓</div> <<elseif $polling_status === 'failed'>> <div class="status-icon status-error">✕</div> <<else>> <div class="status-icon status-processing"> <div class="spinner"></div> </div> <</if>> </div> <h1 class="polling-title"> <<if $polling_status === 'ready'>> Processing Complete! <<elseif $polling_status === 'failed'>> Processing Failed <<else>> Processing Your Documents <</if>> </h1> <p class="polling-subtitle"> <<if $polling_status === 'ready'>> Your interactive narrative is ready to view. <<elseif $polling_status === 'failed'>> An error occurred during processing. Please try uploading again. <<else>> Please wait while we extract intelligence and generate your narrative. <</if>> </p> </div> <<if $error_message>> <div class="error-banner" role="alert"> <div class="error-content"> <strong>Error:</strong> <<print window.setup.escapeHtml ? window.setup.escapeHtml($error_message) : $error_message>> </div> <button type="button" onclick="window.setup.clearError(); Engine.play('StatusPolling');" class="btn-dismiss" aria-label="Dismiss error"> × </button> </div> <</if>> <<if $polling_status !== 'idle'>> <div class="processing-timeline"> <div class="timeline-item <<if $polling_status === 'uploading' or $polling_status === 'extracting' or $polling_status === 'generating' or $polling_status === 'ready'>>complete<<else>>pending<</if>>"> <div class="timeline-marker"> <<if $polling_status === 'uploading' or $polling_status === 'extracting' or $polling_status === 'generating' or $polling_status === 'ready'>> <svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"> <path fill="currentColor" d="M9,20.42L2.79,14.21L5.62,11.38L9,14.77L18.88,4.88L21.71,7.71L9,20.42Z"/> </svg> <<else>> <div class="timeline-dot"></div> <</if>> </div> <div class="timeline-content"> <h3 class="timeline-title">Upload Complete</h3> <p class="timeline-description">Document uploaded to secure storage</p> </div> </div> <div class="timeline-item <<if $polling_status === 'extracting'>>active<<elseif $polling_status === 'generating' or $polling_status === 'ready'>>complete<<else>>pending<</if>>"> <div class="timeline-marker"> <<if $polling_status === 'extracting'>> <div class="spinner-small"></div> <<elseif $polling_status === 'generating' or $polling_status === 'ready'>> <svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"> <path fill="currentColor" d="M9,20.42L2.79,14.21L5.62,11.38L9,14.77L18.88,4.88L21.71,7.71L9,20.42Z"/> </svg> <<else>> <div class="timeline-dot"></div> <</if>> </div> <div class="timeline-content"> <h3 class="timeline-title">Extracting Intelligence</h3> <p class="timeline-description">Analyzing document content and structure</p> </div> </div> <div class="timeline-item <<if $polling_status === 'generating'>>active<<elseif $polling_status === 'ready'>>complete<<else>>pending<</if>>"> <div class="timeline-marker"> <<if $polling_status === 'generating'>> <div class="spinner-small"></div> <<elseif $polling_status === 'ready'>> <svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"> <path fill="currentColor" d="M9,20.42L2.79,14.21L5.62,11.38L9,14.77L18.88,4.88L21.71,7.71L9,20.42Z"/> </svg> <<else>> <div class="timeline-dot"></div> <</if>> </div> <div class="timeline-content"> <h3 class="timeline-title">Generating Narrative</h3> <p class="timeline-description">Creating interactive branching story</p> </div> </div> <div class="timeline-item <<if $polling_status === 'ready'>>complete<<else>>pending<</if>>"> <div class="timeline-marker"> <<if $polling_status === 'ready'>> <svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"> <path fill="currentColor" d="M9,20.42L2.79,14.21L5.62,11.38L9,14.77L18.88,4.88L21.71,7.71L9,20.42Z"/> </svg> <<else>> <div class="timeline-dot"></div> <</if>> </div> <div class="timeline-content"> <h3 class="timeline-title">Ready to View</h3> <p class="timeline-description">Narrative is ready for presentation</p> </div> </div> </div> <</if>> <<if $polling_active and $polling_status !== 'ready' and $polling_status !== 'failed'>> <div class="polling-info"> <div class="info-item"> <span class="info-label">Status</span> <span class="info-value status-badge status-<<print $polling_status>>"> <<print $polling_status>> </span> </div> <div class="info-item"> <span class="info-label">Polling Attempts</span> <span class="info-value"> <<print $polling_attempts>> </span> </div> <div class="info-item"> <span class="info-label">Elapsed Time</span> <span class="info-value"> <<print window.setup.formatElapsedTime($polling_start_time)>> </span> </div> </div> <div class="polling-hint"> <p> Processing typically takes 2–5 minutes. You can safely leave this page and return later. The system will continue processing in the background. </p> </div> <</if>> <div class="polling-actions"> <<if $polling_status === 'ready'>> <button class="btn-action btn-primary" type="button" onclick="window.setup.stopStatusPolling(); Engine.play('NarrativeView');" aria-label="View narrative"> View Narrative </button> <button class="btn-action btn-secondary" type="button" onclick="window.setup.stopPollingAndReturn()" aria-label="Back to project"> Back to Project </button> <<elseif $polling_status === 'failed'>> <button class="btn-action btn-primary" type="button" onclick="window.setup.stopStatusPolling(); Engine.play('UploadScreen');" aria-label="Try again"> Try Again </button> <button class="btn-action btn-secondary" type="button" onclick="window.setup.stopPollingAndReturn()" aria-label="Back to project"> Back to Project </button> <<else>> <button class="btn-action btn-secondary" type="button" onclick="window.setup.stopPollingAndReturn()" aria-label="Return to project"> Return to Project </button> <</if>> </div> </div> </div> </main> </div> <style> .status-polling-container { min-height: 100vh; background: #f7fafc; } .polling-header { background: white; border-bottom: 1px solid #e2e8f0; padding: 16px 24px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); } .header-content { max-width: 900px; margin: 0 auto; } .btn-back { display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; background: transparent; border: 1px solid #e2e8f0; border-radius: 6px; font-size: 14px; color: #4a5568; cursor: pointer; transition: all 0.2s ease; } .btn-back:hover { background: #f7fafc; border-color: #cbd5e0; } .btn-back:focus { outline: 2px solid #667eea; outline-offset: 2px; } .btn-back svg { flex-shrink: 0; } .polling-main { padding: 32px 24px; } .polling-content { max-width: 900px; margin: 0 auto; } .polling-card { background: white; border: 1px solid #e2e8f0; border-radius: 12px; padding: 40px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); } .polling-header-section { text-align: center; margin-bottom: 40px; } .polling-icon-container { margin-bottom: 24px; } .status-icon { width: 80px; height: 80px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-size: 48px; } .status-success { background: #c6f6d5; color: #22543d; } .status-error { background: #fed7d7; color: #742a2a; } .status-processing { background: #bee3f8; color: #2c5282; } .spinner { width: 48px; height: 48px; border: 4px solid rgba(44, 82, 130, 0.2); border-top-color: #2c5282; border-radius: 50%; animation: status-polling-spin 1s linear infinite; } @keyframes status-polling-spin { to { transform: rotate(360deg); } } .polling-title { font-size: 32px; font-weight: 700; color: #1a202c; margin: 0 0 12px 0; } .polling-subtitle { font-size: 16px; color: #718096; margin: 0; line-height: 1.5; } .error-banner { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 16px; background: #fed7d7; border: 1px solid #fc8181; border-radius: 8px; margin-bottom: 24px; } .error-content { font-size: 14px; color: #742a2a; } .btn-dismiss { padding: 4px 12px; background: transparent; border: none; font-size: 24px; color: #742a2a; cursor: pointer; transition: all 0.2s ease; line-height: 1; } .btn-dismiss:hover { background: rgba(0, 0, 0, 0.05); border-radius: 4px; } .processing-timeline { display: flex; flex-direction: column; gap: 0; margin-bottom: 32px; padding: 24px; background: #f7fafc; border-radius: 12px; } .timeline-item { display: flex; gap: 16px; padding: 16px 0; position: relative; } .timeline-item:not(:last-child)::after { content: ''; position: absolute; left: 19px; top: 56px; width: 2px; height: calc(100% - 16px); background: #e2e8f0; } .timeline-item.active::after, .timeline-item.complete::after { background: #667eea; } .timeline-marker { width: 40px; height: 40px; border-radius: 50%; background: #e2e8f0; display: flex; align-items: center; justify-content: center; flex-shrink: 0; position: relative; z-index: 1; transition: all 0.3s ease; } .timeline-item.active .timeline-marker { background: #667eea; box-shadow: 0 0 0 4px rgba(102, 126, 234, 0.2); } .timeline-item.complete .timeline-marker { background: #48bb78; } .timeline-item.pending .timeline-marker { background: #e2e8f0; } .timeline-marker svg { color: white; } .timeline-dot { width: 12px; height: 12px; border-radius: 50%; background: #a0aec0; } .spinner-small { width: 20px; height: 20px; border: 2px solid rgba(255, 255, 255, 0.3); border-top-color: white; border-radius: 50%; animation: status-polling-spin 0.8s linear infinite; } .timeline-content { flex: 1; padding-top: 4px; } .timeline-title { font-size: 16px; font-weight: 600; color: #1a202c; margin: 0 0 4px 0; } .timeline-item.active .timeline-title { color: #667eea; } .timeline-description { font-size: 14px; color: #718096; margin: 0; } .polling-info { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; padding: 20px; background: #edf2f7; border-radius: 8px; margin-bottom: 16px; } .info-item { display: flex; flex-direction: column; gap: 6px; } .info-label { font-size: 13px; color: #718096; font-weight: 500; text-transform: uppercase; letter-spacing: 0.5px; } .info-value { font-size: 16px; color: #1a202c; font-weight: 600; } .status-badge { display: inline-block; padding: 4px 12px; border-radius: 12px; font-size: 13px; font-weight: 600; text-transform: uppercase; } .status-uploading { background: #bee3f8; color: #2c5282; } .status-extracting { background: #feebc8; color: #7c2d12; } .status-generating { background: #d6bcfa; color: #44337a; } .status-ready { background: #c6f6d5; color: #22543d; } .status-failed { background: #fed7d7; color: #742a2a; } .polling-hint { padding: 16px; background: #edf2f7; border-left: 4px solid #667eea; border-radius: 4px; margin-bottom: 24px; } .polling-hint p { font-size: 14px; color: #4a5568; margin: 0; line-height: 1.5; } .polling-actions { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; } .btn-action { padding: 12px 32px; border: none; border-radius: 8px; font-size: 16px; font-weight: 600; cursor: pointer; transition: all 0.2s ease; } .btn-primary { background: #667eea; color: white; } .btn-primary:hover { background: #5568d3; transform: translateY(-1px); box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3); } .btn-secondary { background: white; color: #4a5568; border: 1px solid #e2e8f0; } .btn-secondary:hover { background: #f7fafc; border-color: #cbd5e0; } .btn-action:focus { outline: 2px solid #667eea; outline-offset: 2px; } @media (max-width: 768px) { .polling-card { padding: 24px; } .polling-title { font-size: 24px; } .polling-info { grid-template-columns: 1fr; } .polling-actions { flex-direction: column; } .btn-action { width: 100%; } } </style>