diff --git a/app.js b/app.js index 4d7172c..a197cde 100644 --- a/app.js +++ b/app.js @@ -5,6 +5,11 @@ import { openModal, setModalPending, } from "./src/sharedControls.js"; +import { + PLAN_LABELS as planLabels, + PLAN_LIMITS as planLimits, + PLAN_FEATURES as planFeatures, +} from "./src/plansSurface.js"; import { getPrimaryArtifact, normalizeArtifactBundle, @@ -139,11 +144,9 @@ let identityState = { } // --- TIER LIMITS --- -const TIER_LIMITS = { - free: 2, - professional: 50, - power: 2000, -} +// Single source of truth in src/plansSurface.js so the plans page, the +// paywall and generation gating agree (STU-382 criterion 1). +const TIER_LIMITS = planLimits; const SUBSCRIPTION_CACHE_KEY = 'ccc_subscription' const SUBSCRIPTION_CACHE_VERSION = 3 @@ -4686,7 +4689,7 @@ function renderUsageSurfaces() { const limit = resolved ? getRunLimit() : 0; const remaining = Math.max(0, limit - count); const usedLimitText = `${count} / ${limit} runs this month`; - const labels = { free: "Free", professional: "Pro", power: "Power" }; + const labels = planLabels; const tier = resolved ? (subscriptionState.tier || "free") : null; const topbarCredits = document.getElementById("topbar-credits-count"); @@ -4992,17 +4995,43 @@ async function callBuildShip(step, model, prompt, context = {}, images = []) { } } +// Set when the user returns from Stripe with `?checkout=success`. The paid +// entitlement is never granted by the return param itself — it is only ever +// set by the subscription reconciliation below. This flag defers the "plan +// active" confirmation until after reconcileSubscription has confirmed a paid +// tier, so a return that has NOT reconciled (blocked, webhook lag, or a +// cancelled/failed session) can never be reported as subscribed (STU-382 +// criterion 3). +let checkoutConfirmPending = false + function handleCheckoutRedirect() { const params = new URLSearchParams(window.location.search) const checkout = params.get('checkout') if (checkout === 'success') { window.history.replaceState({}, '', window.location.pathname + window.location.hash) clearSubscriptionCache() - showToast('Subscription active! Welcome aboard.', 'success') + checkoutConfirmPending = true } else if (checkout === 'cancel') { window.history.replaceState({}, '', window.location.pathname + window.location.hash) showToast('Checkout cancelled.', 'info') } + return checkout +} + +// Called once the subscription has been reconciled after a checkout return. +// Only reports a live plan when the contract is actually paid; otherwise it +// says the subscription is still being confirmed so the UI never claims a +// subscribed state it cannot back up. +function confirmCheckoutAfterReconcile() { + if (!checkoutConfirmPending) return + checkoutConfirmPending = false + const isPaid = subscriptionState.tier !== 'free' + showToast( + isPaid + ? 'Subscription active! Your plan is live.' + : 'Checkout complete — confirming your subscription. Check Manage billing if it does not update.', + isPaid ? 'success' : 'info', + ) } // --- SUBSCRIPTION UI --- @@ -5015,7 +5044,7 @@ function updateSubscriptionUI() { const badge = document.getElementById('subscription-tier-badge') if (badge) { - const labels = { free: 'Free', professional: 'Professional', power: 'Power Developer' } + const labels = planLabels const colors = { free: 'bg-gray-100 text-gray-600', professional: 'bg-indigo-100 text-indigo-700', @@ -5062,6 +5091,27 @@ function updatePricingModalState(tier) { if (freeCurrent) freeCurrent.classList.toggle('hidden', tier !== 'free') } +// Render every plan card's feature list from the single PLAN_FEATURES source, +// so the plans page and the pricing modal share one feature set and can never +// drift (and a test can never assert a constant the UI does not render). Each +// .pm-card carries data-tier; its