PageSourceSearch

https://tyga.ai/js/billing/paywall.js

js tyga.ai collected 2026-10-05 15:09:10 UTC 16,163 bytes, 353 lines download raw bytes

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">&times;</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 &amp; 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">&#10003;</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.