Files
ComfyUI_frontend/apps/website/src/components/pricing/PriceSection.vue
imick-io eeeacc9b03 feat(website): constrain sections to max-w-9xl on wide screens (#12428)
Add max-w-9xl mx-auto to section/container wrappers across the website
so layout stays centered and capped at 96rem on screens wider than
1536px.

## Summary

<!-- One sentence describing what changed and why. -->

## Changes

- **What**: <!-- Core functionality added/modified -->
- **Breaking**: <!-- Any breaking changes (if none, remove this line)
-->
- **Dependencies**: <!-- New dependencies (if none, remove this line)
-->

## Review Focus

<!-- Critical design decisions or edge cases that need attention -->

<!-- If this PR fixes an issue, uncomment and update the line below -->
<!-- Fixes #ISSUE_NUMBER -->

## Screenshots (if applicable)

<!-- Add screenshots or video recording to help explain your changes -->

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 21:36:29 +00:00

428 lines
13 KiB
Vue

<script setup lang="ts">
import type { Locale, TranslationKey } from '../../i18n/translations'
import { cn } from '@comfyorg/tailwind-utils'
import { ref } from 'vue'
import BrandButton from '../common/BrandButton.vue'
import PricingPlanFeatureList from './PricingPlanFeatureList.vue'
import PricingTierCard from './PricingTierCard.vue'
import { SHOW_FREE_TIER } from '../../config/features'
import { externalLinks, getRoutes } from '../../config/routes'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
function subscribeUrl(tier: string): string {
return `${externalLinks.cloud}/cloud/subscribe?tier=${tier}&cycle=monthly`
}
interface PlanFeature {
text: TranslationKey
}
interface PricingPlan {
id: string
labelKey: TranslationKey
summaryKey: TranslationKey
priceKey?: TranslationKey
creditsKey?: TranslationKey
estimateKey?: TranslationKey
ctaKey: TranslationKey
ctaHref: string
featureIntroKey?: TranslationKey
features: PlanFeature[]
andMoreKey?: TranslationKey
image?: string
isPopular?: boolean
isEnterprise?: boolean
}
const freePlan: PricingPlan = {
id: 'free',
labelKey: 'pricing.plan.free.label',
summaryKey: 'pricing.plan.free.summary',
priceKey: 'pricing.plan.free.price',
creditsKey: 'pricing.plan.free.credits',
estimateKey: 'pricing.plan.free.estimate',
ctaKey: 'pricing.plan.free.cta',
ctaHref: externalLinks.cloud,
features: [
{ text: 'pricing.plan.free.feature1' },
{ text: 'pricing.plan.free.feature2' }
]
}
const plans: PricingPlan[] = [
...(SHOW_FREE_TIER ? [freePlan] : []),
{
id: 'standard',
labelKey: 'pricing.plan.standard.label',
summaryKey: 'pricing.plan.standard.summary',
priceKey: 'pricing.plan.standard.price',
creditsKey: 'pricing.plan.standard.credits',
estimateKey: 'pricing.plan.standard.estimate',
ctaKey: 'pricing.plan.standard.cta',
ctaHref: subscribeUrl('standard'),
featureIntroKey: SHOW_FREE_TIER
? 'pricing.plan.standard.featureIntro'
: undefined,
features: [
{ text: 'pricing.plan.standard.feature1' },
{ text: 'pricing.plan.standard.feature2' }
]
},
{
id: 'creator',
labelKey: 'pricing.plan.creator.label',
summaryKey: 'pricing.plan.creator.summary',
priceKey: 'pricing.plan.creator.price',
creditsKey: 'pricing.plan.creator.credits',
estimateKey: 'pricing.plan.creator.estimate',
ctaKey: 'pricing.plan.creator.cta',
ctaHref: subscribeUrl('creator'),
featureIntroKey: 'pricing.plan.creator.featureIntro',
features: [
{ text: 'pricing.plan.creator.feature1' },
{ text: 'pricing.plan.creator.feature2' }
],
isPopular: true
},
{
id: 'pro',
labelKey: 'pricing.plan.pro.label',
summaryKey: 'pricing.plan.pro.summary',
priceKey: 'pricing.plan.pro.price',
creditsKey: 'pricing.plan.pro.credits',
estimateKey: 'pricing.plan.pro.estimate',
ctaKey: 'pricing.plan.pro.cta',
ctaHref: subscribeUrl('pro'),
featureIntroKey: 'pricing.plan.pro.featureIntro',
features: [
{ text: 'pricing.plan.pro.feature1' },
{ text: 'pricing.plan.pro.feature2' }
]
},
{
id: 'enterprise',
labelKey: 'pricing.enterprise.label',
summaryKey: 'pricing.enterprise.description',
ctaKey: 'pricing.enterprise.cta',
ctaHref: getRoutes(locale).cloudEnterprise,
features: [],
isEnterprise: true
}
]
const standardPlans = plans.filter((p) => !p.isEnterprise)
const enterprisePlan = plans.find((p) => p.isEnterprise)!
const activePlanIndex = ref(0)
</script>
<template>
<section class="max-w-9xl mx-auto px-4 py-16 lg:px-20 lg:py-14">
<!-- Header -->
<div class="mx-auto mb-8 max-w-3xl text-center lg:mb-10">
<h1
class="text-primary-comfy-canvas font-formula text-4xl font-light lg:text-5xl"
>
{{ t('pricing.title', locale) }}
</h1>
<p class="text-primary-comfy-canvas mt-3 text-base">
{{ t('pricing.subtitle', locale) }}
</p>
</div>
<!-- Mobile plan tabs -->
<div class="mb-6 flex scrollbar-none gap-2 overflow-x-auto lg:hidden">
<button
v-for="(plan, index) in plans"
:key="plan.id"
:class="
cn(
'shrink-0 rounded-full px-4 py-2 text-xs font-bold tracking-wider transition-colors',
activePlanIndex === index
? 'bg-primary-comfy-yellow text-primary-comfy-ink'
: 'bg-transparency-white-t4 text-primary-comfy-canvas'
)
"
@click="activePlanIndex = index"
>
<span class="ppformula-text-center">
{{ t(plan.labelKey, locale) }}
</span>
</button>
</div>
<!-- Desktop: dynamic grid (3 or 4 columns) / Mobile: single card -->
<div
:class="
cn(
'rounded-5xl bg-transparency-white-t4 hidden p-2 lg:grid lg:gap-2',
standardPlans.length === 4 ? 'lg:grid-cols-4' : 'lg:grid-cols-3'
)
"
>
<PricingTierCard v-for="plan in standardPlans" :key="plan.id">
<!-- Label + badge -->
<div class="flex items-center gap-2 px-6 pt-6">
<span
class="text-primary-comfy-yellow translate-y-0.5 text-base font-bold tracking-wider"
>
{{ t(plan.labelKey, locale) }}
</span>
<span v-if="plan.isPopular" class="flex h-5 items-stretch">
<img
src="/icons/node-left.svg"
alt=""
class="-mx-px self-stretch"
aria-hidden="true"
/>
<span
class="bg-primary-comfy-yellow font-formula-narrow text-primary-comfy-ink flex items-center px-2 text-sm font-bold tracking-wider"
>
<span class="ppformula-text-center">
{{ t('pricing.badge.popular', locale) }}
</span>
</span>
<img
src="/icons/node-right.svg"
alt=""
class="-mx-px self-stretch"
aria-hidden="true"
/>
</span>
</div>
<!-- Summary -->
<p class="text-primary-comfy-canvas px-6 text-sm">
{{ t(plan.summaryKey, locale) }}
</p>
<!-- Price -->
<div v-if="plan.priceKey" class="flex items-baseline gap-1 px-6 pt-2">
<span
class="text-primary-comfy-canvas font-formula text-5xl font-light"
>
{{ t(plan.priceKey, locale) }}
</span>
<span class="text-primary-comfy-canvas text-sm">
{{ t('pricing.plan.period', locale) }}
</span>
</div>
<div v-else class="px-6 pt-2" />
<!-- Credits -->
<p
v-if="plan.creditsKey"
class="text-primary-comfy-canvas px-6 text-sm"
>
{{ t(plan.creditsKey, locale) }}
</p>
<div v-else class="px-6" />
<!-- Estimate -->
<p
v-if="plan.estimateKey"
class="text-primary-comfy-canvas/80 px-6 text-xs"
>
{{ t(plan.estimateKey, locale) }}
</p>
<div v-else class="px-6" />
<!-- Features -->
<div v-if="plan.features.length" class="px-6 py-3">
<p
v-if="plan.featureIntroKey"
class="text-primary-comfy-canvas mb-2 text-sm font-semibold"
>
{{ t(plan.featureIntroKey, locale) }}
</p>
<p
v-else
class="text-primary-comfy-canvas mb-2 text-sm font-semibold"
aria-hidden="true"
>
&nbsp;
</p>
<ul class="space-y-2">
<li
v-for="feature in plan.features"
:key="feature.text"
class="flex items-start gap-2"
>
<span class="text-primary-comfy-yellow mt-0.5 text-sm"></span>
<span class="text-primary-comfy-canvas text-sm">
{{ t(feature.text, locale) }}
</span>
</li>
</ul>
</div>
<!-- CTA -->
<div class="flex self-end px-6">
<BrandButton
:href="plan.ctaHref"
variant="outline"
size="sm"
class="w-full text-center"
>
{{ t(plan.ctaKey, locale) }}
</BrandButton>
</div>
</PricingTierCard>
</div>
<!-- Mobile: single plan view -->
<div class="lg:hidden">
<div
v-for="(plan, index) in plans"
:key="plan.id"
:class="cn('flex-col', activePlanIndex !== index ? 'hidden' : 'flex')"
>
<!-- Main info card -->
<div class="bg-transparency-white-t4 rounded-3xl p-6">
<!-- Label + badge -->
<div class="flex items-center gap-2">
<span
class="text-primary-comfy-yellow text-xs font-bold tracking-wider"
>
{{ t(plan.labelKey, locale) }}
</span>
<span v-if="plan.isPopular" class="flex h-5 items-stretch">
<img
src="/icons/node-left.svg"
alt=""
class="-mx-px self-stretch"
aria-hidden="true"
/>
<span
class="bg-primary-comfy-yellow text-primary-comfy-ink flex items-center px-2 text-[10px] font-bold tracking-wider"
>
<span class="ppformula-text-center">
{{ t('pricing.badge.popular', locale) }}
</span>
</span>
<img
src="/icons/node-right.svg"
alt=""
class="-mx-px self-stretch"
aria-hidden="true"
/>
</span>
</div>
<!-- Enterprise heading -->
<h2
v-if="plan.isEnterprise"
class="text-primary-comfy-canvas mt-3 text-2xl font-light"
>
{{ t('pricing.enterprise.heading', locale) }}
</h2>
<!-- Summary -->
<p class="text-primary-comfy-canvas mt-2 text-sm">
{{ t(plan.summaryKey, locale) }}
</p>
<!-- Price (standard plans only) -->
<template v-if="plan.priceKey">
<div class="mt-6 flex items-baseline gap-1">
<span
class="text-primary-comfy-canvas font-formula text-5xl font-light"
>
{{ t(plan.priceKey, locale) }}
</span>
<span class="text-primary-comfy-canvas/55 text-sm">
{{ t('pricing.plan.period', locale) }}
</span>
</div>
<p
v-if="plan.creditsKey"
class="text-primary-comfy-canvas mt-4 text-xs font-medium"
>
{{ t(plan.creditsKey, locale) }}
</p>
<p
v-if="plan.estimateKey"
class="text-primary-comfy-canvas mt-2 text-xs"
>
{{ t(plan.estimateKey, locale) }}
</p>
</template>
<!-- CTA -->
<div class="mt-6">
<BrandButton
:href="plan.ctaHref"
variant="outline"
size="lg"
class="w-full text-center"
>
{{ t(plan.ctaKey, locale) }}
</BrandButton>
</div>
</div>
<!-- Features card -->
<div
v-if="plan.features.length"
class="bg-transparency-white-t4 mt-2 rounded-3xl p-6"
>
<PricingPlanFeatureList
:features="plan.features"
:feature-intro-key="plan.featureIntroKey"
:and-more-key="plan.andMoreKey"
:locale
/>
</div>
<!-- Image (standard plans only) -->
<div v-if="plan.image" class="mt-2">
<img
:src="plan.image"
:alt="t(plan.labelKey, locale)"
class="aspect-21/9 w-full rounded-3xl object-cover"
/>
</div>
</div>
</div>
<!-- Enterprise section (desktop only, mobile handled in plan loop) -->
<div
class="bg-transparency-white-t4 rounded-5xl mt-8 hidden w-full flex-col p-2 lg:mt-8 lg:flex lg:flex-row"
>
<!-- Left side -->
<div
class="bg-primary-comfy-ink rounded-4.5xl flex w-full flex-col items-start justify-between gap-8 p-8"
>
<div>
<span
class="text-primary-comfy-yellow text-xs font-bold tracking-wider"
>
{{ t(enterprisePlan.labelKey, locale) }}
</span>
<h2
class="text-primary-comfy-canvas mt-3 text-2xl font-light lg:text-3xl"
>
{{ t('pricing.enterprise.heading', locale) }}
</h2>
<p class="text-primary-comfy-canvas mt-3 text-sm">
{{ t(enterprisePlan.summaryKey, locale) }}
</p>
</div>
<BrandButton :href="enterprisePlan.ctaHref" variant="outline" size="lg">
{{ t(enterprisePlan.ctaKey, locale) }}
</BrandButton>
</div>
</div>
<!-- Footnote -->
<p class="text-primary-comfy-canvas/70 mt-12 text-xs">
{{ t('pricing.footnote', locale) }}
</p>
</section>
</template>