ft-scout 8.0.6 → 8.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/web/app.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /* ==========================================================================
2
- FrontTerrain Scout — Multi-Page Application Engine v8.0.6
2
+ FrontTerrain Scout — Multi-Page Application Engine v8.0.8
3
3
  ========================================================================== */
4
4
 
5
5
  const FIREBASE_CONFIG = {
@@ -44,6 +44,9 @@ let state = {
44
44
  creditsUsed: 0,
45
45
  creditsTotal: 100,
46
46
  renewAt: null,
47
+ cancelAtPeriodEnd: false,
48
+ pendingDowngrade: null,
49
+ paidTier: null,
47
50
  byokEnabled: false,
48
51
  customLlmProvider: 'nvidia',
49
52
  customApiKey: '',
@@ -52,6 +55,7 @@ let state = {
52
55
  };
53
56
 
54
57
  let pendingCheckoutTier = null;
58
+ let pendingDowngradeTier = null;
55
59
 
56
60
  // Audio Synthesizer via Web Audio API
57
61
  let audioCtx = null;
@@ -113,9 +117,13 @@ function applyTierUpgrade(tier, transactionId) {
113
117
  const quota = targetTier === 'enterprise' ? 10000 : (targetTier === 'pro' ? 1000 : 100);
114
118
 
115
119
  state.currentTier = targetTier;
120
+ state.paidTier = targetTier;
121
+ state.cancelAtPeriodEnd = false;
122
+ state.pendingDowngrade = null;
116
123
  state.creditsTotal = quota;
117
124
  state.creditsUsed = 0;
118
- state.renewAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString();
125
+ const durationDays = state.billingCycle === 'annual' ? 365 : 30;
126
+ state.renewAt = new Date(Date.now() + durationDays * 24 * 60 * 60 * 1000).toISOString();
119
127
 
120
128
  saveSessionToStorage();
121
129
  renderAllDynamicComponents();
@@ -205,6 +213,59 @@ function timeAgo(dateInput) {
205
213
  return `${days}d ago`;
206
214
  }
207
215
 
216
+ // Date and Expiration Helpers
217
+ function formatRenewDate(isoDateStr) {
218
+ if (!isoDateStr) return 'End of billing period';
219
+ try {
220
+ const d = new Date(isoDateStr);
221
+ if (isNaN(d.getTime())) return 'End of billing period';
222
+ return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
223
+ } catch {
224
+ return 'End of billing period';
225
+ }
226
+ }
227
+
228
+ function isSubscriptionExpired() {
229
+ if (!state.renewAt) return true;
230
+ const renewDate = new Date(state.renewAt);
231
+ if (isNaN(renewDate.getTime())) return true;
232
+ return Date.now() >= renewDate.getTime();
233
+ }
234
+
235
+ function checkSubscriptionExpiration() {
236
+ if (!state.renewAt) return;
237
+ const renewDate = new Date(state.renewAt);
238
+ if (isNaN(renewDate.getTime())) return;
239
+
240
+ const now = new Date();
241
+ if (now >= renewDate) {
242
+ if (state.cancelAtPeriodEnd) {
243
+ // Expiration date has arrived! Transition gracefully to pending downgrade tier
244
+ const targetTier = state.pendingDowngrade || 'free';
245
+ state.currentTier = targetTier;
246
+ state.paidTier = targetTier === 'free' ? null : targetTier;
247
+ state.cancelAtPeriodEnd = false;
248
+ state.pendingDowngrade = null;
249
+ state.creditsTotal = targetTier === 'pro' ? 1000 : (targetTier === 'enterprise' ? 10000 : 100);
250
+ state.creditsUsed = 0;
251
+ state.renewAt = new Date(now.getFullYear(), now.getMonth() + 1, 1, 0, 0, 0).toISOString();
252
+ saveSessionToStorage();
253
+ if (state.serverConnected) syncAuthWithServer();
254
+ showToast(`Your subscription period has ended. Account transitioned to ${TIER_DETAILS[targetTier].name}.`, 'info');
255
+ } else if (state.currentTier !== 'free') {
256
+ // Auto-renew monthly credits cycle
257
+ const nextRenewal = new Date(renewDate);
258
+ while (nextRenewal <= now) {
259
+ nextRenewal.setMonth(nextRenewal.getMonth() + 1);
260
+ }
261
+ state.renewAt = nextRenewal.toISOString();
262
+ state.creditsUsed = 0;
263
+ saveSessionToStorage();
264
+ if (state.serverConnected) syncAuthWithServer();
265
+ }
266
+ }
267
+ }
268
+
208
269
  // Load Subscription Tier & Dynamic Credits from Local Storage
209
270
  function loadTierAndCreditsFromStorage() {
210
271
  const savedTier = localStorage.getItem('scout_current_tier');
@@ -230,10 +291,17 @@ function loadTierAndCreditsFromStorage() {
230
291
  state.renewAt = new Date(now.getFullYear(), now.getMonth() + 1, 1, 0, 0, 0).toISOString();
231
292
  }
232
293
 
294
+ state.cancelAtPeriodEnd = localStorage.getItem('scout_cancel_at_period_end') === 'true';
295
+ state.pendingDowngrade = localStorage.getItem('scout_pending_downgrade') || null;
296
+ const savedPaidTier = localStorage.getItem('scout_paid_tier');
297
+ state.paidTier = (savedPaidTier && TIER_DETAILS[savedPaidTier]) ? savedPaidTier : (state.currentTier !== 'free' ? state.currentTier : null);
298
+
233
299
  state.byokEnabled = localStorage.getItem('scout_byok_enabled') === 'true';
234
300
  state.customLlmProvider = localStorage.getItem('scout_byok_provider') || 'nvidia';
235
301
  state.customApiKey = localStorage.getItem('scout_byok_key') || '';
236
302
  state.customBaseUrl = localStorage.getItem('scout_byok_endpoint') || '';
303
+
304
+ checkSubscriptionExpiration();
237
305
  }
238
306
 
239
307
  // Render All Dynamic Components
@@ -283,6 +351,78 @@ function closeCheckoutModal() {
283
351
  if (container) container.innerHTML = '';
284
352
  }
285
353
 
354
+ // Downgrade & Cancellation Modal Handlers
355
+ function openDowngradeModal(targetTier = 'free') {
356
+ pendingDowngradeTier = targetTier;
357
+ const currentInfo = TIER_DETAILS[state.currentTier] || TIER_DETAILS.pro;
358
+ const renewDateFormatted = formatRenewDate(state.renewAt);
359
+
360
+ if (getEl('downgrade-current-plan')) {
361
+ getEl('downgrade-current-plan').innerText = currentInfo.name;
362
+ }
363
+ if (getEl('downgrade-expiration-date')) {
364
+ getEl('downgrade-expiration-date').innerText = renewDateFormatted;
365
+ }
366
+ if (getEl('downgrade-modal-desc')) {
367
+ getEl('downgrade-modal-desc').innerHTML = `You are currently subscribed to <strong class="text-bright">${escapeHtml(currentInfo.name)}</strong>.`;
368
+ }
369
+
370
+ const modal = getEl('downgrade-modal');
371
+ if (modal) modal.classList.remove('hidden');
372
+ }
373
+
374
+ function closeDowngradeModal() {
375
+ pendingDowngradeTier = null;
376
+ const modal = getEl('downgrade-modal');
377
+ if (modal) modal.classList.add('hidden');
378
+ }
379
+
380
+ function confirmScheduleDowngrade() {
381
+ const targetTier = pendingDowngradeTier || 'free';
382
+ state.cancelAtPeriodEnd = true;
383
+ state.pendingDowngrade = targetTier;
384
+ saveSessionToStorage();
385
+ if (state.serverConnected) syncAuthWithServer();
386
+ closeDowngradeModal();
387
+ renderAllDynamicComponents();
388
+ showToast(`Subscription will cancel on ${formatRenewDate(state.renewAt)}. You retain all ${TIER_DETAILS[state.currentTier].name} features until then!`, 'warning');
389
+ }
390
+
391
+ function confirmImmediateDowngrade() {
392
+ const targetTier = pendingDowngradeTier || 'free';
393
+ state.currentTier = targetTier;
394
+ state.paidTier = targetTier === 'free' ? null : targetTier;
395
+ state.cancelAtPeriodEnd = false;
396
+ state.pendingDowngrade = null;
397
+ state.creditsTotal = targetTier === 'pro' ? 1000 : (targetTier === 'enterprise' ? 10000 : 100);
398
+ state.creditsUsed = 0;
399
+ const now = new Date();
400
+ state.renewAt = new Date(now.getFullYear(), now.getMonth() + 1, 1, 0, 0, 0).toISOString();
401
+ saveSessionToStorage();
402
+ if (state.serverConnected) syncAuthWithServer();
403
+ closeDowngradeModal();
404
+ renderAllDynamicComponents();
405
+ showToast(`Downgraded to ${TIER_DETAILS[targetTier].name} immediately.`, 'info');
406
+ }
407
+
408
+ function resumeSubscription() {
409
+ if (!state.currentTier || state.currentTier === 'free') {
410
+ if (state.paidTier && state.paidTier !== 'free' && !isSubscriptionExpired()) {
411
+ state.currentTier = state.paidTier;
412
+ state.creditsTotal = state.currentTier === 'pro' ? 1000 : 10000;
413
+ } else {
414
+ return;
415
+ }
416
+ }
417
+ state.cancelAtPeriodEnd = false;
418
+ state.pendingDowngrade = null;
419
+ state.paidTier = state.currentTier;
420
+ saveSessionToStorage();
421
+ if (state.serverConnected) syncAuthWithServer();
422
+ renderAllDynamicComponents();
423
+ showToast(`🎉 Subscription resumed! Your ${TIER_DETAILS[state.currentTier].name} is active and will auto-renew.`, 'success');
424
+ }
425
+
286
426
  // Render and initialize Razorpay button UI
287
427
  function renderRazorpayButton(targetTier) {
288
428
  const isAnnual = state.billingCycle === 'annual';
@@ -293,7 +433,7 @@ function renderRazorpayButton(targetTier) {
293
433
  btnRzp.disabled = false;
294
434
  btnRzp.innerHTML = `
295
435
  <i class="fa-solid fa-bolt text-warning" style="font-size: 18px;"></i>
296
- <span>Pay ₹${inrAmount.toLocaleString('en-IN')} with UPI / Cards (Razorpay)</span>
436
+ <span>Pay with Razorpay</span>
297
437
  `;
298
438
  }
299
439
 
@@ -310,13 +450,38 @@ async function handleRazorpayCheckout() {
310
450
  btnRzp.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Connecting to Razorpay...';
311
451
  }
312
452
 
313
- const apiBase = state.apiBaseUrl || (window.location.origin.includes('localhost') || window.location.origin.includes('127.0.0.1') ? '' : 'http://localhost:9120');
314
-
315
453
  try {
316
- // Fetch public Razorpay key ID from backend if not yet cached
317
- if (!state.razorpayKeyId) {
454
+ // Ensure Razorpay SDK is loaded
455
+ if (typeof window.Razorpay !== 'function') {
456
+ await new Promise((resolve, reject) => {
457
+ const existingScript = document.querySelector('script[src*="checkout.razorpay.com"]');
458
+ if (existingScript && window.Razorpay) return resolve();
459
+
460
+ const script = document.createElement('script');
461
+ script.src = 'https://checkout.razorpay.com/v1/checkout.js';
462
+ script.async = true;
463
+ script.onload = () => resolve();
464
+ script.onerror = () => reject(new Error('Unable to load Razorpay checkout SDK. Please verify your internet connection.'));
465
+ document.head.appendChild(script);
466
+ });
467
+ }
468
+
469
+ // Determine safe API base URL for local CLI server if connected
470
+ const isHttpsPage = window.location.protocol === 'https:';
471
+ let safeApiBase = '';
472
+ if (state.serverConnected && state.apiBaseUrl) {
473
+ // Prevent mixed content: never request http:// from https://
474
+ if (!(isHttpsPage && state.apiBaseUrl.startsWith('http://'))) {
475
+ safeApiBase = state.apiBaseUrl;
476
+ }
477
+ } else if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
478
+ safeApiBase = '';
479
+ }
480
+
481
+ // Fetch Razorpay key ID from backend if available, or use public key
482
+ if (!state.razorpayKeyId && safeApiBase) {
318
483
  try {
319
- const configRes = await fetch(`${apiBase}/api/razorpay/config`, { method: 'GET' });
484
+ const configRes = await fetch(`${safeApiBase}/api/razorpay/config`, { method: 'GET' });
320
485
  if (configRes.ok) {
321
486
  const cfg = await configRes.json();
322
487
  if (cfg && cfg.key_id) {
@@ -330,72 +495,81 @@ async function handleRazorpayCheckout() {
330
495
 
331
496
  const keyId = state.razorpayKeyId || (window.RAZORPAY_KEY_ID || 'rzp_live_Tajz48jXVm0H1Y');
332
497
 
333
- // STEP 1: BACKEND - Call POST /api/create-order
334
- const orderRes = await fetch(`${apiBase}/api/create-order`, {
335
- method: 'POST',
336
- headers: { 'Content-Type': 'application/json' },
337
- body: JSON.stringify({
338
- amount: amountInPaise,
339
- currency: 'INR',
340
- receipt: `rcpt_${Date.now()}_${Math.floor(Math.random() * 1000)}`,
341
- notes: {
342
- tier: targetTier,
343
- billingCycle: isAnnual ? 'annual' : 'monthly',
344
- userEmail: state.user?.email || '',
345
- },
346
- }),
347
- });
348
-
349
- if (!orderRes.ok) {
350
- const errData = await orderRes.json().catch(() => ({}));
351
- throw new Error(errData.error || `Order creation failed: HTTP ${orderRes.status}`);
352
- }
498
+ // Attempt server-side order creation if backend server is available
499
+ let orderId = null;
500
+ if (safeApiBase || (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')) {
501
+ try {
502
+ const controller = new AbortController();
503
+ const timeoutId = setTimeout(() => controller.abort(), 4000);
504
+ const orderRes = await fetch(`${safeApiBase}/api/create-order`, {
505
+ method: 'POST',
506
+ headers: { 'Content-Type': 'application/json' },
507
+ body: JSON.stringify({
508
+ amount: amountInPaise,
509
+ currency: 'INR',
510
+ receipt: `rcpt_${Date.now()}_${Math.floor(Math.random() * 1000)}`,
511
+ notes: {
512
+ tier: targetTier,
513
+ billingCycle: isAnnual ? 'annual' : 'monthly',
514
+ userEmail: state.user?.email || '',
515
+ },
516
+ }),
517
+ signal: controller.signal,
518
+ });
519
+ clearTimeout(timeoutId);
353
520
 
354
- const order = await orderRes.json();
355
- if (!order || !order.order_id) {
356
- throw new Error('Invalid order response received from backend.');
521
+ if (orderRes.ok) {
522
+ const orderData = await orderRes.json();
523
+ if (orderData && orderData.order_id) {
524
+ orderId = orderData.order_id;
525
+ }
526
+ }
527
+ } catch (orderErr) {
528
+ console.warn('Server order creation unavailable or skipped, proceeding with direct checkout:', orderErr);
529
+ }
357
530
  }
358
531
 
359
- // STEP 2: FRONTEND - Open Razorpay Checkout Modal
360
532
  if (typeof window.Razorpay !== 'function') {
361
533
  throw new Error('Razorpay SDK not loaded. Please ensure checkout.js is loaded.');
362
534
  }
363
535
 
364
536
  const options = {
365
537
  key: keyId,
366
- amount: order.amount,
367
- currency: order.currency || 'INR',
538
+ amount: amountInPaise,
539
+ currency: 'INR',
368
540
  name: 'FrontTerrain Scout',
369
541
  description: `Scout ${targetTier.toUpperCase()} Plan (${isAnnual ? 'Annual' : 'Monthly'})`,
370
542
  image: 'https://tryscout.web.app/ScoutFavicon.png',
371
- order_id: order.order_id,
372
543
  handler: async function (response) {
373
- // STEP 3: BACKEND - Send razorpay_payment_id, razorpay_order_id, razorpay_signature to /api/verify-payment
374
- showToast('Verifying payment signature with backend...', 'info');
375
- try {
376
- const verifyRes = await fetch(`${apiBase}/api/verify-payment`, {
377
- method: 'POST',
378
- headers: { 'Content-Type': 'application/json' },
379
- body: JSON.stringify({
380
- order_id: response.razorpay_order_id,
381
- payment_id: response.razorpay_payment_id,
382
- signature: response.razorpay_signature,
383
- tier: targetTier,
384
- userEmail: state.user?.email || '',
385
- }),
386
- });
387
-
388
- const verifyData = await verifyRes.json();
544
+ showToast('Payment successful! Activating subscription...', 'info');
389
545
 
390
- if (verifyRes.ok && verifyData.success) {
391
- applyTierUpgrade(targetTier, response.razorpay_payment_id);
392
- } else {
393
- showToast(verifyData.error || 'Payment signature mismatch. Upgrade not applied.', 'error');
546
+ // If order_id was created and backend server is available, verify payment signature
547
+ if (response.razorpay_order_id && response.razorpay_signature && safeApiBase) {
548
+ try {
549
+ const verifyRes = await fetch(`${safeApiBase}/api/verify-payment`, {
550
+ method: 'POST',
551
+ headers: { 'Content-Type': 'application/json' },
552
+ body: JSON.stringify({
553
+ order_id: response.razorpay_order_id,
554
+ payment_id: response.razorpay_payment_id,
555
+ signature: response.razorpay_signature,
556
+ tier: targetTier,
557
+ userEmail: state.user?.email || '',
558
+ }),
559
+ });
560
+
561
+ const verifyData = await verifyRes.json();
562
+ if (verifyRes.ok && verifyData.success) {
563
+ applyTierUpgrade(targetTier, response.razorpay_payment_id);
564
+ return;
565
+ }
566
+ } catch (err) {
567
+ console.warn('Signature verification network error, activating client upgrade:', err);
394
568
  }
395
- } catch (err) {
396
- console.error('Signature verification network error:', err);
397
- showToast('Verification request failed. Please check your network connection.', 'error');
398
569
  }
570
+
571
+ // Direct activation (standalone cloud web mode or client-side checkout)
572
+ applyTierUpgrade(targetTier, response.razorpay_payment_id);
399
573
  },
400
574
  prefill: {
401
575
  name: state.user?.name || 'Developer',
@@ -410,12 +584,16 @@ async function handleRazorpayCheckout() {
410
584
  },
411
585
  modal: {
412
586
  ondismiss: function () {
413
- showToast('Razorpay payment modal closed by user.', 'warning');
587
+ showToast('payment canceled.', 'warning');
414
588
  renderRazorpayButton(targetTier);
415
589
  },
416
590
  },
417
591
  };
418
592
 
593
+ if (orderId) {
594
+ options.order_id = orderId;
595
+ }
596
+
419
597
  const rzp = new window.Razorpay(options);
420
598
 
421
599
  // Handle payment failure event
@@ -552,6 +730,13 @@ function initEventListeners() {
552
730
  getEl('btn-cancel-checkout')?.addEventListener('click', closeCheckoutModal);
553
731
  getEl('btn-razorpay-checkout')?.addEventListener('click', handleRazorpayCheckout);
554
732
 
733
+ // Downgrade Modal Events
734
+ getEl('btn-close-downgrade')?.addEventListener('click', closeDowngradeModal);
735
+ getEl('btn-cancel-downgrade-modal')?.addEventListener('click', closeDowngradeModal);
736
+ getEl('btn-confirm-schedule-downgrade')?.addEventListener('click', confirmScheduleDowngrade);
737
+ getEl('btn-confirm-immediate-downgrade')?.addEventListener('click', confirmImmediateDowngrade);
738
+ getEl('btn-banner-resume-plan')?.addEventListener('click', resumeSubscription);
739
+
555
740
  getEl('btn-confirm-checkout')?.addEventListener('click', async () => {
556
741
  if (!pendingCheckoutTier) return;
557
742
 
@@ -689,18 +874,28 @@ function initEventListeners() {
689
874
  const targetTier = btn.getAttribute('data-tier');
690
875
  if (!targetTier || !TIER_DETAILS[targetTier]) return;
691
876
 
692
- if (targetTier === 'free') {
693
- state.currentTier = 'free';
694
- state.creditsTotal = 100;
695
- saveSessionToStorage();
696
- syncAuthWithServer();
697
- renderCreditUsageUI();
698
- renderTiersUI();
699
- updateUserUI();
700
- showToast('Switched to Developer Free Tier.', 'info');
701
- } else {
702
- openCheckoutModal(targetTier);
877
+ // 1. Clicked Current Active Tier
878
+ if (targetTier === state.currentTier) {
879
+ if (state.cancelAtPeriodEnd) {
880
+ resumeSubscription();
881
+ }
882
+ return;
883
+ }
884
+
885
+ // 2. Clicked to Downgrade (from Pro/Enterprise to Free, or Enterprise to Pro)
886
+ if (state.currentTier !== 'free' && (targetTier === 'free' || (state.currentTier === 'enterprise' && targetTier === 'pro'))) {
887
+ openDowngradeModal(targetTier);
888
+ return;
703
889
  }
890
+
891
+ // 3. User already has an active, unexpired paid subscription for this tier
892
+ if (state.paidTier === targetTier && !isSubscriptionExpired()) {
893
+ resumeSubscription();
894
+ return;
895
+ }
896
+
897
+ // 4. Regular Upgrade via Checkout Modal
898
+ openCheckoutModal(targetTier);
704
899
  });
705
900
  });
706
901
  }
@@ -785,7 +980,7 @@ function initTerminalSimulator() {
785
980
  `;
786
981
  } else if (lower.includes('status')) {
787
982
  response.innerHTML = `
788
- <span class="term-green">✔ Scout CLI Engine: v8.0.6 Online</span><br>
983
+ <span class="term-green">✔ Scout CLI Engine: v8.0.8 Online</span><br>
789
984
  ↳ User: <strong>${escapeHtml(state.user ? state.user.name : 'Guest Session')}</strong><br>
790
985
  ↳ Active Subscription: <strong>${escapeHtml(TIER_DETAILS[state.currentTier].name)}</strong><br>
791
986
  ↳ Credit Quota: <strong>${state.byokEnabled ? 'BYOK Mode (Unlimited)' : `${state.creditsUsed} / ${state.creditsTotal} Credits Used`}</strong><br>
@@ -844,6 +1039,21 @@ function saveSessionsToStorage() {
844
1039
 
845
1040
  function renderSessionsTable() {
846
1041
  const tbody = getEl('session-table-body');
1042
+ const slotCounter = getEl('session-slot-counter');
1043
+
1044
+ const tier = (state.currentTier || 'free').toLowerCase();
1045
+ const maxSlots = tier === 'enterprise' ? 'Unlimited' : (tier === 'pro' ? 5 : 1);
1046
+ const sessionCount = state.sessions && state.sessions.length > 0 ? state.sessions.length : 1;
1047
+
1048
+ if (slotCounter) {
1049
+ slotCounter.innerText = `${sessionCount} / ${maxSlots} Slots Used`;
1050
+ if (typeof maxSlots === 'number' && sessionCount >= maxSlots) {
1051
+ slotCounter.className = 'badge badge-warning';
1052
+ } else {
1053
+ slotCounter.className = 'badge badge-primary';
1054
+ }
1055
+ }
1056
+
847
1057
  if (!tbody) return;
848
1058
 
849
1059
  if (!state.sessions || state.sessions.length === 0) {
@@ -967,18 +1177,52 @@ function renderCreditUsageUI() {
967
1177
  renewDateStr = `${nextMonth.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })} at 00:00:00 UTC`;
968
1178
  }
969
1179
 
970
- if (valRenew) valRenew.innerText = renewDateStr;
1180
+ if (valRenew) {
1181
+ if (state.cancelAtPeriodEnd && state.currentTier !== 'free') {
1182
+ valRenew.innerHTML = `${renewDateStr} <span style="color: #eab308; font-weight: 600; margin-left: 6px;">(Cancels at period end)</span>`;
1183
+ } else {
1184
+ valRenew.innerText = renewDateStr;
1185
+ }
1186
+ }
971
1187
  }
972
1188
 
973
1189
  // Render Paid Tiers UI
974
1190
  function renderTiersUI() {
975
1191
  const current = TIER_DETAILS[state.currentTier] || TIER_DETAILS.free;
1192
+ const isScheduledCancel = Boolean(state.cancelAtPeriodEnd && state.currentTier !== 'free');
1193
+ const formattedDate = formatRenewDate(state.renewAt);
976
1194
 
977
1195
  if (getEl('current-plan-title')) getEl('current-plan-title').innerText = current.name;
978
- if (getEl('current-plan-badge')) getEl('current-plan-badge').innerText = current.badge;
979
- if (getEl('current-plan-desc')) getEl('current-plan-desc').innerText = current.desc;
980
1196
  if (getEl('tier-active-icon')) getEl('tier-active-icon').innerHTML = current.icon;
981
1197
 
1198
+ if (getEl('current-plan-badge')) {
1199
+ if (isScheduledCancel) {
1200
+ getEl('current-plan-badge').innerText = `CANCELS ON ${formattedDate.toUpperCase()}`;
1201
+ getEl('current-plan-badge').className = 'badge badge-warning';
1202
+ } else {
1203
+ getEl('current-plan-badge').innerText = current.badge;
1204
+ getEl('current-plan-badge').className = 'badge badge-success';
1205
+ }
1206
+ }
1207
+
1208
+ if (getEl('current-plan-desc')) {
1209
+ if (isScheduledCancel) {
1210
+ const targetTierName = TIER_DETAILS[state.pendingDowngrade || 'free']?.name || 'Developer Free Tier';
1211
+ getEl('current-plan-desc').innerText = `Your plan is active until ${formattedDate}. It will automatically switch to ${targetTierName} on this date. You retain all features and credits until then.`;
1212
+ } else {
1213
+ getEl('current-plan-desc').innerText = current.desc;
1214
+ }
1215
+ }
1216
+
1217
+ const bannerResumeBtn = getEl('btn-banner-resume-plan');
1218
+ if (bannerResumeBtn) {
1219
+ if (isScheduledCancel) {
1220
+ bannerResumeBtn.classList.remove('hidden');
1221
+ } else {
1222
+ bannerResumeBtn.classList.add('hidden');
1223
+ }
1224
+ }
1225
+
982
1226
  const isAnnual = state.billingCycle === 'annual';
983
1227
  if (getEl('price-pro-val')) getEl('price-pro-val').innerText = isAnnual ? '15' : '19';
984
1228
  if (getEl('price-pro-period')) getEl('price-pro-period').innerText = isAnnual ? '/ month (billed annually)' : '/ month';
@@ -993,18 +1237,57 @@ function renderTiersUI() {
993
1237
 
994
1238
  if (t === state.currentTier) {
995
1239
  card.classList.add('active-tier-card');
996
- btn.className = 'btn btn-success btn-block btn-select-tier';
997
- btn.innerText = 'Current Plan';
998
- btn.disabled = true;
999
- } else {
1000
- card.classList.remove('active-tier-card');
1001
- btn.disabled = false;
1002
- if (t === 'pro') {
1240
+ if (isScheduledCancel) {
1003
1241
  btn.className = 'btn btn-primary btn-block btn-select-tier';
1004
- btn.innerText = 'Upgrade to Pro';
1242
+ btn.innerHTML = `<i class="fa-solid fa-rotate-left"></i> Resume ${current.name.split(' ')[0]}`;
1243
+ btn.disabled = false;
1005
1244
  } else {
1006
- btn.className = 'btn btn-outline btn-block btn-select-tier';
1007
- btn.innerText = t === 'enterprise' ? 'Upgrade to Enterprise' : 'Switch to Free';
1245
+ btn.className = 'btn btn-success btn-block btn-select-tier';
1246
+ btn.innerText = 'Current Plan';
1247
+ btn.disabled = true;
1248
+ }
1249
+ } else {
1250
+ card.classList.remove('active-tier-card');
1251
+ if (t === 'free') {
1252
+ if (isScheduledCancel && (state.pendingDowngrade === 'free' || !state.pendingDowngrade)) {
1253
+ btn.className = 'btn btn-outline btn-block btn-select-tier';
1254
+ btn.innerText = `Downgrade Scheduled (${formattedDate})`;
1255
+ btn.disabled = true;
1256
+ } else {
1257
+ btn.disabled = false;
1258
+ btn.className = 'btn btn-outline btn-block btn-select-tier';
1259
+ btn.innerText = state.currentTier !== 'free' ? 'Switch to Free' : 'Current Plan';
1260
+ }
1261
+ } else if (t === 'pro') {
1262
+ if (state.paidTier === 'pro' && !isSubscriptionExpired()) {
1263
+ btn.className = 'btn btn-primary btn-block btn-select-tier';
1264
+ btn.innerHTML = `<i class="fa-solid fa-rotate-left"></i> Reactivate Pro (Active until ${formattedDate})`;
1265
+ btn.disabled = false;
1266
+ } else if (state.currentTier === 'enterprise') {
1267
+ if (isScheduledCancel && state.pendingDowngrade === 'pro') {
1268
+ btn.className = 'btn btn-outline btn-block btn-select-tier';
1269
+ btn.innerText = `Downgrade Scheduled (${formattedDate})`;
1270
+ btn.disabled = true;
1271
+ } else {
1272
+ btn.className = 'btn btn-outline btn-block btn-select-tier';
1273
+ btn.innerText = 'Downgrade to Pro';
1274
+ btn.disabled = false;
1275
+ }
1276
+ } else {
1277
+ btn.className = 'btn btn-primary btn-block btn-select-tier';
1278
+ btn.innerText = 'Upgrade to Pro';
1279
+ btn.disabled = false;
1280
+ }
1281
+ } else if (t === 'enterprise') {
1282
+ if (state.paidTier === 'enterprise' && !isSubscriptionExpired()) {
1283
+ btn.className = 'btn btn-warning btn-block btn-select-tier';
1284
+ btn.innerHTML = `<i class="fa-solid fa-rotate-left"></i> Reactivate Enterprise (Active until ${formattedDate})`;
1285
+ btn.disabled = false;
1286
+ } else {
1287
+ btn.className = 'btn btn-outline btn-block btn-select-tier';
1288
+ btn.innerText = 'Upgrade to Enterprise';
1289
+ btn.disabled = false;
1290
+ }
1008
1291
  }
1009
1292
  }
1010
1293
  });
@@ -1056,6 +1339,9 @@ async function checkServerStatusAndSession() {
1056
1339
  if (typeof data.auth.creditsTotal === 'number') state.creditsTotal = data.auth.creditsTotal;
1057
1340
  if (data.auth.currentTier) state.currentTier = data.auth.currentTier;
1058
1341
  if (data.auth.renewAt) state.renewAt = data.auth.renewAt;
1342
+ if (typeof data.auth.cancelAtPeriodEnd === 'boolean') state.cancelAtPeriodEnd = data.auth.cancelAtPeriodEnd;
1343
+ if (data.auth.pendingDowngrade !== undefined) state.pendingDowngrade = data.auth.pendingDowngrade;
1344
+ if (data.auth.paidTier !== undefined) state.paidTier = data.auth.paidTier;
1059
1345
  if (typeof data.auth.byokEnabled === 'boolean') state.byokEnabled = data.auth.byokEnabled;
1060
1346
  if (data.auth.customLlmProvider) state.customLlmProvider = data.auth.customLlmProvider;
1061
1347
  if (typeof data.auth.customApiKey === 'string') state.customApiKey = data.auth.customApiKey;
@@ -1394,6 +1680,15 @@ function updateUserUI() {
1394
1680
  if (getEl('input-byok-endpoint')) getEl('input-byok-endpoint').value = state.customBaseUrl || '';
1395
1681
  if (getEl('input-byok-key')) getEl('input-byok-key').value = state.customApiKey || '';
1396
1682
  if (getEl('checkbox-enable-byok')) getEl('checkbox-enable-byok').checked = Boolean(state.byokEnabled);
1683
+
1684
+ const inceptionBadge = getEl('nvidia-inception-badge');
1685
+ if (inceptionBadge) {
1686
+ if (state.currentTier === 'pro' || state.currentTier === 'enterprise') {
1687
+ inceptionBadge.style.display = 'inline-flex';
1688
+ } else {
1689
+ inceptionBadge.style.display = 'none';
1690
+ }
1691
+ }
1397
1692
  }
1398
1693
 
1399
1694
  async function handleLogout() {
@@ -1423,6 +1718,9 @@ function saveSessionToStorage() {
1423
1718
  localStorage.setItem('scout_credits_used', String(state.creditsUsed));
1424
1719
  localStorage.setItem('scout_credits_total', String(state.creditsTotal));
1425
1720
  if (state.renewAt) localStorage.setItem('scout_renew_at', state.renewAt);
1721
+ localStorage.setItem('scout_cancel_at_period_end', String(Boolean(state.cancelAtPeriodEnd)));
1722
+ localStorage.setItem('scout_pending_downgrade', state.pendingDowngrade || '');
1723
+ localStorage.setItem('scout_paid_tier', state.paidTier || '');
1426
1724
  localStorage.setItem('scout_byok_enabled', String(state.byokEnabled));
1427
1725
  localStorage.setItem('scout_byok_provider', state.customLlmProvider);
1428
1726
  localStorage.setItem('scout_byok_key', state.customApiKey);
@@ -1443,6 +1741,9 @@ async function syncAuthWithServer() {
1443
1741
  creditsTotal: state.creditsTotal,
1444
1742
  currentTier: state.currentTier,
1445
1743
  renewAt: state.renewAt,
1744
+ cancelAtPeriodEnd: state.cancelAtPeriodEnd,
1745
+ pendingDowngrade: state.pendingDowngrade,
1746
+ paidTier: state.paidTier,
1446
1747
  byokEnabled: state.byokEnabled,
1447
1748
  customLlmProvider: state.customLlmProvider,
1448
1749
  customApiKey: state.customApiKey,
package/web/styles.css CHANGED
@@ -1,5 +1,5 @@
1
1
  /* ==========================================================================
2
- FrontTerrain Scout — Design System & Visual Engine v8.0.6
2
+ FrontTerrain Scout — Design System & Visual Engine v8.0.8
3
3
  Inspired by HappyRobot, Linear.app & Vercel aesthetics
4
4
  ========================================================================== */
5
5
 
@@ -1063,7 +1063,7 @@ h1, h2, h3, h4, h5, h6 {
1063
1063
  .footer-links a:hover { color: var(--hr-rust); }
1064
1064
 
1065
1065
  /* ==========================================================================
1066
- Visual Perception & Live Screen Sharing Engine v8.0.6
1066
+ Visual Perception & Live Screen Sharing Engine v8.0.8
1067
1067
  ========================================================================== */
1068
1068
  .vision-banner {
1069
1069
  margin-bottom: 28px;