fix(pricing): disable current and lower plan actions (#7191)

This commit is contained in:
Ray Xi
2026-08-20 23:03:52 +08:00
committed by GitHub
parent b032abed00
commit 4fccbdb31b
5 changed files with 235 additions and 0 deletions
@@ -856,6 +856,16 @@ const otherModels = 'Gemini-3.1-Pro, Grok-4.5, GPT-5.4, GPT-5.6-Terra, Qwen3.7-M
background: #8cf76c;
}
.pricing-card .pricing-card-cta[aria-disabled='true'],
.pricing-card .pricing-card-cta[aria-disabled='true']:hover,
.pricing-card .pricing-card-cta[aria-disabled='true']:focus-visible {
color: #595d54;
background: #d8dad4;
border-color: #c5c8c0;
cursor: not-allowed;
transform: none;
}
.plan-feature-details {
margin-top: 14px;
padding-top: 0;
@@ -16,6 +16,23 @@
*/
import type { LandingLocaleCode } from '../i18n';
const CURRENT_PLAN_LABELS: Partial<Record<LandingLocaleCode, string>> = {
en: 'Current plan',
zh: '当前套餐',
'zh-tw': '目前方案',
ja: '現在のプラン',
ko: '현재 요금제',
de: 'Aktueller Tarif',
fr: 'Offre actuelle',
ru: 'Текущий тариф',
es: 'Plan actual',
'pt-br': 'Plano atual',
};
export function getCurrentPlanLabel(locale: LandingLocaleCode): string {
return CURRENT_PLAN_LABELS[locale] ?? CURRENT_PLAN_LABELS.en!;
}
export type PlanTierId = 'plus' | 'pro' | 'max';
export interface PlanCopy {
@@ -0,0 +1,73 @@
export type PersonalPlanTier = 'go' | 'plus' | 'pro' | 'max';
type PricingFetch = (
input: RequestInfo | URL,
init?: RequestInit,
) => Promise<Response>;
const PERSONAL_PLAN_TIERS = new Set<PersonalPlanTier>([
'go',
'plus',
'pro',
'max',
]);
const PERSONAL_PLAN_ORDER: readonly PersonalPlanTier[] = [
'go',
'plus',
'pro',
'max',
];
export function isPersonalPlanAtOrBelow(
candidateTier: string,
currentTier: PersonalPlanTier,
): boolean {
const candidateIndex = PERSONAL_PLAN_ORDER.indexOf(
candidateTier as PersonalPlanTier,
);
return (
candidateIndex >= 0 &&
candidateIndex <= PERSONAL_PLAN_ORDER.indexOf(currentTier)
);
}
function personalPlanTier(value: unknown): PersonalPlanTier | null {
if (typeof value !== 'string') return null;
const normalized = value.trim().toLowerCase().replace(/_(monthly|yearly)$/, '');
return PERSONAL_PLAN_TIERS.has(normalized as PersonalPlanTier)
? (normalized as PersonalPlanTier)
: null;
}
/**
* Resolve only the signed-in account's personal subscription tier. Landing
* remains a static comparison surface: failures, signed-out visitors, and
* workspace/team plans leave every CTA exactly as rendered at build time.
*/
export async function loadCurrentPersonalPlanTier(
apiOrigin: string,
fetcher: PricingFetch = fetch,
): Promise<PersonalPlanTier | null> {
const origin = apiOrigin.trim().replace(/\/+$/, '');
if (!origin) return null;
try {
const sessionResponse = await fetcher(`${origin}/api/auth/get-session`, {
credentials: 'include',
headers: { Accept: 'application/json' },
});
if (!sessionResponse.ok) return null;
const session = await sessionResponse.json();
if (!session?.user) return null;
const billingResponse = await fetcher(`${origin}/api/v1/billing/summary`, {
credentials: 'include',
headers: { Accept: 'application/json' },
});
if (!billingResponse.ok) return null;
const summary = await billingResponse.json();
return personalPlanTier(summary?.membershipTier);
} catch {
return null;
}
}
@@ -21,6 +21,7 @@ import {
type TeamPlanTierConfig,
} from '../../_lib/pricing';
import {
getCurrentPlanLabel,
getPricingContent,
fillTemplate,
} from '../../_lib/pricing-content';
@@ -39,8 +40,14 @@ const locale = localeFromPath(Astro.url.pathname);
const isZh = locale === 'zh' || locale === 'zh-tw';
const href = (path: string) => localizedHref(path, locale);
const content = getPricingContent(locale);
const currentPlanLabel = getCurrentPlanLabel(locale);
const teamContent = getTeamPricingContent(locale);
const L = content.labels;
const env = import.meta.env as Record<string, string | undefined>;
const apiOrigin =
env.PUBLIC_CLOUD_API_BASE ??
env.PUBLIC_AMR_API_BASE ??
'https://amr-api.open-design.ai';
// Footer line with the cloud-console link injected into the localized
// template. Brand/URL only — safe to render as HTML.
@@ -176,6 +183,8 @@ const jsonLd = [
data-pricing-root
data-audience="creator"
data-interval="yearly"
data-billing-api-origin={apiOrigin}
data-current-plan-label={currentPlanLabel}
data-campaign-start-at={DEEPSEEK_V4_PRO_CAMPAIGN.startAt}
data-campaign-end-at={DEEPSEEK_V4_PRO_CAMPAIGN.endAtExclusive}
>
@@ -560,6 +569,10 @@ const jsonLd = [
const syncCtas = () => {
const interval = root.getAttribute('data-interval') === 'monthly' ? 'monthly' : 'yearly';
for (const cta of root.querySelectorAll('[data-pricing-cta]')) {
if (cta.hasAttribute('data-subscription-disabled')) {
cta.removeAttribute('href');
continue;
}
const tier = cta.getAttribute('data-tier') || '';
if (tier === 'free') {
cta.setAttribute('href', new URL('/cloud/dashboard', billingPlanUrl).toString());
@@ -880,6 +893,10 @@ const jsonLd = [
root.addEventListener('click', (event) => {
const cta = event.target && event.target.closest && event.target.closest('[data-pricing-cta]');
if (!cta) return;
if (cta.hasAttribute('data-subscription-disabled')) {
event.preventDefault();
return;
}
try {
const audience = root.getAttribute('data-audience') || 'creator';
// Outside the activity window the CTA still records od_entry_*
@@ -992,6 +1009,33 @@ const jsonLd = [
}
})();
</script>
<script>
import {
isPersonalPlanAtOrBelow,
loadCurrentPersonalPlanTier,
} from '../../_lib/pricing-current-plan';
const pricingRoot = document.querySelector('[data-pricing-root]');
if (pricingRoot) {
const apiOrigin = pricingRoot.getAttribute('data-billing-api-origin') || '';
const currentPlanLabel = pricingRoot.getAttribute('data-current-plan-label') || '';
void loadCurrentPersonalPlanTier(apiOrigin).then((currentTier) => {
if (!currentTier || !currentPlanLabel) return;
for (const cta of pricingRoot.querySelectorAll('[data-pricing-cta]')) {
const tier = cta.getAttribute('data-tier') || '';
if (!isPersonalPlanAtOrBelow(tier, currentTier)) continue;
cta.setAttribute('data-subscription-disabled', '');
cta.setAttribute('aria-disabled', 'true');
cta.setAttribute('tabindex', '-1');
cta.removeAttribute('href');
}
const label = pricingRoot.querySelector(
`[data-pricing-cta][data-tier="${currentTier}"] > span`,
);
if (label) label.textContent = currentPlanLabel;
});
}
</script>
</Layout>
<style>
@@ -20,8 +20,13 @@ import {
} from "../app/_lib/pricing-team-content.ts";
import {
PREMIUM_MODELS,
getCurrentPlanLabel,
getPricingContent,
} from "../app/_lib/pricing-content.ts";
import {
isPersonalPlanAtOrBelow,
loadCurrentPersonalPlanTier,
} from "../app/_lib/pricing-current-plan.ts";
import { LANDING_LOCALES } from "../app/i18n.ts";
import { DEEPSEEK_V4_PRO_CAMPAIGN } from "../app/_lib/deepseek-v4-pro-campaign.ts";
@@ -660,6 +665,92 @@ describe("pricing contract", () => {
assert.equal(scopedBillingPlanUrl(" "), CLOUD_CONSOLE_URL);
});
it("recognizes only the signed-in account's current personal plan for CTA copy", async () => {
const requests: string[] = [];
const requestOptions: Array<RequestInit | undefined> = [];
const fetcher = async (input: string | URL | Request, init?: RequestInit) => {
const url = String(input);
requests.push(url);
requestOptions.push(init);
if (url.endsWith("/api/auth/get-session")) {
return Response.json({ user: { id: "user-1" } });
}
return Response.json({ membershipTier: "Pro" });
};
assert.equal(
await loadCurrentPersonalPlanTier("https://amr-api.open-design.ai/", fetcher),
"pro",
);
assert.deepEqual(requests, [
"https://amr-api.open-design.ai/api/auth/get-session",
"https://amr-api.open-design.ai/api/v1/billing/summary",
]);
assert.deepEqual(
requestOptions.map((options) => options?.credentials),
["include", "include"],
);
assert.equal(getCurrentPlanLabel("en"), "Current plan");
assert.equal(getCurrentPlanLabel("zh"), "当前套餐");
});
it("disables the current personal plan and every downgrade target", () => {
assert.deepEqual(
["go", "plus", "pro", "max"].filter((tier) =>
isPersonalPlanAtOrBelow(tier, "pro"),
),
["go", "plus", "pro"],
);
assert.equal(isPersonalPlanAtOrBelow("team", "pro"), false);
assert.equal(isPersonalPlanAtOrBelow("max", "pro"), false);
});
it("keeps CTA copy unchanged when subscription identity is unavailable or non-personal", async () => {
const signedOut = async () => Response.json(null);
const teamPlan = async (input: string | URL | Request) =>
Response.json(
String(input).endsWith("/api/auth/get-session")
? { user: { id: "user-1" } }
: { membershipTier: "team_pro" },
);
const failed = async () => new Response(null, { status: 503 });
assert.equal(
await loadCurrentPersonalPlanTier("https://amr-api.open-design.ai", signedOut),
null,
);
assert.equal(
await loadCurrentPersonalPlanTier("https://amr-api.open-design.ai", teamPlan),
null,
);
assert.equal(
await loadCurrentPersonalPlanTier("https://amr-api.open-design.ai", failed),
null,
);
});
it("labels the current CTA and disables current or lower personal tiers", async () => {
const [page, individualPlans] = await Promise.all([
readFile(PRICING_PAGE_PATH, "utf8"),
readFile(PRICING_INDIVIDUAL_PATH, "utf8"),
]);
assert.match(page, /data-billing-api-origin=\{apiOrigin\}/);
assert.match(page, /data-current-plan-label=\{currentPlanLabel\}/);
assert.match(page, /loadCurrentPersonalPlanTier\(apiOrigin\)/);
assert.match(page, /\[data-pricing-cta\]\[data-tier="\$\{currentTier\}"\] > span/);
assert.match(page, /isPersonalPlanAtOrBelow\(tier, currentTier\)/);
assert.match(page, /cta\.setAttribute\('aria-disabled', 'true'\)/);
assert.match(page, /cta\.setAttribute\('tabindex', '-1'\)/);
assert.match(page, /cta\.removeAttribute\('href'\)/);
assert.match(page, /cta\.hasAttribute\('data-subscription-disabled'\)/);
assert.match(
individualPlans,
/\.pricing-card-cta\[aria-disabled='true'\][\s\S]*?cursor:\s*not-allowed;/,
);
assert.match(individualPlans, /data-pricing-cta data-tier=\{tier\}/);
});
it("preserves only an explicit inbound workspace without inferring local state", async () => {
const page = await readFile(PRICING_PAGE_PATH, "utf8");
const scoped = new URL(scopedBillingPlanUrl("workspace & team"));