Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 61 additions & 9 deletions app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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 ---
Expand All @@ -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',
Expand Down Expand Up @@ -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 <ul class="pm-features"> is filled from
// PLAN_FEATURES[tier]. Idempotent: safe to run more than once.
function renderPlanFeatureLists() {
document.querySelectorAll('.pm-card[data-tier]').forEach((card) => {
const tier = card.dataset.tier
const rows = planFeatures[tier]
if (!rows) return
const ul = card.querySelector('ul.pm-features')
if (!ul) return
ul.innerHTML = ''
for (const row of rows) {
const li = document.createElement('li')
li.textContent = row
ul.appendChild(li)
}
})
}

function updatePricingDisplay() {
const currency = detectUserCurrency()
const proEl = document.getElementById('pro-price')
Expand Down Expand Up @@ -5130,8 +5180,10 @@ document.addEventListener("DOMContentLoaded", async () => {
await initializeAuth();
handleCheckoutRedirect();
await fetchSubscription();
confirmCheckoutAfterReconcile();
updateSubscriptionUI();
updatePricingDisplay();
renderPlanFeatureLists();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Plan features disappear during startup

When startup requests stall, renderPlanFeatureLists has not run and every plan card shows an empty feature list. The previous static rows remained visible during the same delay.

Learn more

The feature rows now exist only in JavaScript. This call runs after initializeAuth() and fetchSubscription(), which each perform network requests without a request timeout. The document remains interactive while the async DOMContentLoaded callback waits, so users can open the pricing modal and see cards with no features.

Example: A session refresh takes 20 seconds. During those 20 seconds, opening the pricing modal displays Free, Pro, and Power cards with empty feature lists instead of their plan capabilities.

Recommended fix: Call renderPlanFeatureLists() before the first startup await, since its data is local and independent of authentication. Alternatively, retain static HTML rows as the initial fallback and let the renderer replace them later.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.


// Initialize API keys and check connection
await checkConnection();
Expand Down Expand Up @@ -6623,7 +6675,7 @@ function updateShellUI() {
const loading = signedIn && isSubscriptionLoading();
const resolved = !signedIn || isSubscriptionResolved();

const labels = { free: "Free", professional: "Pro", power: "Power" };
const labels = planLabels;
const planLabel = loading ? "Checking…" : resolved ? labels[tier] || "Free" : "—";

const topbarPlan = document.getElementById("topbar-plan");
Expand Down
4 changes: 2 additions & 2 deletions e2e/smoke.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ test.describe("Redesigned hero landing and composer", () => {
await page.goto("/#account");

const badge = page.locator("#subscription-tier-badge");
await expect(badge).toContainText("Professional");
await expect(badge).toContainText("Pro");
});

test("signed-in Power badge is rendered from fixture", async ({ page }) => {
Expand All @@ -149,7 +149,7 @@ test.describe("Redesigned hero landing and composer", () => {
await page.goto("/#account");

const badge = page.locator("#subscription-tier-badge");
await expect(badge).toContainText("Power Developer");
await expect(badge).toContainText("Power");
});

test("unresolved subscription surfaces the unavailable plan state", async ({ page }) => {
Expand Down
Loading
Loading