PageSourceSearch

https://boltscribe.ink/assets/views/subscription.js

js boltscribe.ink collected 2026-10-02 01:57:13 UTC 9,362 bytes, 224 lines download raw bytes

1import { html } from '../../shared/lib/html.js';
2import { signal } from '../lib/uhtml.js';
3
4export const title = 'Manage Subscription - Bolt Scribe';
5
6// Module-scoped signals: created ONCE, persist across re-renders. Creating these
7// inside the render body would mint new instances every render, so the
8// double-click guard (processing) and the error toast (checkoutError) would
9// bind to stale copies and reset on the next state-change re-render.
10const processing = signal(false);
11const checkoutError = signal('');
12
13/**
14 * Runs ONCE per route entry (client only, driven by app.js on path change).
15 * All side-effects live here: load plans, reconcile the subscription with
16 * Stripe (source of truth), then refresh account data in the background.
17 */
18export function mount(state, actions) {
19  // Reset interaction state on (re)entry so a prior navigation-mid-action can't
20  // leave the buttons disabled or a stale error toast showing.
21  processing.value = false;
22  checkoutError.value = '';
23  (async () => {
24    await actions.fetchPlans();
25    await actions.syncSubscription();
26    await actions.fetchAccount(true);
27  })().catch((err) => console.error('[subscription mount]', err));
28}
29
30export default (html, state, actions) => {
31  const plan = state.account?.plan || null;
32  const usage = state.account?.usage || null;
33  const subscription = state.account?.subscription || null;
34  const plans = state.plans || [];
35  const isLoading = state.loading?.account || false;
36  const error = state.account?.error || null;
37
38  // Every subscription action leaves for a Stripe-hosted flow: Stripe Checkout for
39  // a new purchase, the Customer Portal (deep-linked) for cancel / switch. The
40  // wording never names Stripe — to the user these are just Upgrade / Switch / Cancel.
41  const goTo = async (fn, errorMsg) => {
42    if (processing.value) return;
43    checkoutError.value = '';
44    processing.value = true;
45    try {
46      window.location.href = await fn();
47    } catch (err) {
48      checkoutError.value = err.message || errorMsg;
49      processing.value = false;
50    }
51  };
52  const handleCheckout = (planName) => goTo(() => actions.createCheckout(planName), 'Checkout failed. Please try again.');
53  const handleCancel = () => goTo(() => actions.openPortal({ flow: 'cancel' }), 'Unable to start cancellation.');
54  const handleSwitch = (planName) => goTo(() => actions.openPortal({ flow: 'switch', plan: planName }), 'Unable to change plan.');
55  const handleManage = () => goTo(() => actions.openPortal({}), 'Unable to open subscription management.');
56
57  const formatMinutes = (s) => s < 0 ? 'Unlimited' : Math.ceil(s / 60) + ' minutes / month';
58  const formatPrice = (cents) => cents === 0 ? '$0' : '$' + (cents / 100).toFixed(0);
59
60  const currentPlanId = plan?.id || 1;
61  const isPaidUser = currentPlanId > 1;
62  const isCancelling = !!subscription?.cancel_at;
63  const secondsAllowed = usage?.seconds_allowed || 1800;
64
65  // A deferred plan change (portal "apply at next billing cycle") surfaces as a
66  // pending target plan + effective date. Null when none is scheduled.
67  const pendingPlanId = subscription?.pending_plan_id ?? null;
68  const pendingSwitch = pendingPlanId
69    ? {
70        displayName: subscription.pending_plan_display_name,
71        effectiveAt: subscription.pending_effective_at,
72      }
73    : null;
74  const pendingDateStr = pendingSwitch?.effectiveAt
75    ? new Date(pendingSwitch.effectiveAt * 1000).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })
76    : '';
77
78  // Period / renewal info
79  let renewalText = '';
80  let notice = null;
81
82  if (usage?.period_end) {
83    const periodEnd = new Date(usage.period_end * 1000);
84    const dateStr = periodEnd.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
85
86    if (subscription?.status === 'past_due') {
87      renewalText = 'Payment failed - update your payment method';
88      notice = {
89        type: 'error',
90        title: 'Payment Failed',
91        msg: 'Please update your payment method to continue your subscription.'
92      };
93    } else if (subscription?.cancel_at) {
94      const cancelDate = new Date(subscription.cancel_at * 1000);
95      renewalText = 'Cancels ' + cancelDate.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
96      notice = {
97        type: 'warning',
98        title: 'Subscription Ending',
99        msg: 'Your subscription will end on ' + cancelDate.toLocaleDateString() + '. You can resubscribe anytime.'
100      };
101    } else if (pendingSwitch) {
102      renewalText = 'Switches to ' + pendingSwitch.displayName + ' on ' + pendingDateStr;
103    } else if (currentPlanId > 1) {
104      renewalText = 'Renews ' + dateStr;
105    } else {
106      renewalText = 'Resets ' + dateStr;
107    }
108  }
109
110  return html`
111    <main>
112      <article data-page="subscription">
113        <header>
114          <h1>Manage Subscription</h1>
115          <p>View and change your Bolt Scribe plan</p>
116        </header>
117
118        ${notice ? html`
119          <aside class="alert alert-${notice.type}">
120            <strong>${notice.title}</strong>
121            <p>${notice.msg}</p>
122          </aside>
123        ` : null}
124
125        ${error ? html`
126          <aside class="alert alert-error">
127            <strong>Couldn't load the latest details.</strong>
128            <p>Showing saved info — <button class="btn" onclick=${() => location.reload()}>retry</button>.</p>
129          </aside>
130        ` : null}
131
132        <section>
133          <h2>Current Plan</h2>
134          <dl>
135            <div>
136              <dt>Plan</dt>
137              <dd>${plan?.display_name || 'Free'}</dd>
138            </div>
139            <div>
140              <dt>Allowance</dt>
141              <dd>${formatMinutes(secondsAllowed)}</dd>
142            </div>
143            ${renewalText ? html`
144              <div>
145                <dt>Status</dt>
146                <dd>${renewalText}</dd>
147              </div>
148            ` : null}
149          </dl>
150          ${isPaidUser ? html`
151            <footer>
152              ${isCancelling
153                ? html`<button class="btn btn-primary" @click=${() => handleManage()}>Resume subscription</button>`
154                : html`
155                  ${pendingSwitch ? html`<button class="btn" @click=${() => handleManage()}>Manage plan</button>` : null}
156                  <button class="btn" @click=${() => handleCancel()}>Cancel subscription</button>
157                `}
158            </footer>
159          ` : null}
160        </section>
161
162        <section>
163          <h2>Available Plans</h2>
164          <p>Choose the plan that fits your needs</p>
165
166          ${checkoutError.value ? html`
167            <aside class="alert alert-error">${checkoutError.value}</aside>
168          ` : null}
169
170          ${plans.length === 0 ? html`<p class="text-muted">Loading plans…</p>` : null}
171
172          ${plans.map((p) => {
173            const isCurrent = p.id === currentPlanId;
174            const isAnnual = p.name === 'annual';
175            const priceSuffix = p.price_cents === 0 ? '' : (isAnnual ? '/yr' : '/mo');
176            const periodWord = p.price_cents === 0 ? 'forever' : (isAnnual ? 'per year' : 'per month');
177
178            const features = [formatMinutes(p.monthly_seconds)];
179            if (p.price_cents === 0) {
180              features.push('Default templates', 'Desktop app + extension', 'Community support');
181            } else {
182              features.push('Custom templates', 'Priority processing', 'Email support');
183            }
184
185            // Card state is shown by a ribbon (Current / Next period), never a
186            // disabled button. A button only appears for a real action:
187            //   - free user on a paid plan → "Upgrade" (Stripe Checkout)
188            //   - paid user on the other paid plan, no switch pending → "Switch to …"
189            // The current plan, the pending target, and the Free card get no
190            // button (you reach Free by canceling, which lives on Current Plan).
191            const isPendingTarget = pendingPlanId === p.id;
192            let btnText = null, btnPrimary = false, btnDisabled = false, btnAction = null;
193            if (!isCurrent && !isPendingTarget) {
194              if (!isPaidUser) {
195                btnText = 'Upgrade';
195 btnPrimary = true; btnDisabled = processing.value; btnAction = () => handleCheckout(p.name);
196              } else if (p.id !== 1 && !pendingSwitch) {
197                btnText = p.name === 'annual' ? 'Switch to Annual' : 'Switch to Monthly';
198                btnPrimary = true; btnDisabled = processing.value; btnAction = () => handleSwitch(p.name);
199              }
200            }
201
202            return html`
203              <article ?data-current=${isCurrent} ?data-pending=${isPendingTarget}>
204                <h3>${p.display_name}</h3>
205                <p><strong>${formatPrice(p.price_cents)}</strong>${priceSuffix}</p>
206                <p>${periodWord}</p>
207                <ul>
208                  ${features.map(f => html`<li>${f}</li>`)}
209                </ul>
210                ${btnText !== null ? html`
211                  <button
212                    class=${['btn', btnPrimary && 'btn-primary'].filter(Boolean).join(' ')}
213                    ?disabled=${btnDisabled}
214                    @click=${btnAction}
215                  >${btnText}</button>
216                ` : null}
217              </article>
218            `;
219          })}
220        </section>
221      </article>
222    </main>
223  `;
224};

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.