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.