mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
fix(pricing): disable current and lower plan actions (#7191)
This commit is contained in:
@@ -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"));
|
||||
|
||||
Reference in New Issue
Block a user