mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
fix(pricing): align follow-up acceptance details (#7228)
* fix(pricing): unify model display order * fix(pricing): scope banner colors and footnote size * fix(pricing): render all access checks consistently * fix(pricing): center model access icons
This commit is contained in:
@@ -50,7 +50,7 @@ const apiOrigin =
|
||||
min-height: 48px;
|
||||
padding: 7px 70px;
|
||||
color: #20241d;
|
||||
background: #e6efd5;
|
||||
background: #d8ffb5;
|
||||
border: 0;
|
||||
}
|
||||
.go-banner[hidden] { display: none !important; }
|
||||
|
||||
@@ -87,44 +87,19 @@ const unlimitedByTier: Record<TierId, Set<string>> = {
|
||||
max: new Set(popularModels.map((model) => model.name)),
|
||||
};
|
||||
|
||||
const popularOrderByTier: Record<TierId, string[]> = {
|
||||
go: [
|
||||
'DeepSeek V4 Flash',
|
||||
'DeepSeek V4 Pro',
|
||||
'GLM-5.2',
|
||||
'Kimi K2.7 Code',
|
||||
'MiniMax M2.7',
|
||||
'Kimi K2.6',
|
||||
'MiMo V2.5 Pro',
|
||||
'GLM-5.1',
|
||||
],
|
||||
plus: ['DeepSeek V4 Flash', 'DeepSeek V4 Pro', 'GLM-5.2', 'Kimi K2.7 Code'],
|
||||
pro: [
|
||||
'DeepSeek V4 Flash',
|
||||
'DeepSeek V4 Pro',
|
||||
'GLM-5.2',
|
||||
'Kimi K2.7 Code',
|
||||
'MiniMax M2.7',
|
||||
],
|
||||
max: [
|
||||
'DeepSeek V4 Flash',
|
||||
'DeepSeek V4 Pro',
|
||||
'GLM-5.2',
|
||||
'Kimi K2.7 Code',
|
||||
'MiniMax M2.7',
|
||||
'Kimi K2.6',
|
||||
'MiMo V2.5 Pro',
|
||||
'GLM-5.1',
|
||||
],
|
||||
};
|
||||
const popularModelDisplayOrder = [
|
||||
'DeepSeek V4 Flash',
|
||||
'DeepSeek V4 Pro',
|
||||
'GLM-5.2',
|
||||
'Kimi K2.7 Code',
|
||||
'MiMo V2.5 Pro',
|
||||
'MiniMax M2.7',
|
||||
'Kimi K2.6',
|
||||
'GLM-5.1',
|
||||
];
|
||||
|
||||
const popularOrderForTier = (tier: TierId) => {
|
||||
const lead = popularOrderByTier[tier];
|
||||
return [
|
||||
...lead.map((name) => popularModels.find((model) => model.name === name)!),
|
||||
...popularModels.filter((model) => !lead.includes(model.name)),
|
||||
];
|
||||
};
|
||||
const orderedPopularModels = () =>
|
||||
popularModelDisplayOrder.map((name) => popularModels.find((model) => model.name === name)!);
|
||||
|
||||
const renderCatalogLabel = (label: string) => fillTemplate(label, {
|
||||
skillsCount,
|
||||
@@ -273,16 +248,7 @@ const usageRows = [
|
||||
{ ...popularModel('GLM-5.1'), value: 670 },
|
||||
];
|
||||
|
||||
const comparisonPopular = [
|
||||
'DeepSeek V4 Flash',
|
||||
'DeepSeek V4 Pro',
|
||||
'GLM-5.2',
|
||||
'Kimi K2.7 Code',
|
||||
'MiniMax M2.7',
|
||||
'Kimi K2.6',
|
||||
'MiMo V2.5 Pro',
|
||||
'GLM-5.1',
|
||||
].map((name) => popularModels.find((model) => model.name === name)!);
|
||||
const comparisonPopular = orderedPopularModels();
|
||||
|
||||
const comparisonFlagship = [
|
||||
'Kimi-K3',
|
||||
@@ -356,7 +322,7 @@ const otherModels = 'Gemini-3.1-Pro, Grok-4.5, GPT-5.4, GPT-5.6-Terra, Qwen3.7-M
|
||||
<div class="benefits-summary-stage">
|
||||
<div class="four-tier-grid decision-grid">
|
||||
{cardData.map(({ tier, logo, view }) => {
|
||||
const popular = popularOrderForTier(tier);
|
||||
const popular = orderedPopularModels();
|
||||
const flagship = tier === 'plus' ? plusFlagshipModels : flagshipModels;
|
||||
const imageResolution = tier === 'plus' ? '1K' : tier === 'pro' ? '2K' : '4K';
|
||||
const bonusPct = tier === 'go' ? null : CREDIT_BONUS_PCT[tier];
|
||||
@@ -566,7 +532,8 @@ const otherModels = 'Gemini-3.1-Pro, Grok-4.5, GPT-5.4, GPT-5.6-Terra, Qwen3.7-M
|
||||
<td class="model-name-cell"><span class:list={['model-logo', model.iconClass]}><img src={model.icon} alt="" loading="lazy" /></span><span>{model.name}</span></td>
|
||||
{(['go', 'plus', 'pro', 'max'] as TierId[]).map((tier) => {
|
||||
const status = accessStatus(category, model.name, tier);
|
||||
return status.kind === 'more-ample' ? (
|
||||
const checkOnly = status.kind === 'more-ample' || status.kind === 'ample' || status.kind === 'included';
|
||||
return checkOnly ? (
|
||||
<td><span class={`model-access-status ${status.kind}`} aria-label={status.text}><i class="status-icon check"></i></span></td>
|
||||
) : (
|
||||
<td><span class={`model-access-status ${status.kind}`}><i class:list={['status-icon', { infinity: status.kind === 'unlimited' }]}>{status.kind === 'unlimited' ? '∞' : ''}</i>{status.text}</span></td>
|
||||
@@ -676,7 +643,7 @@ const otherModels = 'Gemini-3.1-Pro, Grok-4.5, GPT-5.4, GPT-5.6-Terra, Qwen3.7-M
|
||||
line-height: 1;
|
||||
letter-spacing: 0.055em;
|
||||
text-align: center;
|
||||
background: #d8ffb5;
|
||||
background: var(--pricing-accent);
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
|
||||
@@ -1598,7 +1565,12 @@ const otherModels = 'Gemini-3.1-Pro, Grok-4.5, GPT-5.4, GPT-5.6-Terra, Qwen3.7-M
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.model-access-status.more-ample {
|
||||
.model-access-status.more-ample,
|
||||
.model-access-status.ample,
|
||||
.model-access-status.included {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
place-items: center;
|
||||
width: 104px;
|
||||
height: 23px;
|
||||
}
|
||||
@@ -1632,26 +1604,6 @@ const otherModels = 'Gemini-3.1-Pro, Grok-4.5, GPT-5.4, GPT-5.6-Terra, Qwen3.7-M
|
||||
transform: translateY(-1px) rotate(45deg);
|
||||
}
|
||||
|
||||
.model-access-status.ample,
|
||||
.model-access-status.included {
|
||||
width: 104px;
|
||||
height: 23px;
|
||||
overflow: hidden;
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.model-access-status.ample::after,
|
||||
.model-access-status.included::after {
|
||||
grid-column: 1;
|
||||
justify-self: center;
|
||||
width: 5px;
|
||||
height: 9px;
|
||||
content: '';
|
||||
border: 2px solid;
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: translateY(-1px) rotate(45deg);
|
||||
}
|
||||
|
||||
.model-access-status.unavailable {
|
||||
position: relative;
|
||||
width: 104px;
|
||||
@@ -1664,7 +1616,7 @@ const otherModels = 'Gemini-3.1-Pro, Grok-4.5, GPT-5.4, GPT-5.6-Terra, Qwen3.7-M
|
||||
.model-access-status.unavailable::after {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 11.5px;
|
||||
left: 50%;
|
||||
width: 9px;
|
||||
height: 1.5px;
|
||||
content: '';
|
||||
|
||||
@@ -836,14 +836,14 @@ const ZH_CN: PricingContent = {
|
||||
features: ['BYOK 自带密钥,支持本地 Coding Agent', '社区支持'],
|
||||
},
|
||||
go: {
|
||||
tagline: '轻量需求,轻松交付 · 零配置即用',
|
||||
tagline: '轻量需求,轻松交付',
|
||||
ctaLabel: '订阅 Go',
|
||||
allowance: '8 个热门模型 · 充裕额度',
|
||||
features: ['完整设计与 Coding 能力', '无需配置供应商 API Key', '额度自动恢复'],
|
||||
},
|
||||
plans: {
|
||||
plus: {
|
||||
tagline: '独立项目、零散需求,单人交付 · 零配置即用',
|
||||
tagline: '独立项目、零散需求,单人交付',
|
||||
ctaLabel: '升级 Plus',
|
||||
concurrency: '2 个任务并发',
|
||||
features: [
|
||||
@@ -854,7 +854,7 @@ const ZH_CN: PricingContent = {
|
||||
],
|
||||
},
|
||||
pro: {
|
||||
tagline: '一个人产出整个设计团队的活 · 零配置即用',
|
||||
tagline: '一个人产出整个设计团队的活',
|
||||
ctaLabel: '升级 Pro',
|
||||
concurrency: '5 个任务并发',
|
||||
features: [
|
||||
@@ -865,7 +865,7 @@ const ZH_CN: PricingContent = {
|
||||
],
|
||||
},
|
||||
max: {
|
||||
tagline: '把外包设计费砸到零头 · 零配置即用',
|
||||
tagline: '把外包设计费砸到零头',
|
||||
ctaLabel: '升级 Max',
|
||||
concurrency: '10 个任务并发',
|
||||
features: [
|
||||
@@ -918,14 +918,14 @@ const ZH_TW: PricingContent = {
|
||||
features: ['BYOK 自帶密鑰,支援本機 Coding Agent', '社群支援'],
|
||||
},
|
||||
go: {
|
||||
tagline: '輕量需求,輕鬆交付 · 零配置即用',
|
||||
tagline: '輕量需求,輕鬆交付',
|
||||
ctaLabel: '訂閱 Go',
|
||||
allowance: '8 個熱門模型 · 充裕額度',
|
||||
features: ['完整設計與 Coding 能力', '無需配置供應商 API Key', '額度自動恢復'],
|
||||
},
|
||||
plans: {
|
||||
plus: {
|
||||
tagline: '獨立專案、零散需求,單人交付 · 零配置即用',
|
||||
tagline: '獨立專案、零散需求,單人交付',
|
||||
ctaLabel: '升級 Plus',
|
||||
concurrency: '2 個任務並行',
|
||||
features: [
|
||||
@@ -936,7 +936,7 @@ const ZH_TW: PricingContent = {
|
||||
],
|
||||
},
|
||||
pro: {
|
||||
tagline: '一個人產出整個設計團隊的活 · 零配置即用',
|
||||
tagline: '一個人產出整個設計團隊的活',
|
||||
ctaLabel: '升級 Pro',
|
||||
concurrency: '5 個任務並行',
|
||||
features: [
|
||||
@@ -947,7 +947,7 @@ const ZH_TW: PricingContent = {
|
||||
],
|
||||
},
|
||||
max: {
|
||||
tagline: '把外包設計費砍到零頭 · 零配置即用',
|
||||
tagline: '把外包設計費砍到零頭',
|
||||
ctaLabel: '升級 Max',
|
||||
concurrency: '10 個任務並行',
|
||||
features: [
|
||||
|
||||
@@ -254,9 +254,9 @@ const cobeEnhancerUrl = `/enhancers/cobe.js?v=${
|
||||
left: 0;
|
||||
right: 0;
|
||||
min-height: var(--home-campaign-banner-height);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--coral) 36%, var(--line));
|
||||
border-bottom: 1px solid color-mix(in srgb, #78ea57 36%, var(--line));
|
||||
color: var(--ink);
|
||||
background: color-mix(in srgb, var(--coral) 11%, #fff);
|
||||
background: #d8ffb5;
|
||||
font-family: var(--sans);
|
||||
font-size: 13px;
|
||||
line-height: 1.25;
|
||||
|
||||
@@ -185,6 +185,7 @@ const jsonLd = [
|
||||
<article
|
||||
class="info-page pr-page"
|
||||
data-pricing-root
|
||||
data-pricing-locale={locale}
|
||||
data-audience="creator"
|
||||
data-interval="yearly"
|
||||
data-cloud-console-environment="production"
|
||||
@@ -1425,6 +1426,9 @@ const jsonLd = [
|
||||
line-height: 1.6;
|
||||
color: var(--ink-soft, #666);
|
||||
}
|
||||
.pr-page[data-pricing-locale='en'] .pr-foot {
|
||||
font-size: calc(10.5px + 2pt);
|
||||
}
|
||||
|
||||
/* ---- Team / Enterprise plans ---- */
|
||||
.pr-team-panel {
|
||||
|
||||
@@ -45,6 +45,7 @@ test('homepage mounts Go ahead of the existing paid-user DeepSeek banner', () =>
|
||||
assert.match(home, /import GoBanner from ['"]\.\.\/_components\/go-banner\.astro['"]/);
|
||||
assert.match(home, /<GoBanner locale=\{locale\} \/>[\s\S]*data-home-campaign-banner/);
|
||||
assert.match(banner, /html\.go-banner-active \.home-campaign-banner/);
|
||||
assert.match(banner, /\.go-banner\s*\{[^}]*background:\s*#d8ffb5;/s);
|
||||
});
|
||||
|
||||
test('Go banner keeps its sticky height in flow without double-offsetting the mobile hero', () => {
|
||||
|
||||
@@ -27,6 +27,7 @@ test('home campaign banner keeps only the arrow visible while preserving an acce
|
||||
assert.match(source, /data-home-campaign-countdown/);
|
||||
assert.match(source, /data-campaign-window-label/);
|
||||
assert.doesNotMatch(source, /距开始/);
|
||||
assert.match(source, /\.home-campaign-banner\s*\{[^}]*background:\s*#d8ffb5;/s);
|
||||
assert.match(source, /background:\s*#68f22e/);
|
||||
assert.match(source, /home-campaign-banner__cta/);
|
||||
assert.match(source, /<span class="home-campaign-banner__cta" aria-hidden="true">→<\/span>/);
|
||||
|
||||
@@ -176,6 +176,8 @@ describe("pricing contract", () => {
|
||||
|
||||
it("matches the demo's individual taglines and compact billing copy", async () => {
|
||||
const content = getPricingContent("en");
|
||||
const zhContent = getPricingContent("zh");
|
||||
const zhTwContent = getPricingContent("zh-tw");
|
||||
const individualPlans = await readFile(PRICING_INDIVIDUAL_PATH, "utf8");
|
||||
|
||||
assert.deepEqual(
|
||||
@@ -192,6 +194,17 @@ describe("pricing contract", () => {
|
||||
"High-volume creation · Consistent output",
|
||||
],
|
||||
);
|
||||
for (const localizedContent of [zhContent, zhTwContent]) {
|
||||
assert.deepEqual(
|
||||
[
|
||||
localizedContent.go.tagline,
|
||||
localizedContent.plans.plus.tagline,
|
||||
localizedContent.plans.pro.tagline,
|
||||
localizedContent.plans.max.tagline,
|
||||
].some((tagline) => tagline.includes("零配置即用")),
|
||||
false,
|
||||
);
|
||||
}
|
||||
assert.equal(content.labels.monthlyRenewal, "First-month price");
|
||||
assert.equal(content.labels.yearlySubline, "Billed {totalUsd}/year");
|
||||
assert.deepEqual(
|
||||
@@ -237,7 +250,7 @@ describe("pricing contract", () => {
|
||||
);
|
||||
assert.match(
|
||||
individualPlans,
|
||||
/\.new-plan-ribbon\s*\{[^}]*background:\s*#d8ffb5;/s,
|
||||
/\.new-plan-ribbon\s*\{[^}]*background:\s*var\(--pricing-accent\);/s,
|
||||
);
|
||||
assert.match(
|
||||
individualPlans,
|
||||
@@ -424,31 +437,39 @@ describe("pricing contract", () => {
|
||||
|
||||
it("uses the reviewed popular-model order and keeps ample access check-only", async () => {
|
||||
const individualPlans = await readFile(PRICING_INDIVIDUAL_PATH, "utf8");
|
||||
const comparisonBlock = individualPlans.match(
|
||||
/const comparisonPopular = \[([\s\S]*?)\]\.map/,
|
||||
const displayOrderBlock = individualPlans.match(
|
||||
/const popularModelDisplayOrder = \[([\s\S]*?)\];/,
|
||||
)?.[1];
|
||||
|
||||
assert.ok(comparisonBlock);
|
||||
assert.ok(displayOrderBlock);
|
||||
const reviewedOrder = [
|
||||
"DeepSeek V4 Flash",
|
||||
"DeepSeek V4 Pro",
|
||||
"GLM-5.2",
|
||||
"Kimi K2.7 Code",
|
||||
"MiMo V2.5 Pro",
|
||||
"MiniMax M2.7",
|
||||
"Kimi K2.6",
|
||||
"MiMo V2.5 Pro",
|
||||
"GLM-5.1",
|
||||
];
|
||||
assert.deepEqual(
|
||||
Array.from(comparisonBlock.matchAll(/'([^']+)'/g), (match) => match[1]),
|
||||
Array.from(displayOrderBlock.matchAll(/'([^']+)'/g), (match) => match[1]),
|
||||
reviewedOrder,
|
||||
);
|
||||
assert.match(individualPlans, /go:\s*\[[\s\S]*?'GLM-5\.1',\s*\],\s*plus:/);
|
||||
assert.match(individualPlans, /plus:\s*\['DeepSeek V4 Flash', 'DeepSeek V4 Pro', 'GLM-5\.2', 'Kimi K2\.7 Code'\]/);
|
||||
assert.match(individualPlans, /pro:\s*\[[\s\S]*?'MiniMax M2\.7',\s*\],\s*max:/);
|
||||
assert.match(individualPlans, /const comparisonPopular = orderedPopularModels\(\);/);
|
||||
assert.match(individualPlans, /const popular = orderedPopularModels\(\);/);
|
||||
assert.match(
|
||||
individualPlans,
|
||||
/status\.kind === 'more-ample'\s*\?\s*\(\s*<td><span class=\{`model-access-status \$\{status\.kind\}`\} aria-label=\{status\.text\}><i class="status-icon check"><\/i><\/span><\/td>/s,
|
||||
/const checkOnly = status\.kind === 'more-ample' \|\| status\.kind === 'ample' \|\| status\.kind === 'included';\s*return checkOnly \? \(\s*<td><span class=\{`model-access-status \$\{status\.kind\}`\} aria-label=\{status\.text\}><i class="status-icon check"><\/i><\/span><\/td>/s,
|
||||
);
|
||||
assert.doesNotMatch(individualPlans, /\.model-access-status\.(?:ample|included)::after/);
|
||||
assert.match(
|
||||
individualPlans,
|
||||
/\.model-access-status\.more-ample,\s*\.model-access-status\.ample,\s*\.model-access-status\.included\s*\{[^}]*grid-template-columns:\s*1fr;[^}]*place-items:\s*center;/s,
|
||||
);
|
||||
assert.match(
|
||||
individualPlans,
|
||||
/\.model-access-status\.unavailable::before,\s*\.model-access-status\.unavailable::after\s*\{[^}]*left:\s*50%;/s,
|
||||
);
|
||||
assert.match(
|
||||
individualPlans,
|
||||
@@ -456,7 +477,7 @@ describe("pricing contract", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("matches the legal footnote size to the model allowance note", async () => {
|
||||
it("makes only the English legal footnote 2pt larger than the model allowance note", async () => {
|
||||
const [page, individualPlans] = await Promise.all([
|
||||
readFile(PRICING_PAGE_PATH, "utf8"),
|
||||
readFile(PRICING_INDIVIDUAL_PATH, "utf8"),
|
||||
@@ -464,6 +485,11 @@ describe("pricing contract", () => {
|
||||
|
||||
assert.match(individualPlans, /\.individual-usage-note p\s*\{[^}]*font-size:\s*10\.5px;/s);
|
||||
assert.match(page, /\.pr-foot\s*\{[^}]*font-size:\s*10\.5px;/s);
|
||||
assert.match(page, /data-pricing-locale=\{locale\}/);
|
||||
assert.match(
|
||||
page,
|
||||
/\.pr-page\[data-pricing-locale='en'\] \.pr-foot\s*\{[^}]*font-size:\s*calc\(10\.5px \+ 2pt\);/s,
|
||||
);
|
||||
});
|
||||
|
||||
it("renders paid flagship headings as one localized phrase", async () => {
|
||||
|
||||
Reference in New Issue
Block a user