1/** 2 * Trial Paywall â buggazi-style 7-day free trial, card upfront ($0 today). 3 * 4 * Flow: 5 * 1. A value action hits the 402 PAYMENT_REQUIRED gate â api.js dispatches 6 * the `payment:required` event â this modal opens. 7 * 2. User picks a plan (starter/pro/teams). We call POST /billing/start-trial 8 * which creates a Stripe subscription (trial_period_days:7, 9 * default_incomplete) and returns a SetupIntent client_secret. 10 * 3. We mount the Stripe Payment Element, the user adds a card, we confirm the 11 * SetupIntent ($0 charge today), then call POST /billing/confirm-trial to 12 * flip paymentVerified â true. Stripe charges automatically on day 7. 13 */ 14 15import { api } from '../common/api.js'; 16import { showToast } from '../common/utils.js'; 17 18class Paywall { 19 constructor() { 20 this.overlay = null; 21 this.stripe = null; 22 this.elements = null; 23 this.trialPlans = null; 24 this.selectedPlan = null; 25 this.clientSecret = null; 26 this.open = false; 27 } 28 29 init() { 30 if (this._initialized) return; 31 this._initialized = true; 32 window.addEventListener('payment:required', (e) => this.show(e.detail)); 33 // Refresh the countdown banner once a trial is activated from the modal. 34 window.addEventListener('payment:verified', () => this.checkTrialBanner()); 35 } 36 37 /** 38 * Show a slim countdown banner while the user is on an active trial. 39 * Reads live state from GET /billing/subscription. No-op if not on trial. 40 */ 41 async checkTrialBanner() { 42 try { 43 const sub = await api.getSubscription(); 44 const onTrial = sub && sub.trialPlan && sub.trialEndsAt && sub.paymentVerified; 45 if (!onTrial) { this._removeBanner(); return; } 46 const end = new Date(sub.trialEndsAt).getTime(); 47 const daysLeft = Math.max(0, Math.ceil((end - Date.now()) / 86400000)); 48 // Only surface while the subscription is still in the trial window. 49 const status = sub.subscription?.status; 50 if (status && status !== 'trialing') { this._removeBanner(); return; } 51 this._renderBanner(daysLeft, sub.trialPlan); 52 } catch { 53 // Silent â banner is non-critical. 54 } 55 } 56 57 _renderBanner(daysLeft, planKey) { 58 this._injectStyles(); 59 let banner = document.getElementById('pw-trial-banner'); 60 if (!banner) { 61 banner = document.createElement('div'); 62 banner.id = 'pw-trial-banner'; 63 banner.className = 'pw-trial-banner'; 64 document.body.appendChild(banner); 65 } 66 const label = daysLeft <= 0 67 ? 'Your free trial ends today' 68 : `${daysLeft} day${daysLeft === 1 ? '' : 's'} left in your free trial`; 69 banner.innerHTML = ` 70 <span class="pw-trial-dot"></span> 71 <span>${label}${planKey ? ` \u00b7 ${planKey.charAt(0).toUpperCase()}${planKey.slice(1)}` : ''}</span> 72 <a href="/app#billing" class="pw-trial-link">Manage</a> 73 `; 74 } 75 76 _removeBanner() { 77 document.getElementById('pw-trial-banner')?.remove(); 78 } 79 80 async show(detail = {}) { 81 if (this.open) return; 82 this.open = true; 83 this._injectStyles(); 84 this._renderShell(detail); 85 86 try { 87 const res = await api.getTrialPlans(); 88 this.trialPlans = res.plans || []; 89 this.trialDays = res.trialDays || 7; 90 this._renderPlans(); 91 } catch (err) { 92 this._renderError(err.message || 'Could not load plans. Please try again.'); 93 } 94 } 95 96 close() { 97 if (this.overlay) { 98 this.overlay.remove(); 99 this.overlay = null; 100 } 101 this.stripe = null; 102 this.elements = null; 103 this.selectedPlan = null; 104 this.clientSecret = null; 105 this.open = false; 106 } 107 108 // -------------------------------------------------------------------------- 109 // Rendering 110 // -------------------------------------------------------------------------- 111 112 _renderShell(detail) { 113 const overlay = document.createElement('div'); 114 overlay.className = 'pw-overlay'; 115 overlay.innerHTML = ` 116 <div class="pw-modal" role="dialog" aria-modal="true"> 117 <button class="pw-close" aria-label="Close">×</button> 118 <div class="pw-header"> 119 <div class="pw-badge">7-day free trial</div> 120 <h2 class="pw-title">Start building â $0 today</h2> 121 <p class="pw-sub">${(detail && detail.message) || 'Add a card to unlock building & publishing. You won\u2019t be charged until day 7 \u2014 cancel anytime.'}</p> 122 </div> 123 <div class="pw-body"> 124 <div class="pw-loading"><div class="pw-spinner"></div></div> 125 </div> 126 </div> 127 `; 128 overlay.querySelector('.pw-close').addEventListener('click', () => this.close()); 129 overlay.addEventListener('click', (e) => { if (e.target === overlay) this.close(); }); 130 document.body.appendChild(overlay);
131 this.overlay = overlay; 132 } 133 134 _renderError(msg) { 135 const body = this.overlay?.querySelector('.pw-body'); 136 if (!body) return; 137 body.innerHTML = `<div class="pw-error">${msg}</div> 138 <button class="pw-btn pw-btn-secondary" id="pw-retry">Retry</button>`; 139 body.querySelector('#pw-retry')?.addEventListener('click', () => { this.close(); this.show(); }); 140 } 141 142 _renderPlans() { 143 const body = this.overlay?.querySelector('.pw-body'); 144 if (!body) return; 145 146 const cards = this.trialPlans.map((p) => ` 147 <div class="pw-plan ${p.popular ? 'pw-plan-popular' : ''}" data-plan="${p.key}"> 148 ${p.popular ? '<span class="pw-plan-tag">Most popular</span>' : ''} 149 <div class="pw-plan-name">${p.name}</div> 150 <div class="pw-plan-price">${p.priceLabel}</div> 151 <div class="pw-plan-credits">${p.creditsPerDay} credits / day</div> 152 <ul class="pw-plan-features"> 153 ${(p.features || []).slice(0, 4).map((f) => `<li>${f}</li>`).join('')} 154 </ul> 155 <button class="pw-btn ${p.popular ? 'pw-btn-primary' : 'pw-btn-secondary'} pw-select" data-plan="${p.key}" 156 ${p.configured ? '' : 'disabled title="Not available yet"'}> 157 Start ${this.trialDays}-day trial 158 </button> 159 </div> 160 `).join(''); 161 162 body.innerHTML = `<div class="pw-plans">${cards}</div> 163 <p class="pw-fineprint">$0 charged today. After ${this.trialDays} days your selected plan begins. Cancel anytime before then.</p>`; 164 165 body.querySelectorAll('.pw-select').forEach((btn) => { 166 btn.addEventListener('click', () => this._startTrial(btn.dataset.plan, btn)); 167 }); 168 } 169 170 async _startTrial(planKey, btn) { 171 if (btn) { btn.disabled = true; btn.textContent = 'Preparing checkout\u2026'; } 172 this.selectedPlan = planKey; 173 try { 174 const res = await api.startTrial(planKey); 175 this.clientSecret = res.clientSecret; 176 const pubKey = res.publishableKey; 177 if (!this.clientSecret || !pubKey) { 178 throw new Error('Payment could not be initialized. Please try again.'); 179 } 180 if (typeof Stripe === 'undefined') { 181 throw new Error('Stripe failed to load. Check your connection and retry.'); 182 } 183 this.stripe = Stripe(pubKey); 184 this._renderPaymentForm(res); 185 } catch (err) { 186 showToast(err.message || 'Could not start trial', 'error'); 187 if (btn) { btn.disabled = false; btn.textContent = `Start ${this.trialDays}-day trial`; } 188 } 189 } 190 191 _renderPaymentForm(res) { 192 const body = this.overlay?.querySelector('.pw-body'); 193 if (!body) return; 194 195 const plan = this.trialPlans.find((p) => p.key === this.selectedPlan); 196 body.innerHTML = ` 197 <div class="pw-payment"> 198 <div class="pw-selected"> 199 <span>${plan ? plan.name : 'Plan'} \u2014 ${plan ? plan.priceLabel : ''}</span> 200 <button class="pw-link" id="pw-back">Change plan</button> 201 </div> 202 <div id="pw-payment-element"></div> 203 <div id="pw-payment-error" class="pw-error" style="display:none;"></div> 204 <button class="pw-btn pw-btn-primary" id="pw-submit"> 205 Start free trial \u2014 $0 today 206 </button> 207 <p class="pw-fineprint">Card saved securely by Stripe. First charge on ${this._trialEndLabel(res.trialEndsAt)}.</p> 208 </div> 209 `; 210 211 this.elements = this.stripe.elements({ clientSecret: this.clientSecret, appearance: { theme: 'night' } }); 212 const paymentElement = this.elements.create('payment'); 213 paymentElement.mount('#pw-payment-element'); 214 215 body.querySelector('#pw-back')?.addEventListener('click', () => this._renderPlans()); 216 body.querySelector('#pw-submit')?.addEventListener('click', () => this._confirm()); 217 } 218 219 _trialEndLabel(iso) { 220 if (!iso) return `in ${this.trialDays} days`; 221 try { 222 return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); 223 } catch { 224 return `in ${this.trialDays} days`; 225 } 226 } 227 228 async _confirm() { 229 const submit = this.overlay?.querySelector('#pw-submit'); 230 const errBox = this.overlay?.querySelector('#pw-payment-error'); 231 if (submit) { submit.disabled = true; submit.textContent = 'Confirming\u2026'; } 232 if (errBox) { errBox.style.display = 'none'; errBox.textContent = ''; } 233 234 try { 235 const { error, setupIntent } = await this.stripe.confirmSetup({ 236 elements: this.elements, 237 redirect: 'if_required', 238 }); 239 240 if (error) throw new Error(error.message || 'Card could not be saved.'); 241 if (!setupIntent || setupIntent.status !== 'succeeded') { 242 throw new Error('Card verification did not complete. Please try again.'); 243 } 244 245 const result = await api.confirmTrial(setupIntent.id); 246 this._renderSuccess(result); 247 window.dispatchEvent(new CustomEvent('payment:verified', { detail: result })); 248 } catch (err) { 249 if (errBox) { errBox.style.display = 'block'; errBox.textContent = err.message; } 250 if (submit) { submit.disabled = false; submit.textContent = 'Start free trial \u2014 $0 today'; } 251 } 252 } 253 254 _renderSuccess(result) { 255 const body = this.overlay?.querySelector('.pw-body'); 256 const header = this.overlay?.querySelector('.pw-header'); 257 if (header) header.style.display = 'none'; 258 if (!body) return; 259 body.innerHTML = ` 260 <div class="pw-success"> 261 <div class="pw-check">✓</div> 262 <h3>You're all set!</h3> 263 <p>Your ${this.trialDays}-day free trial has started. Build and publish freely \u2014 264 you won't be charged until ${this._trialEndLabel(result?.trialEndsAt)}.</p> 265 <button class="pw-btn pw-btn-primary" id="pw-done">Start building</button> 266 </div> 267 `; 268 body.querySelector('#pw-done')?.addEventListener('click', () => { 269 this.close(); 270 showToast('Free trial active â build away!', 'success'); 271 }); 272 } 273 274 // -------------------------------------------------------------------------- 275 // Styles 276 // -------------------------------------------------------------------------- 277 278 _injectStyles() { 279 if (document.getElementById('pw-styles')) return; 280 const style = document.createElement('style'); 281 style.id = 'pw-styles'; 282 style.textContent = ` 283 .pw-overlay { position:fixed; inset:0; z-index:10000; background:rgba(8,10,20,0.72); 284 backdrop-filter:blur(6px); display:flex; align-items:center; justify-content:center; padding:1.25rem; } 285 .pw-modal { position:relative; width:100%; max-width:720px; max-height:90vh; overflow-y:auto; 286 background:#12141c; border:1px solid rgba(255,255,255,0.08); border-radius:18px; 287 box-shadow:0 24px 80px rgba(0,0,0,0.5); color:#f4f5f8; } 288 .pw-close { position:absolute; top:14px; right:16px; background:none; border:none;
288 color:#9aa0b0; 289 font-size:1.6rem; line-height:1; cursor:pointer; } 290 .pw-close:hover { color:#fff; } 291 .pw-header { padding:2rem 2rem 1rem; text-align:center; } 292 .pw-badge { display:inline-block; background:rgba(99,102,241,0.15); color:#a5b4fc; 293 font-size:0.7rem; font-weight:700; letter-spacing:0.05em; text-transform:uppercase; 294 padding:4px 12px; border-radius:100px; margin-bottom:0.75rem; } 295 .pw-title { font-size:1.6rem; font-weight:800; margin:0 0 0.5rem; } 296 .pw-sub { font-size:0.9rem; color:#9aa0b0; margin:0; } 297 .pw-body { padding:1rem 2rem 2rem; } 298 .pw-loading { display:flex; justify-content:center; padding:3rem 0; } 299 .pw-spinner { width:36px; height:36px; border:3px solid rgba(255,255,255,0.15); 300 border-top-color:#6366f1; border-radius:50%; animation:pw-spin 0.8s linear infinite; } 301 @keyframes pw-spin { to { transform:rotate(360deg); } } 302 .pw-plans { display:grid; grid-template-columns:repeat(3,1fr); gap:1rem; } 303 @media (max-width:640px) { .pw-plans { grid-template-columns:1fr; } } 304 .pw-plan { position:relative; background:#171a24; border:1px solid rgba(255,255,255,0.08); 305 border-radius:14px; padding:1.25rem; display:flex; flex-direction:column; } 306 .pw-plan-popular { border-color:#6366f1; box-shadow:0 0 0 1px #6366f1 inset; } 307 .pw-plan-tag { position:absolute; top:-10px; left:50%; transform:translateX(-50%); 308 background:#6366f1; color:#fff; font-size:0.6rem; font-weight:700; text-transform:uppercase; 309 padding:2px 10px; border-radius:100px; white-space:nowrap; } 310 .pw-plan-name { font-weight:700; font-size:1rem; } 311 .pw-plan-price { font-size:1.6rem; font-weight:800; margin:0.25rem 0; } 312 .pw-plan-credits { font-size:0.8rem; color:#a5b4fc; margin-bottom:0.75rem; } 313 .pw-plan-features { list-style:none; padding:0; margin:0 0 1rem; font-size:0.75rem; color:#9aa0b0; 314 flex:1; } 315 .pw-plan-features li { padding:3px 0 3px 16px; position:relative; } 316 .pw-plan-features li::before { content:'\u2713'; position:absolute; left:0; color:#34d399; } 317 .pw-btn { width:100%; padding:0.7rem; border-radius:9px; border:none; font-weight:700; 318 font-size:0.85rem; cursor:pointer; transition:opacity 0.15s, transform 0.05s; } 319 .pw-btn:disabled { opacity:0.5; cursor:not-allowed; } 320 .pw-btn:not(:disabled):active { transform:scale(0.99); } 321 .pw-btn-primary { background:#6366f1; color:#fff; } 322 .pw-btn-primary:hover:not(:disabled) { background:#4f52e0; } 323 .pw-btn-secondary { background:rgba(255,255,255,0.08); color:#f4f5f8; } 324 .pw-btn-secondary:hover:not(:disabled) { background:rgba(255,255,255,0.14); } 325 .pw-fineprint { font-size:0.72rem; color:#7c8296; text-align:center; margin-top:1rem; } 326 .pw-payment { max-width:440px; margin:0 auto; } 327 .pw-selected { display:flex; justify-content:space-between; align-items:center; 328 font-size:0.85rem; font-weight:600; margin-bottom:1rem; } 329 .pw-link { background:none; border:none; color:#a5b4fc; font-size:0.8rem; cursor:pointer; padding:0; } 330 .pw-link:hover { text-decoration:underline; } 331 #pw-payment-element { margin-bottom:1rem; } 332 #pw-submit { margin-top:0.5rem; } 333 .pw-error { background:rgba(239,68,68,0.12); color:#fca5a5; border:1px solid rgba(239,68,68,0.3); 334 border-radius:9px; padding:0.7rem 0.9rem; font-size:0.8rem; margin-bottom:1rem; } 335 .pw-success { text-align:center; padding:2rem 1rem; max-width:420px; margin:0 auto; } 336 .pw-check { width:56px; height:56px; border-radius:50%; background:#34d399; color:#0b1120; 337 font-size:1.8rem; display:flex; align-items:center; justify-content:center; margin:0 auto 1rem; } 338 .pw-success h3 { font-size:1.3rem; font-weight:800; margin:0 0 0.5rem; } 339 .pw-success p { font-size:0.85rem; color:#9aa0b0; margin:0 0 1.5rem; } 340 .pw-trial-banner { position:fixed; bottom:16px; left:50%; transform:translateX(-50%); 341 z-index:9000; display:flex; align-items:center; gap:10px; background:#171a24; 342 border:1px solid rgba(99,102,241,0.4); color:#e6e8ef; font-size:0.8rem; font-weight:600; 343 padding:8px 14px; border-radius:100px; box-shadow:0 8px 30px rgba(0,0,0,0.4); } 344 .pw-trial-dot { width:8px; height:8px; border-radius:50%; background:#34d399; 345 box-shadow:0 0 0 3px rgba(52,211,153,0.2); } 346 .pw-trial-link { color:#a5b4fc; text-decoration:none; font-weight:700; } 347 .pw-trial-link:hover { text-decoration:underline; } 348 `; 349 document.head.appendChild(style); 350 } 351} 352 353export const paywall = new Paywall();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.