Skip to content

Commit 8bc9b9f

Browse files
authored
fix(pricing): align landing actions with subscription state (#7239)
* fix(pricing): align subscription actions with billing state * fix(pricing): avoid duplicate cancellation banner * fix(pricing): block yearly to monthly changes
1 parent 9caec80 commit 8bc9b9f

10 files changed

Lines changed: 804 additions & 108 deletions

apps/landing-page/app/_components/header.tsx

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,17 @@ const FEISHU_GROUP_QR = '/community/feishu-group-qr.png';
2727
const FEISHU_MARK = '/launch-week/feishu-mark.png';
2828
const X_PROFILE = 'https://x.com/OpenDesignHQ';
2929

30+
// Pricing can opt into the existing Cloud account menu without restoring it
31+
// across the marketing site. The enhancer reads these values from data-*
32+
// because its inline script cannot access import.meta.env at runtime.
33+
const env = (import.meta.env ?? {}) as Record<string, string | undefined>;
34+
const CLOUD_API_BASE =
35+
env.PUBLIC_CLOUD_API_BASE ?? env.PUBLIC_AMR_API_BASE ?? 'https://amr-api.open-design.ai';
36+
const CLOUD_CONSOLE_URL =
37+
env.PUBLIC_CLOUD_CONSOLE_URL ??
38+
env.PUBLIC_AMR_CONSOLE_URL ??
39+
'https://open-design.ai/cloud/dashboard?source=open_design';
40+
3041
// Solution → Use cases / Roles. Hrefs mirror upstream main's header 1:1 and
3142
// pair positionally with the localized `useCaseItems` / `roleItems` tuples.
3243
const USE_CASE_HREFS = [
@@ -155,6 +166,8 @@ export interface HeaderProps {
155166
copy?: HeaderCopy;
156167
/** Brand link target — `#top` on the homepage, `/` on sub-pages. */
157168
brandHref?: string;
169+
/** Render the signed-in Cloud avatar/menu. Disabled on marketing pages by default. */
170+
showAccount?: boolean;
158171
}
159172

160173
export function Header({
@@ -164,6 +177,7 @@ export function Header({
164177
locale = DEFAULT_LOCALE,
165178
copy,
166179
brandHref = '#top',
180+
showAccount = false,
167181
}: HeaderProps) {
168182
const headerCopy = copy ?? getCommonCopy(locale).header;
169183
const href = (path: string) => localizedHref(path, locale);
@@ -623,6 +637,53 @@ export function Header({
623637
>
624638
{headerCopy.download}
625639
</a>
640+
{showAccount ? (
641+
<div
642+
className='nav-account'
643+
data-amr-account
644+
data-amr-api={CLOUD_API_BASE}
645+
data-amr-console={CLOUD_CONSOLE_URL}
646+
>
647+
<details className='nav-account-menu' data-amr-menu hidden>
648+
<summary
649+
className='nav-account-trigger'
650+
aria-label={headerCopy.accountAria}
651+
title={headerCopy.accountAria}
652+
>
653+
<img className='nav-avatar' alt='' data-amr-avatar />
654+
<span
655+
className='nav-avatar-fallback'
656+
data-amr-avatar-fallback
657+
aria-hidden='true'
658+
/>
659+
</summary>
660+
<div className='nav-account-dropdown' role='menu'>
661+
<div className='nav-account-id'>
662+
<span className='nav-account-name' data-amr-name />
663+
<span className='nav-account-email' data-amr-email />
664+
</div>
665+
<a
666+
className='nav-account-item'
667+
role='menuitem'
668+
href={CLOUD_CONSOLE_URL}
669+
target='_blank'
670+
rel='noreferrer noopener'
671+
data-amr-console-link
672+
>
673+
{headerCopy.menuConsole}
674+
</a>
675+
<button
676+
type='button'
677+
className='nav-account-item nav-account-signout'
678+
role='menuitem'
679+
data-amr-signout
680+
>
681+
{headerCopy.menuSignOut}
682+
</button>
683+
</div>
684+
</details>
685+
</div>
686+
) : null}
626687
</div>
627688
</div>
628689
{/*

apps/landing-page/app/_components/pricing-individual-plans.astro

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -228,11 +228,13 @@ const cardData = [
228228
tier: 'go' as const,
229229
logo: null,
230230
view: goView,
231+
regularMonthlyPrice: GO_PLAN.monthly.priceUsd,
231232
},
232233
...tiers.map((tier) => ({
233234
tier: tier.tier as PlanTierId,
234235
logo: `/pricing/plan-${tier.tier}.svg`,
235236
view: individualView(tier),
237+
regularMonthlyPrice: tier.monthly.priceUsd,
236238
})),
237239
];
238240
@@ -321,7 +323,7 @@ const otherModels = 'Gemini-3.1-Pro, Grok-4.5, GPT-5.4, GPT-5.6-Terra, Qwen3.7-M
321323
>
322324
<div class="benefits-summary-stage">
323325
<div class="four-tier-grid decision-grid">
324-
{cardData.map(({ tier, logo, view }) => {
326+
{cardData.map(({ tier, logo, view, regularMonthlyPrice }) => {
325327
const popular = orderedPopularModels();
326328
const flagship = tier === 'plus' ? plusFlagshipModels : flagshipModels;
327329
const imageResolution = tier === 'plus' ? '1K' : tier === 'pro' ? '2K' : '4K';
@@ -345,17 +347,22 @@ const otherModels = 'Gemini-3.1-Pro, Grok-4.5, GPT-5.4, GPT-5.6-Terra, Qwen3.7-M
345347
)}
346348
</div>
347349
<div class="plan-badges">
348-
<span class="discount-corner-badge" data-when="monthly">{fillTemplate(L.firstMonthTag, { pct: String(view.monthly.discount) })}</span>
350+
<span class="discount-corner-badge" data-when="monthly" data-intro-only>{fillTemplate(L.firstMonthTag, { pct: String(view.monthly.discount) })}</span>
349351
<span class="discount-corner-badge" data-when="yearly">{fillTemplate(L.yearlyDiscountTag, { pct: String(view.yearly.discount) })}</span>
350352
</div>
351353
</div>
352354
<p>{tierTaglines[tier]}</p>
353355
</header>
354356

355357
<div class="price" data-when="monthly">
356-
<span>$</span><strong class="rolling-price-number">{view.monthly.amount}</strong><small>{L.perMonth}</small><del>{view.monthly.strike}</del>
358+
<span>$</span><strong
359+
class="rolling-price-number"
360+
data-monthly-price
361+
data-intro-price={view.monthly.amount}
362+
data-regular-price={formatUsd(regularMonthlyPrice).replace('$', '')}
363+
>{view.monthly.amount}</strong><small>{L.perMonth}</small><del data-intro-only>{view.monthly.strike}</del>
357364
</div>
358-
<div class="compact-price-detail" data-when="monthly">
365+
<div class="compact-price-detail" data-when="monthly" data-intro-only>
359366
<span class="price-savings-note">{fillTemplate(P.saveAmount, { amount: view.monthly.saving })}</span>
360367
<small>{view.monthly.billed}</small>
361368
</div>
@@ -372,6 +379,7 @@ const otherModels = 'Gemini-3.1-Pro, Grok-4.5, GPT-5.4, GPT-5.6-Terra, Qwen3.7-M
372379
data-pricing-cta
373380
data-tier={tier}
374381
data-plan-name={tier.charAt(0).toUpperCase() + tier.slice(1)}
382+
data-default-label={tierCopy[tier].ctaLabel}
375383
>
376384
<span>{tierCopy[tier].ctaLabel}</span>
377385
</a>

apps/landing-page/app/_components/sub-page-layout.astro

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ export interface Props {
7070
* max-width + side padding.
7171
*/
7272
mainClassName?: string;
73+
/** Opt into the signed-in Cloud account menu for this route only. */
74+
showHeaderAccount?: boolean;
7375
}
7476
7577
const {
@@ -82,6 +84,7 @@ const {
8284
localeCanonicalOnly = false,
8385
suppressLocaleAutoRedirect = false,
8486
mainClassName = 'sub-main container',
87+
showHeaderAccount = false,
8588
} = Astro.props;
8689
// Clamp the meta/OG/Twitter description to a search-friendly length (the full
8790
// string still flows into JSON-LD untouched).
@@ -119,6 +122,7 @@ const headerHtml = renderToStaticMarkup(
119122
localeSwitcher: getHeaderLocaleSwitcher(locale, Astro.url.pathname, {
120123
canonicalOnly: localeCanonicalOnly,
121124
}),
125+
showAccount: showHeaderAccount,
122126
}) as ReturnType<typeof createElement>,
123127
);
124128
const ldArray = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : [];

apps/landing-page/app/_lib/pricing-content.ts

Lines changed: 48 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,29 +18,65 @@ import type { LandingLocaleCode } from '../i18n';
1818

1919
export interface PricingPlanActionLabels {
2020
current: string;
21+
currentCanceling: string;
2122
downgrade: string;
2223
upgrade: string;
24+
intervalUpgrade: string;
25+
switchBackToInterval: string;
26+
intervalDowngradeUnavailable: string;
27+
checkoutUnavailable: string;
28+
scheduled: string;
2329
}
2430

25-
const PLAN_ACTION_LABELS: Partial<Record<LandingLocaleCode, PricingPlanActionLabels>> = {
26-
en: { current: 'Current plan', downgrade: 'Downgrade to {plan}', upgrade: 'Upgrade to {plan}' },
27-
zh: { current: '当前套餐', downgrade: '降级至 {plan}', upgrade: '升级至 {plan}' },
28-
'zh-tw': { current: '目前方案', downgrade: '降級至 {plan}', upgrade: '升級至 {plan}' },
29-
ja: { current: '現在のプラン', downgrade: '{plan} にダウングレード', upgrade: '{plan} にアップグレード' },
30-
ko: { current: '현재 요금제', downgrade: '{plan}(으)로 다운그레이드', upgrade: '{plan}(으)로 업그레이드' },
31-
de: { current: 'Aktueller Tarif', downgrade: 'Auf {plan} downgraden', upgrade: 'Auf {plan} upgraden' },
32-
fr: { current: 'Offre actuelle', downgrade: 'Rétrograder vers {plan}', upgrade: 'Passer à {plan}' },
33-
ru: { current: 'Текущий тариф', downgrade: 'Понизить до {plan}', upgrade: 'Повысить до {plan}' },
34-
es: { current: 'Plan actual', downgrade: 'Bajar a {plan}', upgrade: 'Subir a {plan}' },
35-
'pt-br': { current: 'Plano atual', downgrade: 'Fazer downgrade para {plan}', upgrade: 'Fazer upgrade para {plan}' },
31+
const EN_PLAN_ACTION_LABELS: PricingPlanActionLabels = {
32+
current: 'Current plan',
33+
currentCanceling: 'Current plan · Cancels at period end',
34+
downgrade: 'Downgrade to {plan}',
35+
upgrade: 'Upgrade to {plan}',
36+
intervalUpgrade: 'Switch {plan} to {interval}',
37+
switchBackToInterval: 'Switch back to {interval} before upgrading',
38+
intervalDowngradeUnavailable: 'Cancel your subscription to change plans',
39+
checkoutUnavailable: 'Personal checkout unavailable for this account',
40+
scheduled: '{plan} · Scheduled',
41+
};
42+
43+
const PLAN_ACTION_LABELS: Partial<Record<LandingLocaleCode, Partial<PricingPlanActionLabels>>> = {
44+
en: EN_PLAN_ACTION_LABELS,
45+
zh: {
46+
current: '当前套餐', currentCanceling: '当前套餐 · 将在周期结束时取消', downgrade: '降级至 {plan}', upgrade: '升级至 {plan}', intervalUpgrade: '将 {plan} 改为{interval}', switchBackToInterval: '请先切回{interval}再升级', intervalDowngradeUnavailable: '取消订阅后可变更套餐', checkoutUnavailable: '此账户暂不能购买个人套餐', scheduled: '{plan} · 已安排',
47+
},
48+
'zh-tw': {
49+
current: '目前方案', currentCanceling: '目前方案 · 將於週期結束時取消', downgrade: '降級至 {plan}', upgrade: '升級至 {plan}', intervalUpgrade: '將 {plan} 改為{interval}', switchBackToInterval: '請先切回{interval}再升級', intervalDowngradeUnavailable: '取消訂閱後可變更方案', checkoutUnavailable: '此帳戶暫不能購買個人方案', scheduled: '{plan} · 已排程',
50+
},
51+
ja: {
52+
current: '現在のプラン', currentCanceling: '現在のプラン · 期間終了時に解約', downgrade: '{plan} にダウングレード', upgrade: '{plan} にアップグレード', intervalUpgrade: '{plan} を{interval}に変更', switchBackToInterval: 'アップグレードする前に{interval}へ戻してください', intervalDowngradeUnavailable: 'プランを変更するにはサブスクリプションを解約してください', checkoutUnavailable: 'このアカウントでは個人プランを購入できません', scheduled: '{plan} · 予約済み',
53+
},
54+
ko: {
55+
current: '현재 요금제', currentCanceling: '현재 요금제 · 기간 종료 시 해지', downgrade: '{plan}(으)로 다운그레이드', upgrade: '{plan}(으)로 업그레이드', intervalUpgrade: '{plan}을(를) {interval}(으)로 변경', switchBackToInterval: '업그레이드 전에 {interval}(으)로 돌아가세요', intervalDowngradeUnavailable: '요금제를 변경하려면 구독을 취소하세요', checkoutUnavailable: '이 계정에서는 개인 요금제를 구매할 수 없습니다', scheduled: '{plan} · 예약됨',
56+
},
57+
de: {
58+
current: 'Aktueller Tarif', currentCanceling: 'Aktueller Tarif · Endet zum Periodenende', downgrade: 'Auf {plan} downgraden', upgrade: 'Auf {plan} upgraden', intervalUpgrade: '{plan} auf {interval} umstellen', switchBackToInterval: 'Vor dem Upgrade zu {interval} zurückwechseln', intervalDowngradeUnavailable: 'Kündige dein Abonnement, um den Tarif zu wechseln', checkoutUnavailable: 'Persönliche Tarife sind für dieses Konto nicht verfügbar', scheduled: '{plan} · Geplant',
59+
},
60+
fr: {
61+
current: 'Offre actuelle', currentCanceling: 'Offre actuelle · Résiliation en fin de période', downgrade: 'Rétrograder vers {plan}', upgrade: 'Passer à {plan}', intervalUpgrade: 'Passer {plan} en {interval}', switchBackToInterval: 'Revenez à {interval} avant la mise à niveau', intervalDowngradeUnavailable: 'Résiliez votre abonnement pour changer d’offre', checkoutUnavailable: 'Les offres personnelles sont indisponibles pour ce compte', scheduled: '{plan} · Planifié',
62+
},
63+
ru: {
64+
current: 'Текущий тариф', currentCanceling: 'Текущий тариф · Отмена в конце периода', downgrade: 'Понизить до {plan}', upgrade: 'Повысить до {plan}', intervalUpgrade: 'Перевести {plan} на {interval}', switchBackToInterval: 'Перед повышением вернитесь на {interval}', intervalDowngradeUnavailable: 'Отмените подписку, чтобы изменить тариф', checkoutUnavailable: 'Личные тарифы недоступны для этого аккаунта', scheduled: '{plan} · Запланировано',
65+
},
66+
es: {
67+
current: 'Plan actual', currentCanceling: 'Plan actual · Se cancela al final del periodo', downgrade: 'Bajar a {plan}', upgrade: 'Subir a {plan}', intervalUpgrade: 'Cambiar {plan} a {interval}', switchBackToInterval: 'Vuelve a {interval} antes de subir de plan', intervalDowngradeUnavailable: 'Cancela tu suscripción para cambiar de plan', checkoutUnavailable: 'Los planes personales no están disponibles para esta cuenta', scheduled: '{plan} · Programado',
68+
},
69+
'pt-br': {
70+
current: 'Plano atual', currentCanceling: 'Plano atual · Cancela ao fim do período', downgrade: 'Fazer downgrade para {plan}', upgrade: 'Fazer upgrade para {plan}', intervalUpgrade: 'Mudar {plan} para {interval}', switchBackToInterval: 'Volte para {interval} antes de fazer upgrade', intervalDowngradeUnavailable: 'Cancele a assinatura para mudar de plano', checkoutUnavailable: 'Planos pessoais não estão disponíveis para esta conta', scheduled: '{plan} · Agendado',
71+
},
3672
};
3773

3874
export function getCurrentPlanLabel(locale: LandingLocaleCode): string {
3975
return getPricingPlanActionLabels(locale).current;
4076
}
4177

4278
export function getPricingPlanActionLabels(locale: LandingLocaleCode): PricingPlanActionLabels {
43-
return PLAN_ACTION_LABELS[locale] ?? PLAN_ACTION_LABELS.en!;
79+
return { ...EN_PLAN_ACTION_LABELS, ...(PLAN_ACTION_LABELS[locale] ?? {}) };
4480
}
4581

4682
export type PlanTierId = 'plus' | 'pro' | 'max';

0 commit comments

Comments
 (0)