|
20 | 20 | nav.top .email { color: #888; font-size: 0.85rem; } |
21 | 21 | .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; } |
22 | 22 | .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} } |
27 | 37 | h1 { font-size: 1.5rem; color: #fff; font-weight: 700; letter-spacing: -0.02em; margin: 24px 0 4px; } |
28 | 38 | .subtitle { color: #666; font-size: 0.85rem; margin-bottom: 20px; } |
29 | 39 | .group { margin-bottom: 28px; } |
|
47 | 57 | .small-btn.secondary { background: transparent; color: #888; border-color: #222; } |
48 | 58 | .small-btn.secondary:hover { border-color: #4af; color: #4af; background: transparent; } |
49 | 59 | .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; } |
50 | 61 | .small-btn.danger { background: transparent; color: #f88; border: 1px solid #4a1818; } |
51 | 62 | .small-btn.danger:hover:not(:disabled) { background: #2a1010; border-color: #6a2626; color: #fbb; } |
52 | 63 | .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> |
335 | 346 | var next = new Date(paidAt.getTime() + days * 86400000); |
336 | 347 | return ' · next renewal ' + next.toISOString().slice(0, 10); |
337 | 348 | } |
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') |
343 | 410 | ? '<a href="#" class="js-cancel-sub">Cancel subscription</a>' |
344 | 411 | : '<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; |
349 | 412 | } |
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); |
354 | 424 | } |
355 | 425 |
|
| 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 | + |
356 | 451 | function cancelSubscription(e) { |
357 | 452 | if (e) e.preventDefault(); |
358 | 453 | 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> |
396 | 491 | wireCards(); |
397 | 492 | } |
398 | 493 |
|
399 | | - function renderError() { |
400 | | - $content.innerHTML = '<div class="error">Failed to load resources — <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 + ' — <button type="button" class="small-btn" id="retryBtn">Retry</button></div>'; |
401 | 499 | document.getElementById('retryBtn').addEventListener('click', loadResources); |
402 | 500 | } |
403 | 501 |
|
@@ -515,20 +613,23 @@ <h2 id="apikey-heading">API token for CLI / agent</h2> |
515 | 613 |
|
516 | 614 | function loadResources() { |
517 | 615 | $content.innerHTML = '<p class="subtitle" style="text-align:center;padding:24px 0;">Loading resources…</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 }) |
519 | 620 | .then(function (res) { |
| 621 | + clearTimeout(timer); |
520 | 622 | if (res.status === 401) { goStart(); return null; } |
521 | 623 | if (!res.ok) throw new Error('HTTP ' + res.status); |
522 | 624 | return res.json(); |
523 | 625 | }) |
524 | 626 | .then(function (data) { |
525 | 627 | if (data === null) return; |
526 | | - // Accept raw array OR {resources:[...]} |
527 | 628 | var list = Array.isArray(data) ? data : (data && Array.isArray(data.resources) ? data.resources : []); |
528 | | - renderBanner(list); |
529 | 629 | renderResources(list); |
530 | 630 | }) |
531 | | - .catch(renderError); |
| 631 | + .catch(function (err) { clearTimeout(timer); renderError(err); }); |
| 632 | + // Plan is loaded separately in the boot path; nothing to do here. |
532 | 633 | } |
533 | 634 |
|
534 | 635 | function migrate(tok) { |
@@ -590,6 +691,11 @@ <h2 id="apikey-heading">API token for CLI / agent</h2> |
590 | 691 | toast('Payment received. Tier will update shortly — refresh in a moment.', 'err'); |
591 | 692 | } |
592 | 693 | } |
| 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 | + |
593 | 699 | var next = Promise.resolve(); |
594 | 700 | if (claimToken) next = migrate(claimToken).then(function (ok) { |
595 | 701 | // Clear the token either way — stale localStorage would otherwise |
|
0 commit comments