Skip to content

Commit 4529482

Browse files
dashboard: call /api/me/plan on boot + free-tier upgrade modal
Two fixes to surface plan state and drive conversion: 1. /api/me/plan now fires explicitly in the boot path instead of only being called inside loadResources(). The plan banner paints as soon as auth resolves, even if resources are still loading. 2. When plan_tier != 'paid', show a one-time upgrade modal on dashboard visit with Subscribe vs Maybe-later actions. Dismissal snoozes the nag for 24h via localStorage so we don't badger the user on every refresh. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4f269b7 commit 4529482

1 file changed

Lines changed: 129 additions & 23 deletions

File tree

dashboard.html

Lines changed: 129 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,20 @@
2020
nav.top .email { color: #888; font-size: 0.85rem; }
2121
.ghost-btn { background: transparent; color: #888; border: 1px solid #222; padding: 6px 14px; border-radius: 6px; font-family: inherit; font-size: 0.82rem; cursor: pointer; transition: all 0.15s; }
2222
.ghost-btn:hover { border-color: #4af; color: #4af; }
23-
.banner { margin: 24px 0; padding: 14px 18px; border-radius: 8px; border: 1px solid #222; background: #111; font-size: 0.9rem; display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
24-
.banner.paid { border-color: #1d4a20; background: #0d1a0e; color: #cfe8d0; }
25-
.banner.free { border-color: #2a2418; background: #141110; color: #d8c7a0; }
26-
.banner a { color: #4af; font-weight: 600; }
23+
.banner { margin: 24px 0; padding: 18px 20px; border-radius: 10px; border: 1px solid #222; background: #111; display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
24+
.banner .plan-head { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; }
25+
.banner .plan-tag { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.12em; padding: 3px 10px; border-radius: 999px; font-weight: 700; }
26+
.banner .plan-line { font-size: 1rem; font-weight: 600; color: #fff; }
27+
.banner .plan-sub { font-size: 0.82rem; color: #888; margin-top: 2px; }
28+
.banner.paid { border-color: #1d4a20; background: #0d1a0e; }
29+
.banner.paid .plan-tag { background: #1d4a20; color: #cfe8d0; }
30+
.banner.free { border-color: #2a2418; background: #141110; }
31+
.banner.free .plan-tag { background: #2a2418; color: #d8c7a0; }
32+
.banner.warn { border-color: #4a1818; background: #1a0d0d; }
33+
.banner.warn .plan-tag { background: #4a1818; color: #f8a; }
34+
.banner a { color: #4af; font-weight: 600; font-size: 0.9rem; }
35+
.banner .skeleton { width: 180px; height: 14px; border-radius: 4px; background: linear-gradient(90deg,#1a1a1a,#222,#1a1a1a); background-size:200% 100%; animation: shine 1.2s linear infinite; }
36+
@keyframes shine { 0% {background-position: 0 0} 100% {background-position: -200% 0} }
2737
h1 { font-size: 1.5rem; color: #fff; font-weight: 700; letter-spacing: -0.02em; margin: 24px 0 4px; }
2838
.subtitle { color: #666; font-size: 0.85rem; margin-bottom: 20px; }
2939
.group { margin-bottom: 28px; }
@@ -47,6 +57,7 @@
4757
.small-btn.secondary { background: transparent; color: #888; border-color: #222; }
4858
.small-btn.secondary:hover { border-color: #4af; color: #4af; background: transparent; }
4959
.small-btn.copied { background: #4a4; border-color: #4a4; color: #e6ffe6; }
60+
.small-btn.primary { padding: 8px 16px; font-size: 0.85rem; display: inline-flex; align-items: center; }
5061
.small-btn.danger { background: transparent; color: #f88; border: 1px solid #4a1818; }
5162
.small-btn.danger:hover:not(:disabled) { background: #2a1010; border-color: #6a2626; color: #fbb; }
5263
.upgrade-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 100; padding: 20px; }
@@ -335,24 +346,108 @@ <h2 id="apikey-heading">API token for CLI / agent</h2>
335346
var next = new Date(paidAt.getTime() + days * 86400000);
336347
return ' · next renewal ' + next.toISOString().slice(0, 10);
337348
}
338-
function renderBanner(list) {
339-
var label = planLabel(currentUser);
340-
if (label) {
341-
var status = (currentUser && currentUser.subscription_status) || '';
342-
var action = (status === 'active')
349+
function renderBannerLoading() {
350+
$banner.innerHTML = '<div class="banner"><div><div class="skeleton"></div></div><div class="skeleton" style="width:90px;"></div></div>';
351+
}
352+
353+
// Show the proactive upgrade modal at most once per 24h per browser.
354+
// Users who dismissed it recently don't see it again until the cool-down
355+
// expires — avoids badgering every page load.
356+
var UPGRADE_NAG_KEY = 'instanode_upgrade_nag_until';
357+
function shouldNagUpgrade() {
358+
try {
359+
var until = parseInt(localStorage.getItem(UPGRADE_NAG_KEY) || '0', 10);
360+
return !(until && Date.now() < until);
361+
} catch (e) { return true; }
362+
}
363+
function snoozeUpgradeNag(hours) {
364+
try { localStorage.setItem(UPGRADE_NAG_KEY, String(Date.now() + hours * 3600 * 1000)); } catch (e) {}
365+
}
366+
function showFreeTierUpgradeModal() {
367+
if (!shouldNagUpgrade()) return;
368+
if (document.querySelector('.free-nag-overlay')) return; // already open
369+
var overlay = document.createElement('div');
370+
overlay.className = 'upgrade-overlay free-nag-overlay';
371+
overlay.innerHTML =
372+
'<div class="upgrade-modal" role="dialog" aria-modal="true">' +
373+
' <h3>You\'re on the free tier</h3>' +
374+
' <p>Resources expire in 24 hours and you can provision at most 5 per day. Upgrade to <strong>Developer</strong> ($12/mo or $120/yr) for permanent resources, no rate caps, and priority support.</p>' +
375+
' <div class="upgrade-actions">' +
376+
' <button type="button" class="small-btn js-nag-snooze">Maybe later</button>' +
377+
' <a href="/pricing.html" class="small-btn primary">See plans →</a>' +
378+
' </div>' +
379+
'</div>';
380+
overlay.addEventListener('click', function (e) {
381+
if (e.target === overlay) {
382+
snoozeUpgradeNag(24);
383+
overlay.remove();
384+
}
385+
});
386+
overlay.querySelector('.js-nag-snooze').addEventListener('click', function () {
387+
snoozeUpgradeNag(24);
388+
overlay.remove();
389+
});
390+
document.body.appendChild(overlay);
391+
}
392+
393+
function renderBannerFromPlan(plan) {
394+
if (!plan) { $banner.innerHTML = ''; return; }
395+
var tier = (plan.plan_tier || 'free').toLowerCase();
396+
var status = (plan.subscription_status || '').toLowerCase();
397+
var variant = 'free';
398+
var tagText = 'FREE TIER';
399+
var action = '<a href="/pricing.html">Subscribe →</a>';
400+
var subText = 'Resources expire in 24 hours. Per-subnet cap: 5 provisions/day.';
401+
if (tier === 'paid') {
402+
variant = status === 'halted' ? 'warn' : 'paid';
403+
tagText = status === 'halted' ? 'PAYMENT HALTED' : (status === 'cancelled' ? 'CANCELLING' : 'ACTIVE');
404+
subText = status === 'cancelled'
405+
? 'Paid access continues until the current billing period ends.'
406+
: (status === 'halted'
407+
? 'Your card was declined. Update it and retry from the pricing page.'
408+
: 'Resources are permanent. Cancel anytime — access continues until period end.');
409+
action = (status === 'active')
343410
? '<a href="#" class="js-cancel-sub">Cancel subscription</a>'
344411
: '<a href="/pricing.html">Manage plan</a>';
345-
$banner.innerHTML = '<div class="banner paid"><span><strong>' + label.line + '</strong>' + nextRenewal(currentUser) + '</span>' + action + '</div>';
346-
var cancelLink = $banner.querySelector('.js-cancel-sub');
347-
if (cancelLink) cancelLink.addEventListener('click', cancelSubscription);
348-
return;
349412
}
350-
var hasPaid = Array.isArray(list) && list.some(function (r) { return (r.tier || '').toLowerCase() === 'paid'; });
351-
$banner.innerHTML = hasPaid
352-
? '<div class="banner paid"><span><strong>Developer Plan</strong> · resources are permanent.</span><a href="/pricing.html">Manage plan</a></div>'
353-
: '<div class="banner free"><span><strong>Free tier</strong> · resources expire in 24h.</span><a href="/pricing.html">Upgrade →</a></div>';
413+
$banner.innerHTML =
414+
'<div class="banner ' + variant + '">' +
415+
'<div>' +
416+
'<div class="plan-head"><span class="plan-tag">' + tagText + '</span></div>' +
417+
'<div class="plan-line">' + (plan.human_label || '—') + '</div>' +
418+
'<div class="plan-sub">' + subText + '</div>' +
419+
'</div>' +
420+
'<div>' + action + '</div>' +
421+
'</div>';
422+
var cancelLink = $banner.querySelector('.js-cancel-sub');
423+
if (cancelLink) cancelLink.addEventListener('click', cancelSubscription);
354424
}
355425

426+
function loadPlan() {
427+
renderBannerLoading();
428+
return fetch(API + '/api/me/plan', { credentials: 'include' })
429+
.then(function (res) {
430+
if (res.status === 401) { goStart(); return null; }
431+
if (!res.ok) throw new Error('plan HTTP ' + res.status);
432+
return res.json();
433+
})
434+
.then(function (plan) {
435+
if (plan) {
436+
renderBannerFromPlan(plan);
437+
if ((plan.plan_tier || '').toLowerCase() !== 'paid') {
438+
showFreeTierUpgradeModal();
439+
}
440+
}
441+
return plan;
442+
})
443+
.catch(function () {
444+
$banner.innerHTML = '<div class="banner warn"><div><strong>Could not load plan.</strong><div class="plan-sub">Refresh to retry.</div></div><a href="#" onclick="location.reload()">Retry</a></div>';
445+
});
446+
}
447+
448+
// Retained for any legacy caller; simply kicks off the plan fetch.
449+
function renderBanner() { loadPlan(); }
450+
356451
function cancelSubscription(e) {
357452
if (e) e.preventDefault();
358453
if (!confirm('Cancel subscription? You\'ll keep paid access until the end of the current billing period.')) return;
@@ -396,8 +491,11 @@ <h2 id="apikey-heading">API token for CLI / agent</h2>
396491
wireCards();
397492
}
398493

399-
function renderError() {
400-
$content.innerHTML = '<div class="error">Failed to load resources &mdash; <button type="button" class="small-btn" id="retryBtn">Retry</button></div>';
494+
function renderError(err) {
495+
var detail = '';
496+
if (err && err.name === 'AbortError') detail = ' (timed out after 10s)';
497+
else if (err && err.message) detail = ' (' + err.message + ')';
498+
$content.innerHTML = '<div class="error">Failed to load resources' + detail + ' &mdash; <button type="button" class="small-btn" id="retryBtn">Retry</button></div>';
401499
document.getElementById('retryBtn').addEventListener('click', loadResources);
402500
}
403501

@@ -515,20 +613,23 @@ <h2 id="apikey-heading">API token for CLI / agent</h2>
515613

516614
function loadResources() {
517615
$content.innerHTML = '<p class="subtitle" style="text-align:center;padding:24px 0;">Loading resources&hellip;</p>';
518-
fetch(API + '/api/me/resources', { credentials: 'include' })
616+
// Hard 10s timeout so a stuck fetch never leaves the UI on "Loading…".
617+
var controller = new AbortController();
618+
var timer = setTimeout(function () { controller.abort(); }, 10000);
619+
fetch(API + '/api/me/resources', { credentials: 'include', signal: controller.signal })
519620
.then(function (res) {
621+
clearTimeout(timer);
520622
if (res.status === 401) { goStart(); return null; }
521623
if (!res.ok) throw new Error('HTTP ' + res.status);
522624
return res.json();
523625
})
524626
.then(function (data) {
525627
if (data === null) return;
526-
// Accept raw array OR {resources:[...]}
527628
var list = Array.isArray(data) ? data : (data && Array.isArray(data.resources) ? data.resources : []);
528-
renderBanner(list);
529629
renderResources(list);
530630
})
531-
.catch(renderError);
631+
.catch(function (err) { clearTimeout(timer); renderError(err); });
632+
// Plan is loaded separately in the boot path; nothing to do here.
532633
}
533634

534635
function migrate(tok) {
@@ -590,6 +691,11 @@ <h2 id="apikey-heading">API token for CLI / agent</h2>
590691
toast('Payment received. Tier will update shortly — refresh in a moment.', 'err');
591692
}
592693
}
694+
// Plan loads first, independently of resources — the free-tier
695+
// upgrade modal needs to decide whether to appear before the user
696+
// sees anything else.
697+
loadPlan();
698+
593699
var next = Promise.resolve();
594700
if (claimToken) next = migrate(claimToken).then(function (ok) {
595701
// Clear the token either way — stale localStorage would otherwise

0 commit comments

Comments
 (0)