Compare commits
5 Commits
feat/prici
...
codex/cove
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6260c101e5 | ||
|
|
7ab6cb57c5 | ||
|
|
3c3a2ab4e2 | ||
|
|
a07854755f | ||
|
|
2adef5d9f6 |
@@ -1,5 +1,4 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
@@ -8,27 +7,36 @@ test.describe('Pricing page @smoke', () => {
|
||||
await page.goto('/cloud/pricing')
|
||||
})
|
||||
|
||||
const pricingSection = (page: Page) =>
|
||||
page.locator('section').filter({
|
||||
has: page.getByRole('heading', { name: /Choose a plan/i })
|
||||
})
|
||||
|
||||
test('shows the three paid tiers and Enterprise', async ({ page }) => {
|
||||
const section = pricingSection(page)
|
||||
const pricingGrid = page
|
||||
.locator('section', {
|
||||
has: page.getByRole('heading', { name: /Pricing/i })
|
||||
})
|
||||
.locator('.lg\\:grid')
|
||||
|
||||
for (const label of ['STANDARD', 'CREATOR', 'PRO', 'ENTERPRISE']) {
|
||||
for (const label of ['STANDARD', 'CREATOR', 'PRO']) {
|
||||
await expect(
|
||||
section.locator('span', { hasText: new RegExp(`^${label}$`) })
|
||||
pricingGrid.locator('span', { hasText: new RegExp(`^${label}$`) })
|
||||
).toBeVisible()
|
||||
}
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /Looking for Enterprise Solutions/i })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('does not show the Free tier when SHOW_FREE_TIER is disabled', async ({
|
||||
page
|
||||
}) => {
|
||||
const section = pricingSection(page)
|
||||
const pricingGrid = page
|
||||
.locator('section', {
|
||||
has: page.getByRole('heading', { name: /Pricing/i })
|
||||
})
|
||||
.locator('.lg\\:grid')
|
||||
|
||||
await expect(section.locator('span', { hasText: /^FREE$/ })).toHaveCount(0)
|
||||
await expect(
|
||||
pricingGrid.locator('span', { hasText: /^FREE$/ })
|
||||
).toHaveCount(0)
|
||||
await expect(page.getByRole('link', { name: /^START FREE$/ })).toHaveCount(
|
||||
0
|
||||
)
|
||||
|
||||
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 95 KiB |
@@ -1,15 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import Accordion from '../ui/accordion/Accordion.vue'
|
||||
import AccordionContent from '../ui/accordion/AccordionContent.vue'
|
||||
import AccordionItem from '../ui/accordion/AccordionItem.vue'
|
||||
import AccordionTrigger from '../ui/accordion/AccordionTrigger.vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { reactive, watch } from 'vue'
|
||||
|
||||
type Faq = { id: string; question: string; answer: string }
|
||||
|
||||
defineProps<{
|
||||
const { faqs } = defineProps<{
|
||||
heading: string
|
||||
faqs: readonly Faq[]
|
||||
}>()
|
||||
|
||||
const expanded = reactive<boolean[]>(faqs.map(() => false))
|
||||
|
||||
watch(
|
||||
() => faqs.length,
|
||||
(length) => {
|
||||
if (length === expanded.length) return
|
||||
expanded.length = 0
|
||||
for (let i = 0; i < length; i += 1) expanded.push(false)
|
||||
}
|
||||
)
|
||||
|
||||
function toggle(index: number) {
|
||||
expanded[index] = !expanded[index]
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -25,23 +38,57 @@ defineProps<{
|
||||
</div>
|
||||
|
||||
<!-- Right FAQ list -->
|
||||
<Accordion type="multiple" class="flex-1">
|
||||
<AccordionItem
|
||||
<div class="flex-1">
|
||||
<div
|
||||
v-for="(faq, index) in faqs"
|
||||
:key="faq.id"
|
||||
:value="faq.id"
|
||||
class="border-b border-primary-comfy-canvas/20"
|
||||
>
|
||||
<AccordionTrigger :class="index === 0 ? 'pt-0' : ''">
|
||||
{{ faq.question }}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<p
|
||||
class="text-sm whitespace-pre-line text-primary-comfy-canvas/70"
|
||||
v-html="faq.answer"
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
<button
|
||||
:id="`faq-trigger-${faq.id}`"
|
||||
type="button"
|
||||
:aria-expanded="expanded[index]"
|
||||
:aria-controls="`faq-panel-${faq.id}`"
|
||||
:class="
|
||||
cn(
|
||||
'flex w-full cursor-pointer items-center justify-between text-left',
|
||||
index === 0 ? 'pb-6' : 'py-6'
|
||||
)
|
||||
"
|
||||
@click="toggle(index)"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'text-lg font-light md:text-xl',
|
||||
expanded[index]
|
||||
? 'text-primary-comfy-yellow'
|
||||
: 'text-primary-comfy-canvas'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ faq.question }}
|
||||
</span>
|
||||
<span
|
||||
class="text-primary-comfy-yellow ml-4 shrink-0 text-2xl"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{{ expanded[index] ? '−' : '+' }}
|
||||
</span>
|
||||
</button>
|
||||
<section
|
||||
v-show="expanded[index]"
|
||||
:id="`faq-panel-${faq.id}`"
|
||||
role="region"
|
||||
:aria-labelledby="`faq-trigger-${faq.id}`"
|
||||
class="pb-6"
|
||||
>
|
||||
<p class="text-sm whitespace-pre-line text-primary-comfy-canvas/70">
|
||||
{{ faq.answer }}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import FAQSplit01 from '../blocks/FAQSplit01.vue'
|
||||
import { pricingFaqs } from '../../data/pricingFaq'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const faqs = pricingFaqs.map((faq) => ({
|
||||
id: faq.id,
|
||||
question: faq.question[locale],
|
||||
answer: faq.answer[locale]
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FAQSplit01 :heading="t('pricing.faq.heading', locale)" :faqs="faqs" />
|
||||
</template>
|
||||
393
apps/website/src/components/pricing/PriceSection.vue
Normal file
@@ -0,0 +1,393 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
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' },
|
||||
{ text: 'pricing.plan.standard.feature3' }
|
||||
]
|
||||
},
|
||||
{
|
||||
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)!
|
||||
</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="font-formula text-4xl font-light text-primary-comfy-canvas lg:text-5xl"
|
||||
>
|
||||
{{ t('pricing.title', locale) }}
|
||||
</h1>
|
||||
<p class="mt-3 text-base text-primary-comfy-canvas">
|
||||
{{ t('pricing.subtitle', locale) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Desktop: dynamic grid (3 or 4 columns) / Mobile: stacked cards -->
|
||||
<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 flex items-center px-2 text-sm font-bold tracking-wider text-primary-comfy-ink"
|
||||
>
|
||||
<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="px-6 text-sm text-primary-comfy-canvas">
|
||||
{{ t(plan.summaryKey, locale) }}
|
||||
</p>
|
||||
|
||||
<!-- Price -->
|
||||
<div v-if="plan.priceKey" class="flex items-baseline gap-1 px-6 pt-2">
|
||||
<span
|
||||
class="font-formula text-5xl font-light text-primary-comfy-canvas"
|
||||
>
|
||||
{{ t(plan.priceKey, locale) }}
|
||||
</span>
|
||||
<span class="text-sm text-primary-comfy-canvas">
|
||||
{{ t('pricing.plan.period', locale) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="px-6 pt-2" />
|
||||
|
||||
<!-- Credits -->
|
||||
<p
|
||||
v-if="plan.creditsKey"
|
||||
class="px-6 text-sm text-primary-comfy-canvas"
|
||||
>
|
||||
{{ t(plan.creditsKey, locale) }}
|
||||
</p>
|
||||
<div v-else class="px-6" />
|
||||
|
||||
<!-- Estimate -->
|
||||
<p
|
||||
v-if="plan.estimateKey"
|
||||
class="px-6 text-xs text-primary-comfy-canvas/80"
|
||||
>
|
||||
{{ 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="mb-2 text-sm font-semibold text-primary-comfy-canvas"
|
||||
>
|
||||
{{ t(plan.featureIntroKey, locale) }}
|
||||
</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-sm text-primary-comfy-canvas">
|
||||
{{ 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: stacked plans -->
|
||||
<div class="flex flex-col gap-8 lg:hidden">
|
||||
<div v-for="plan in plans" :key="plan.id" class="flex flex-col">
|
||||
<!-- 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 flex items-center px-2 text-[10px] font-bold tracking-wider text-primary-comfy-ink"
|
||||
>
|
||||
<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="mt-3 text-2xl font-light text-primary-comfy-canvas"
|
||||
>
|
||||
{{ t('pricing.enterprise.heading', locale) }}
|
||||
</h2>
|
||||
|
||||
<!-- Summary -->
|
||||
<p class="mt-2 text-sm text-primary-comfy-canvas">
|
||||
{{ 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="font-formula text-5xl font-light text-primary-comfy-canvas"
|
||||
>
|
||||
{{ t(plan.priceKey, locale) }}
|
||||
</span>
|
||||
<span class="text-sm text-primary-comfy-canvas/55">
|
||||
{{ t('pricing.plan.period', locale) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="plan.creditsKey"
|
||||
class="mt-4 text-xs font-medium text-primary-comfy-canvas"
|
||||
>
|
||||
{{ t(plan.creditsKey, locale) }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="plan.estimateKey"
|
||||
class="mt-2 text-xs text-primary-comfy-canvas"
|
||||
>
|
||||
{{ 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="rounded-4.5xl flex w-full flex-col items-start justify-between gap-8 bg-primary-comfy-ink p-8"
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
class="text-primary-comfy-yellow text-xs font-bold tracking-wider"
|
||||
>
|
||||
{{ t(enterprisePlan.labelKey, locale) }}
|
||||
</span>
|
||||
<h2
|
||||
class="mt-3 text-2xl font-light text-primary-comfy-canvas lg:text-3xl"
|
||||
>
|
||||
{{ t('pricing.enterprise.heading', locale) }}
|
||||
</h2>
|
||||
<p class="mt-3 text-sm text-primary-comfy-canvas">
|
||||
{{ t(enterprisePlan.summaryKey, locale) }}
|
||||
</p>
|
||||
</div>
|
||||
<BrandButton :href="enterprisePlan.ctaHref" variant="outline" size="lg">
|
||||
{{ t(enterprisePlan.ctaKey, locale) }}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footnote -->
|
||||
<p class="mt-12 text-xs text-primary-comfy-canvas/70">
|
||||
{{ t('pricing.footnote', locale) }}
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,15 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('rounded-4.5xl bg-primary-comfy-ink p-8', className)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,43 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { Component as ComponentIcon } from '@lucide/vue'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const {
|
||||
locale = 'en',
|
||||
estimateKey,
|
||||
estimateCount
|
||||
} = defineProps<{
|
||||
credits: string
|
||||
label: string
|
||||
estimateKey?: TranslationKey
|
||||
estimateCount?: string
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const estimate = computed(() => {
|
||||
if (!estimateKey) return undefined
|
||||
const text = t(estimateKey, locale)
|
||||
return estimateCount ? text.replace('{count}', estimateCount) : text
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<ComponentIcon class="text-primary-comfy-orange size-4 shrink-0" />
|
||||
<span class="text-primary-warm-white ppformula-text-center text-sm">
|
||||
<span class="font-extrabold">
|
||||
{{ credits }}
|
||||
</span>
|
||||
{{ label }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="estimate" class="text-primary-warm-gray mt-1.5 px-6 text-xs">
|
||||
{{ estimate }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,34 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { getRoutes } from '../../config/routes'
|
||||
import { t } from '../../i18n/translations'
|
||||
import Button from '../ui/button/Button.vue'
|
||||
import PricingCard from './PricingCard.vue'
|
||||
import PricingPlanLabel from './PricingPlanLabel.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const ctaHref = computed(() => getRoutes(locale).contact)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PricingCard class="col-span-full">
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3 lg:gap-20">
|
||||
<div
|
||||
class="flex flex-col gap-6 lg:col-span-2 lg:flex-row lg:items-center"
|
||||
>
|
||||
<PricingPlanLabel :label="t('pricing.enterprise.label', locale)" />
|
||||
<p class="text-primary-warm-white text-sm">
|
||||
{{ t('pricing.enterprise.description', locale) }}
|
||||
</p>
|
||||
</div>
|
||||
<Button :href="ctaHref" variant="outline">
|
||||
{{ t('pricing.enterprise.cta', locale) }}
|
||||
</Button>
|
||||
</div>
|
||||
</PricingCard>
|
||||
</template>
|
||||
@@ -1,81 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
|
||||
import { Check, Clock, X } from '@lucide/vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
type PlanFeatureType = 'checked' | 'coming'
|
||||
|
||||
interface PlanFeature {
|
||||
text: TranslationKey
|
||||
type?: PlanFeatureType
|
||||
included?: boolean
|
||||
}
|
||||
|
||||
export interface PlanFeatureGroup {
|
||||
titleKey?: TranslationKey
|
||||
const {
|
||||
features,
|
||||
featureIntroKey,
|
||||
nextUpKey,
|
||||
andMoreKey,
|
||||
nextUpClass = 'text-primary-comfy-canvas/80 mt-4 text-sm',
|
||||
andMoreClass = 'text-primary-comfy-canvas mt-4 text-sm',
|
||||
listGap = 'space-y-2',
|
||||
introMargin = 'mb-3',
|
||||
locale = 'en'
|
||||
} = defineProps<{
|
||||
features: PlanFeature[]
|
||||
}
|
||||
|
||||
const { locale = 'en' } = defineProps<{
|
||||
features: PlanFeatureGroup[]
|
||||
featureIntroKey?: TranslationKey
|
||||
nextUpKey?: TranslationKey
|
||||
andMoreKey?: TranslationKey
|
||||
nextUpClass?: string
|
||||
andMoreClass?: string
|
||||
listGap?: string
|
||||
introMargin?: string
|
||||
locale?: Locale
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-5">
|
||||
<div
|
||||
v-for="(group, groupIndex) in features"
|
||||
:key="group.titleKey ?? groupIndex"
|
||||
class="flex flex-col gap-2"
|
||||
<p
|
||||
v-if="featureIntroKey"
|
||||
:class="cn('text-primary-comfy-canvas text-sm font-semibold', introMargin)"
|
||||
>
|
||||
{{ t(featureIntroKey, locale) }}
|
||||
</p>
|
||||
<ul :class="listGap">
|
||||
<li
|
||||
v-for="feature in features"
|
||||
:key="feature.text"
|
||||
class="flex items-start gap-2"
|
||||
>
|
||||
<p v-if="group.titleKey" class="text-sm text-primary-comfy-canvas">
|
||||
{{ t(group.titleKey, locale) }}
|
||||
</p>
|
||||
<ul class="space-y-2">
|
||||
<li
|
||||
v-for="feature in group.features"
|
||||
:key="feature.text"
|
||||
class="flex items-start gap-2"
|
||||
>
|
||||
<Clock
|
||||
v-if="feature.type === 'coming'"
|
||||
class="text-primary-warm-gray mt-0.5 size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Check
|
||||
v-else-if="feature.included !== false"
|
||||
class="text-primary-comfy-yellow mt-0.5 size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<X
|
||||
v-else
|
||||
class="mt-0.5 size-4 shrink-0 text-primary-comfy-canvas/40"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="sr-only">
|
||||
{{
|
||||
feature.type === 'coming'
|
||||
? t('pricing.plan.feature.status.coming', locale)
|
||||
: feature.included === false
|
||||
? t('pricing.plan.feature.status.notIncluded', locale)
|
||||
: t('pricing.plan.feature.status.included', locale)
|
||||
}}:
|
||||
</span>
|
||||
<span
|
||||
class="ppformula-text-center text-sm"
|
||||
:class="
|
||||
feature.type === 'coming' || feature.included === false
|
||||
? 'text-primary-warm-gray'
|
||||
: 'text-primary-warm-white'
|
||||
"
|
||||
>
|
||||
{{ t(feature.text, locale) }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<p v-if="nextUpKey" :class="nextUpClass">
|
||||
{{ t(nextUpKey, locale) }}
|
||||
</p>
|
||||
<p v-if="andMoreKey" :class="andMoreClass">
|
||||
{{ t(andMoreKey, locale) }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
label: string
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'text-primary-comfy-yellow text-base font-bold tracking-wider uppercase',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
</template>
|
||||
@@ -1,67 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const {
|
||||
locale = 'en',
|
||||
billingPeriod,
|
||||
yearlyTotal
|
||||
} = defineProps<{
|
||||
price: string
|
||||
period: string
|
||||
originalPrice?: string
|
||||
discount?: string
|
||||
billingPeriod?: 'monthly' | 'yearly'
|
||||
yearlyTotal?: string
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const billingNote = computed(() => {
|
||||
if (billingPeriod === 'yearly' && yearlyTotal) {
|
||||
return t('pricing.period.billedYearly', locale).replace(
|
||||
'{total}',
|
||||
yearlyTotal
|
||||
)
|
||||
}
|
||||
if (billingPeriod === 'monthly') {
|
||||
return t('pricing.period.billedMonthly', locale)
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="mt-6 flex items-baseline gap-2">
|
||||
<span class="font-formula text-5xl font-light text-primary-comfy-canvas">
|
||||
{{ price }}
|
||||
</span>
|
||||
<div class="flex gap-2 max-sm:flex-col">
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span
|
||||
v-if="originalPrice"
|
||||
class="font-formula text-primary-warm-gray text-sm font-light line-through"
|
||||
>
|
||||
{{ originalPrice }}
|
||||
</span>
|
||||
<span class="text-primary-warm-white text-sm">
|
||||
{{ period }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span
|
||||
class="text-primary-comfy-yellow text-sm max-sm:text-xs sm:ml-2"
|
||||
:class="{ 'opacity-0': !discount }"
|
||||
:aria-hidden="!discount || undefined"
|
||||
>
|
||||
{{ discount || ' ' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="billingNote" class="text-primary-warm-gray mt-2 text-sm">
|
||||
{{ billingNote }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,143 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { pricingPlans } from '../../data/pricingPlans'
|
||||
import type { BillingCycle, PricingPlan } from '../../data/pricingPlans'
|
||||
import { t } from '../../i18n/translations'
|
||||
import Badge from '../ui/badge/Badge.vue'
|
||||
import Button from '../ui/button/Button.vue'
|
||||
import ToggleGroup from '../ui/toggle-group/ToggleGroup.vue'
|
||||
import ToggleGroupItem from '../ui/toggle-group/ToggleGroupItem.vue'
|
||||
import PricingCard from './PricingCard.vue'
|
||||
import PricingCredits from './PricingCredits.vue'
|
||||
import PricingEnterpriseBand from './PricingEnterpriseBand.vue'
|
||||
import PricingPlanFeatureList from './PricingPlanFeatureList.vue'
|
||||
import PricingPlanLabel from './PricingPlanLabel.vue'
|
||||
import PricingPrice from './PricingPrice.vue'
|
||||
import PricingTeamCard from './PricingTeamCard.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const billingPeriod = ref<BillingCycle>('yearly')
|
||||
|
||||
function displayPriceKey(plan: PricingPlan): TranslationKey | undefined {
|
||||
if (billingPeriod.value === 'yearly' && plan.yearlyPriceKey) {
|
||||
return plan.yearlyPriceKey
|
||||
}
|
||||
return plan.priceKey
|
||||
}
|
||||
</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="font-formula text-4xl font-light text-primary-comfy-canvas lg:text-5xl"
|
||||
>
|
||||
{{ t('pricing.title', locale) }}
|
||||
</h1>
|
||||
<p
|
||||
class="mx-auto mt-3 max-w-xl text-base text-pretty text-primary-comfy-canvas"
|
||||
>
|
||||
{{ t('pricing.subtitle', locale) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center pb-16">
|
||||
<ToggleGroup v-model="billingPeriod" type="single">
|
||||
<ToggleGroupItem value="monthly" class="min-w-48">
|
||||
{{ t('pricing.period.monthly', locale) }}
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="yearly" class="min-w-48">
|
||||
{{ t('pricing.period.yearly', locale) }}
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
|
||||
<!-- Desktop: dynamic grid (3 or 4 columns) / Mobile: stacked cards -->
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'rounded-5xl bg-transparency-white-t4 grid gap-2 p-2 max-lg:mx-auto max-lg:max-w-lg',
|
||||
pricingPlans.length === 4 ? 'lg:grid-cols-4' : 'lg:grid-cols-3'
|
||||
)
|
||||
"
|
||||
>
|
||||
<PricingCard
|
||||
v-for="plan in pricingPlans"
|
||||
:key="plan.id"
|
||||
class="row-span-7 grid grid-rows-subgrid"
|
||||
>
|
||||
<!-- Label + badge -->
|
||||
<div class="flex items-center gap-4">
|
||||
<PricingPlanLabel
|
||||
:label="t(plan.labelKey, locale)"
|
||||
class="ppformula-text-center text-base uppercase"
|
||||
/>
|
||||
<Badge v-if="plan.isPopular" variant="callout">
|
||||
{{ t('pricing.badge.popular', locale) }}</Badge
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Price -->
|
||||
<PricingPrice
|
||||
v-if="displayPriceKey(plan)"
|
||||
:price="t(displayPriceKey(plan)!, locale)"
|
||||
:period="t('pricing.plan.period', locale)"
|
||||
:original-price="
|
||||
billingPeriod === 'yearly' && plan.yearlyPriceKey && plan.priceKey
|
||||
? t(plan.priceKey, locale)
|
||||
: undefined
|
||||
"
|
||||
:billing-period="billingPeriod"
|
||||
:yearly-total="
|
||||
plan.yearlyTotalKey ? t(plan.yearlyTotalKey, locale) : undefined
|
||||
"
|
||||
:locale
|
||||
/>
|
||||
|
||||
<!-- Features -->
|
||||
<div v-if="plan.features.length" class="mt-8">
|
||||
<PricingPlanFeatureList
|
||||
:features="[{ features: plan.features }]"
|
||||
:locale
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Credits -->
|
||||
<PricingCredits
|
||||
v-if="plan.creditsKey"
|
||||
:credits="t(plan.creditsKey, locale)"
|
||||
:label="t('pricing.creditsLabel', locale)"
|
||||
:estimate-key="plan.estimateKey"
|
||||
:locale
|
||||
/>
|
||||
|
||||
<!-- CTA -->
|
||||
<div class="mt-8 flex self-end">
|
||||
<Button
|
||||
:href="plan.ctaHref(billingPeriod)"
|
||||
as="a"
|
||||
variant="outline"
|
||||
class="w-full text-center"
|
||||
>
|
||||
{{ t(plan.ctaKey, locale) }}
|
||||
</Button>
|
||||
</div>
|
||||
</PricingCard>
|
||||
|
||||
<PricingTeamCard :billing-period="billingPeriod" :locale />
|
||||
|
||||
<PricingEnterpriseBand :locale />
|
||||
</div>
|
||||
|
||||
<!-- Footnote -->
|
||||
<p class="mt-12 text-xs text-primary-comfy-canvas/70">
|
||||
{{ t('pricing.footnote', locale) }}
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,171 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import type { PlanFeatureGroup } from './PricingPlanFeatureList.vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { Component as ComponentIcon } from '@lucide/vue'
|
||||
|
||||
import { subscribeUrl } from '../../data/pricingPlans'
|
||||
import {
|
||||
formatTeamCreditsLong,
|
||||
formatTeamCreditsShort,
|
||||
teamCreditTiers
|
||||
} from '../../data/teamCreditTiers'
|
||||
import { t } from '../../i18n/translations'
|
||||
import Button from '../ui/button/Button.vue'
|
||||
import Slider from '../ui/slider/Slider.vue'
|
||||
import PricingCard from './PricingCard.vue'
|
||||
import PricingCredits from './PricingCredits.vue'
|
||||
import PricingPlanFeatureList from './PricingPlanFeatureList.vue'
|
||||
import PricingPlanLabel from './PricingPlanLabel.vue'
|
||||
import PricingPrice from './PricingPrice.vue'
|
||||
|
||||
const { locale = 'en', billingPeriod } = defineProps<{
|
||||
billingPeriod: 'monthly' | 'yearly'
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const teamCreditTierIndex = ref<number[]>([2])
|
||||
|
||||
const selectedTeamTier = computed(
|
||||
() => teamCreditTiers[teamCreditTierIndex.value[0] ?? 0]
|
||||
)
|
||||
const selectedTeamPrice = computed(() =>
|
||||
billingPeriod === 'yearly'
|
||||
? selectedTeamTier.value.yearlyPrice
|
||||
: selectedTeamTier.value.monthlyPrice
|
||||
)
|
||||
|
||||
function fmtPrice(n: number): string {
|
||||
return `$${n.toLocaleString('en-US')}`
|
||||
}
|
||||
|
||||
const teamSaving = computed<string | undefined>(() => {
|
||||
const base = selectedTeamTier.value.basePrice
|
||||
const discounted = selectedTeamPrice.value
|
||||
if (base === discounted) return undefined
|
||||
const pct = ((base - discounted) / base) * 100
|
||||
return t('pricing.savePercent', locale)
|
||||
.replace('{pct}', String(pct))
|
||||
.replace('{amount}', fmtPrice(base - discounted))
|
||||
})
|
||||
|
||||
const featureGroups: PlanFeatureGroup[] = [
|
||||
{
|
||||
titleKey: 'pricing.plan.team.everythingInProPlus',
|
||||
features: [
|
||||
{ text: 'pricing.feature.inviteMembers' },
|
||||
{ text: 'pricing.feature.concurrentWorkflows' },
|
||||
{ text: 'pricing.feature.sharedCreditPool' },
|
||||
{ text: 'pricing.feature.roleBasedPermissions' }
|
||||
]
|
||||
},
|
||||
{
|
||||
titleKey: 'pricing.plan.team.comingSoon',
|
||||
features: [
|
||||
{ text: 'pricing.plan.team.sharedWorkflowsAndAssets', type: 'coming' },
|
||||
{ text: 'pricing.plan.team.projects', type: 'coming' }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const ctaHref = computed(() =>
|
||||
subscribeUrl(
|
||||
'team',
|
||||
billingPeriod,
|
||||
`team_${selectedTeamTier.value.basePrice}`
|
||||
)
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PricingCard class="col-span-full">
|
||||
<div class="grid grid-cols-1 gap-10 lg:grid-cols-3 lg:gap-20">
|
||||
<div class="lg:col-span-2 lg:max-w-xl">
|
||||
<div
|
||||
class="ppformula-text-center flex flex-col items-start gap-2 lg:flex-row lg:items-center lg:gap-4"
|
||||
>
|
||||
<PricingPlanLabel :label="t('pricing.plan.team.label', locale)" />
|
||||
<p class="text-primary-warm-gray text-sm">
|
||||
{{ t('pricing.team.description', locale) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<PricingPrice
|
||||
:price="fmtPrice(selectedTeamPrice)"
|
||||
:period="t('pricing.plan.period', locale)"
|
||||
:original-price="
|
||||
selectedTeamTier.basePrice !== selectedTeamPrice
|
||||
? fmtPrice(selectedTeamTier.basePrice)
|
||||
: undefined
|
||||
"
|
||||
:discount="teamSaving"
|
||||
:billing-period="billingPeriod"
|
||||
:yearly-total="fmtPrice(selectedTeamPrice * 12)"
|
||||
:locale
|
||||
/>
|
||||
|
||||
<div class="mt-6">
|
||||
<Slider
|
||||
v-model="teamCreditTierIndex"
|
||||
class="w-full"
|
||||
:min="0"
|
||||
:max="teamCreditTiers.length - 1"
|
||||
:step="1"
|
||||
:ticks="teamCreditTiers.length"
|
||||
/>
|
||||
<div class="relative mt-3 h-6">
|
||||
<div
|
||||
v-for="(tier, i) in teamCreditTiers"
|
||||
:key="tier.credits"
|
||||
:class="[
|
||||
'absolute top-0 inline-flex items-center gap-1.5',
|
||||
'max-lg:-translate-x-[calc(50%-8px)]'
|
||||
]"
|
||||
:style="{
|
||||
left: `calc(${i / (teamCreditTiers.length - 1)} * (100% - 16px))`
|
||||
}"
|
||||
>
|
||||
<ComponentIcon
|
||||
class="hidden size-4 shrink-0 lg:block"
|
||||
:class="
|
||||
i === teamCreditTierIndex[0]
|
||||
? 'text-primary-comfy-orange'
|
||||
: 'text-primary-warm-gray'
|
||||
"
|
||||
/>
|
||||
<span
|
||||
class="text-sm max-sm:text-[10px]"
|
||||
:class="
|
||||
i === teamCreditTierIndex[0]
|
||||
? 'text-primary-warm-white'
|
||||
: 'text-primary-warm-gray'
|
||||
"
|
||||
>
|
||||
{{ formatTeamCreditsShort(tier.credits) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PricingCredits
|
||||
:credits="formatTeamCreditsLong(selectedTeamTier.credits)"
|
||||
:label="t('pricing.creditsLabel', locale)"
|
||||
estimate-key="pricing.team.videosEstimate"
|
||||
:estimate-count="selectedTeamTier.videos.toLocaleString('en-US')"
|
||||
:locale
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<PricingPlanFeatureList :features="featureGroups" :locale />
|
||||
|
||||
<div class="mt-8">
|
||||
<Button :href="ctaHref" class="w-full" variant="outline">
|
||||
{{ t('pricing.plan.team.cta', locale) }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PricingCard>
|
||||
</template>
|
||||
7
apps/website/src/components/pricing/PricingTierCard.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
class="bg-primary-comfy-ink rounded-4.5xl row-span-7 grid grid-rows-subgrid pb-3"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,7 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
import { Clock } from '@lucide/vue'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import CheckIcon from '../icons/CheckIcon.vue'
|
||||
|
||||
@@ -56,7 +54,11 @@ const features: IncludedFeature[] = [
|
||||
},
|
||||
{
|
||||
titleKey: 'pricing.included.feature11.title',
|
||||
descriptionKey: 'pricing.included.feature11.description',
|
||||
descriptionKey: 'pricing.included.feature11.description'
|
||||
},
|
||||
{
|
||||
titleKey: 'pricing.included.feature12.title',
|
||||
descriptionKey: 'pricing.included.feature12.description',
|
||||
isComingSoon: true
|
||||
}
|
||||
]
|
||||
@@ -90,9 +92,11 @@ const features: IncludedFeature[] = [
|
||||
>
|
||||
<!-- Title -->
|
||||
<div class="flex items-start gap-3">
|
||||
<Clock
|
||||
<img
|
||||
v-if="feature.isComingSoon"
|
||||
class="mt-0.5 size-4 shrink-0 text-primary-comfy-canvas/55"
|
||||
src="/icons/clock.svg"
|
||||
alt=""
|
||||
class="mt-0.5 size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<CheckIcon
|
||||
@@ -101,12 +105,6 @@ const features: IncludedFeature[] = [
|
||||
/>
|
||||
<p class="text-sm font-medium text-primary-comfy-canvas">
|
||||
{{ t(feature.titleKey, locale) }}
|
||||
<span
|
||||
v-if="feature.isComingSoon"
|
||||
class="block text-primary-comfy-canvas/55"
|
||||
>
|
||||
{{ t('pricing.included.comingSoon', locale) }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/* eslint-disable vue/no-unused-properties -- props forwarded via useForwardPropsEmits */
|
||||
import type { AccordionRootEmits, AccordionRootProps } from 'reka-ui'
|
||||
import { AccordionRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
|
||||
const props = defineProps<AccordionRootProps>()
|
||||
const emits = defineEmits<AccordionRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AccordionRoot v-slot="slotProps" data-slot="accordion" v-bind="forwarded">
|
||||
<slot v-bind="slotProps" />
|
||||
</AccordionRoot>
|
||||
</template>
|
||||
@@ -1,26 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/* eslint-disable vue/no-unused-properties -- props forwarded via v-bind */
|
||||
import type { AccordionContentProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { AccordionContent } from 'reka-ui'
|
||||
|
||||
const props = defineProps<
|
||||
AccordionContentProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AccordionContent
|
||||
data-slot="accordion-content"
|
||||
v-bind="delegatedProps"
|
||||
class="overflow-hidden data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||
>
|
||||
<div :class="cn('pt-0 pb-6', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</template>
|
||||
@@ -1,29 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/* eslint-disable vue/no-unused-properties -- props forwarded via useForwardProps */
|
||||
import type { AccordionItemProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { AccordionItem, useForwardProps } from 'reka-ui'
|
||||
|
||||
const props = defineProps<
|
||||
AccordionItemProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AccordionItem
|
||||
v-slot="slotProps"
|
||||
data-slot="accordion-item"
|
||||
v-bind="forwardedProps"
|
||||
:class="
|
||||
cn('border-b border-primary-comfy-canvas/20 last:border-b-0', props.class)
|
||||
"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</AccordionItem>
|
||||
</template>
|
||||
@@ -1,43 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/* eslint-disable vue/no-unused-properties -- props forwarded via v-bind */
|
||||
import type { AccordionTriggerProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { Minus } from '@lucide/vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { AccordionHeader, AccordionTrigger } from 'reka-ui'
|
||||
|
||||
const props = defineProps<
|
||||
AccordionTriggerProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AccordionHeader class="flex">
|
||||
<AccordionTrigger
|
||||
data-slot="accordion-trigger"
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn(
|
||||
'data-[state=open]:text-primary-comfy-yellow focus-visible:border-primary-comfy-yellow/50 focus-visible:ring-primary-comfy-yellow/50 flex flex-1 cursor-pointer items-center justify-between gap-4 py-6 text-left text-lg font-light text-primary-comfy-canvas transition-all outline-none focus-visible:ring-3 disabled:pointer-events-none disabled:opacity-50 md:text-xl',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
<slot name="icon">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="in-data-[state=open]:text-primary-comfy-yellow relative ml-4 size-6 shrink-0 text-primary-comfy-canvas"
|
||||
>
|
||||
<Minus class="pointer-events-none absolute inset-0 size-6" />
|
||||
<Minus
|
||||
class="pointer-events-none absolute inset-0 size-6 rotate-90 transition-transform duration-300 ease-out in-data-[state=open]:rotate-0"
|
||||
/>
|
||||
</span>
|
||||
</slot>
|
||||
</AccordionTrigger>
|
||||
</AccordionHeader>
|
||||
</template>
|
||||
@@ -8,9 +8,7 @@ export const badgeVariants = cva({
|
||||
default: 'bg-transparency-ink-t80',
|
||||
subtle: 'bg-transparency-white-t4 text-primary-comfy-canvas',
|
||||
accent:
|
||||
'before:bg-primary-comfy-yellow relative isolate overflow-visible rounded-none bg-transparent px-2 py-0.5 text-[9px] font-bold tracking-wide text-primary-comfy-ink uppercase before:absolute before:inset-0 before:-z-10 before:-skew-x-12 before:rounded-sm',
|
||||
callout:
|
||||
'before:bg-primary-comfy-plum text-primary-warm-white relative isolate overflow-visible rounded-none bg-transparent px-2 py-0.5 text-[9px] font-bold tracking-tight uppercase before:absolute before:inset-0 before:-z-10 before:-skew-x-12 before:rounded-sm'
|
||||
'before:bg-primary-comfy-yellow relative isolate overflow-visible rounded-none bg-transparent px-2 py-0.5 text-[9px] font-bold tracking-wide text-primary-comfy-ink uppercase before:absolute before:inset-0 before:-z-10 before:-skew-x-12 before:rounded-sm'
|
||||
},
|
||||
size: {
|
||||
md: 'px-4 py-1 text-xs',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { PrimitiveProps } from 'reka-ui'
|
||||
import type { AnchorHTMLAttributes, Component, HTMLAttributes } from 'vue'
|
||||
import type { Component, HTMLAttributes } from 'vue'
|
||||
import type { ButtonVariants } from '.'
|
||||
import { Primitive } from 'reka-ui'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
@@ -13,19 +13,17 @@ interface Props extends PrimitiveProps {
|
||||
disabled?: boolean
|
||||
prependIcon?: Component
|
||||
appendIcon?: Component
|
||||
href?: AnchorHTMLAttributes['href']
|
||||
}
|
||||
|
||||
const {
|
||||
as,
|
||||
as = 'button',
|
||||
asChild,
|
||||
variant,
|
||||
size,
|
||||
class: className,
|
||||
disabled,
|
||||
prependIcon,
|
||||
appendIcon,
|
||||
href
|
||||
appendIcon
|
||||
} = defineProps<Props>()
|
||||
</script>
|
||||
|
||||
@@ -34,10 +32,9 @@ const {
|
||||
data-slot="button"
|
||||
:data-variant="variant"
|
||||
:data-size="size"
|
||||
:as="as ?? (href != null && !disabled ? 'a' : 'button')"
|
||||
:as
|
||||
:as-child
|
||||
:disabled
|
||||
:href="disabled ? undefined : href"
|
||||
:class="cn(buttonVariants({ variant, size }), className)"
|
||||
>
|
||||
<slot name="prepend">
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { SliderRootEmits, SliderRootProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
SliderRange,
|
||||
SliderRoot,
|
||||
SliderThumb,
|
||||
SliderTrack,
|
||||
useForwardPropsEmits
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const {
|
||||
class: className,
|
||||
ticks,
|
||||
min = 0,
|
||||
max = 100,
|
||||
...restProps
|
||||
} = defineProps<
|
||||
SliderRootProps & { class?: HTMLAttributes['class']; ticks?: number }
|
||||
>()
|
||||
const emits = defineEmits<SliderRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(
|
||||
computed(() => ({ ...restProps, min, max })),
|
||||
emits
|
||||
)
|
||||
|
||||
function tickValue(i: number): number {
|
||||
return min + ((i - 1) / (ticks! - 1)) * (max - min)
|
||||
}
|
||||
|
||||
function isTickFilled(
|
||||
i: number,
|
||||
modelValue: number[] | null | undefined
|
||||
): boolean {
|
||||
if (!modelValue?.length) return false
|
||||
const value = tickValue(i)
|
||||
if (modelValue.length === 1) return value <= modelValue[0]
|
||||
const sorted = [...modelValue].sort((a, b) => a - b)
|
||||
return value >= sorted[0] && value <= sorted[sorted.length - 1]
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SliderRoot
|
||||
v-slot="{ modelValue }"
|
||||
data-slot="slider"
|
||||
:class="
|
||||
cn(
|
||||
'relative flex w-full touch-none items-center select-none data-disabled:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col',
|
||||
className
|
||||
)
|
||||
"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<template v-if="ticks && ticks > 1">
|
||||
<span
|
||||
v-for="i in ticks"
|
||||
:key="i"
|
||||
data-slot="slider-tick"
|
||||
class="pointer-events-none absolute top-1/2 size-2 -translate-1/2 rounded-full"
|
||||
:class="
|
||||
isTickFilled(i, modelValue)
|
||||
? 'bg-primary-warm-white'
|
||||
: 'bg-primary-warm-gray'
|
||||
"
|
||||
:style="{
|
||||
left: `calc(8px + ${(i - 1) / (ticks - 1)} * (100% - 16px))`
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<SliderTrack
|
||||
data-slot="slider-track"
|
||||
class="bg-primary-warm-gray relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
|
||||
>
|
||||
<SliderRange
|
||||
data-slot="slider-range"
|
||||
class="bg-primary-warm-white absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
|
||||
/>
|
||||
</SliderTrack>
|
||||
|
||||
<SliderThumb
|
||||
v-for="(_, key) in modelValue"
|
||||
:key="key"
|
||||
data-slot="slider-thumb"
|
||||
class="bg-primary-warm-white border-primary-comfy-yellow ring-primary-comfy-yellow/50 block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
|
||||
/>
|
||||
</SliderRoot>
|
||||
</template>
|
||||
@@ -1,63 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { VariantProps } from 'class-variance-authority'
|
||||
import type { ToggleGroupRootEmits, ToggleGroupRootProps } from 'reka-ui'
|
||||
import { ToggleGroupRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { computed, provide } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import type { toggleVariants } from '@/components/ui/toggle'
|
||||
|
||||
type ToggleGroupVariants = VariantProps<typeof toggleVariants>
|
||||
|
||||
const {
|
||||
class: className,
|
||||
variant,
|
||||
size,
|
||||
spacing = 0,
|
||||
...restProps
|
||||
} = defineProps<
|
||||
ToggleGroupRootProps & {
|
||||
class?: HTMLAttributes['class']
|
||||
variant?: ToggleGroupVariants['variant']
|
||||
size?: ToggleGroupVariants['size']
|
||||
spacing?: number
|
||||
}
|
||||
>()
|
||||
|
||||
const emits = defineEmits<ToggleGroupRootEmits>()
|
||||
|
||||
provide('toggleGroup', {
|
||||
variant,
|
||||
size,
|
||||
spacing
|
||||
})
|
||||
|
||||
const forwarded = useForwardPropsEmits(
|
||||
computed(() => ({ ...restProps })),
|
||||
emits
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ToggleGroupRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="toggle-group"
|
||||
:data-size="size"
|
||||
:data-variant="variant"
|
||||
:data-spacing="spacing"
|
||||
:style="{
|
||||
'--gap': spacing
|
||||
}"
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'group/toggle-group ring-primary-warm-white/20 flex w-fit items-center gap-[--spacing(var(--gap))] rounded-2xl p-1.5 ring-2 data-[spacing=default]:data-[variant=outline]:shadow-xs',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</ToggleGroupRoot>
|
||||
</template>
|
||||
@@ -1,56 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { VariantProps } from 'class-variance-authority'
|
||||
import type { ToggleGroupItemProps } from 'reka-ui'
|
||||
import { ToggleGroupItem, useForwardProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { computed, inject } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { toggleVariants } from '@/components/ui/toggle'
|
||||
|
||||
type ToggleGroupVariants = VariantProps<typeof toggleVariants> & {
|
||||
spacing?: number
|
||||
}
|
||||
|
||||
const {
|
||||
class: className,
|
||||
variant,
|
||||
size,
|
||||
...restProps
|
||||
} = defineProps<
|
||||
ToggleGroupItemProps & {
|
||||
class?: HTMLAttributes['class']
|
||||
variant?: ToggleGroupVariants['variant']
|
||||
size?: ToggleGroupVariants['size']
|
||||
}
|
||||
>()
|
||||
|
||||
const context = inject<ToggleGroupVariants>('toggleGroup')
|
||||
|
||||
const forwardedProps = useForwardProps(computed(() => ({ ...restProps })))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ToggleGroupItem
|
||||
v-slot="slotProps"
|
||||
data-slot="toggle-group-item"
|
||||
:data-variant="context?.variant || variant"
|
||||
:data-size="context?.size || size"
|
||||
:data-spacing="context?.spacing"
|
||||
v-bind="forwardedProps"
|
||||
:class="
|
||||
cn(
|
||||
toggleVariants({
|
||||
variant: context?.variant || variant,
|
||||
size: context?.size || size
|
||||
}),
|
||||
'w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10',
|
||||
'data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-xl data-[spacing=0]:last:rounded-r-xl data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</ToggleGroupItem>
|
||||
</template>
|
||||
@@ -1,20 +0,0 @@
|
||||
import { cva } from 'class-variance-authority'
|
||||
|
||||
export const toggleVariants = cva(
|
||||
"data-[state=on]:bg-primary-comfy-yellow focus-visible:border-primary-comfy-orange focus-visible:ring-primary-comfy-yellow aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive hover:text-primary-warm-white inline-flex items-center justify-center gap-2 rounded-xl text-xs font-bold whitespace-nowrap uppercase transition-[color,box-shadow] duration-300 outline-none focus-visible:ring-3 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:text-primary-comfy-ink [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'bg-transparency-white-t4 text-primary-warm-gray hover:cursor-pointer'
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 min-w-20 px-4'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default'
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -1,167 +0,0 @@
|
||||
import type { LocalizedText } from '../i18n/translations'
|
||||
|
||||
interface PricingFaq {
|
||||
id: string
|
||||
question: LocalizedText
|
||||
answer: LocalizedText
|
||||
}
|
||||
|
||||
export const pricingFaqs: readonly PricingFaq[] = [
|
||||
{
|
||||
id: 'how-does-pricing-work',
|
||||
question: {
|
||||
en: 'How does Comfy Cloud pricing actually work?',
|
||||
'zh-CN': 'Comfy Cloud 的定价究竟是如何运作的?'
|
||||
},
|
||||
answer: {
|
||||
en: "Every plan includes a monthly pool of <strong>credits</strong>. Credits are spent on two things: <strong>active GPU time</strong> while a workflow is running, and <strong>Partner Nodes</strong> (proprietary models like Nano Banana Pro). You're never charged for idle time. Building or editing a workflow costs nothing. You only spend while a job is actually running.",
|
||||
'zh-CN':
|
||||
'每个计划都包含每月的<strong>积分</strong>池。积分用于两类消耗:工作流运行时的<strong>活跃 GPU 时间</strong>,以及<strong>合作伙伴节点</strong>(如 Nano Banana Pro 等专有模型)。空闲时间不会计费。构建或编辑工作流完全免费。只有任务真正运行时才会扣费。'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'what-is-a-credit-worth',
|
||||
question: {
|
||||
en: "What's a credit worth? How far does it go?",
|
||||
'zh-CN': '一个积分价值多少?能用多久?'
|
||||
},
|
||||
answer: {
|
||||
en: 'Credits map to GPU runtime, so mileage depends on the workflow. As a reference point, a five-second video* uses roughly <strong>11 credits</strong>, so Standard covers a few hundred per month, Creator about double that, and Pro enough for over a thousand.\n\n*Based on 5s videos using the Wan 2.2 Image-to-Video template at default settings (81 frames, 18fps, 640×640, 4-step sampler). Heavier models, higher resolutions, and the inclusion of Partner nodes use more.',
|
||||
'zh-CN':
|
||||
'积分对应 GPU 运行时长,因此具体能用多少取决于工作流本身。作为参考:一段 5 秒视频*大约消耗 <strong>11 积分</strong>,因此 Standard 每月可支持数百段,Creator 约为其两倍,Pro 则足以生成一千多段。\n\n*基于使用 Wan 2.2 图生视频模板在默认设置(81 帧、18fps、640×640、4-step sampler)下生成 5 秒视频的估算。更复杂的模型、更高分辨率以及加入合作伙伴节点会消耗更多积分。'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'run-out-of-credits',
|
||||
question: {
|
||||
en: 'What happens when I run out of credits?',
|
||||
'zh-CN': '积分用完了会怎样?'
|
||||
},
|
||||
answer: {
|
||||
en: 'You can buy <strong>top-up credits</strong> at any time without changing plans. Monthly credits are spent first; top-ups are only drawn down once your monthly allowance is used up. Top-up credits stay valid for <strong>1 year</strong> from purchase.',
|
||||
'zh-CN':
|
||||
'您可以随时购买<strong>充值积分</strong>,无需更换计划。月度积分会优先消耗;只有当月度额度用完后,才会开始使用充值积分。充值积分自购买之日起 <strong>1 年</strong>内有效。'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'do-credits-roll-over',
|
||||
question: {
|
||||
en: 'Do unused credits roll over?',
|
||||
'zh-CN': '未使用的积分会顺延吗?'
|
||||
},
|
||||
answer: {
|
||||
en: "Monthly plan credits reset each billing cycle and don't roll over. <strong>Top-up credits do persist.</strong> They're valid for a year and aren't affected by your monthly reset. Credits work on Comfy Cloud, and on Comfy Desktop <strong>only when calling Partner Nodes.</strong> Comfy Desktop itself is free.",
|
||||
'zh-CN':
|
||||
'月度计划积分在每个计费周期重置,不会顺延。<strong>但充值积分会保留。</strong>有效期为一年,且不受每月重置的影响。积分可在 Comfy Cloud 上使用,在 Comfy 桌面版上<strong>仅在调用合作伙伴节点时使用</strong>。Comfy 桌面版本身免费。'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'difference-between-plans',
|
||||
question: {
|
||||
en: "What's the difference between Standard, Creator, and Pro?",
|
||||
'zh-CN': 'Standard、Creator 和 Pro 有什么区别?'
|
||||
},
|
||||
answer: {
|
||||
en: '<strong>Standard -</strong> 30-min max runtime per workflow, 1 concurrent workflow via API. For individuals building workflows.\n\n<strong>Creator -</strong> Everything in Standard plus the ability to <strong>import your own models</strong> (from CivitAI or Hugging Face) and run up to 3 workflows concurrently via API.\n\n<strong>Pro -</strong> Everything in Creator plus <strong>longer runtime (up to 1 hour)</strong> per workflow and up to 5 concurrent workflows via API. For teams running Comfy in production.',
|
||||
'zh-CN':
|
||||
'<strong>Standard -</strong> 单个工作流最长运行 30 分钟,API 支持 1 个并发工作流。适合构建工作流的个人。\n\n<strong>Creator -</strong> 包含 Standard 的全部功能,并新增<strong>导入自有模型</strong>(来自 CivitAI 或 Hugging Face)的能力,API 支持最多 3 个并发工作流。\n\n<strong>Pro -</strong> 包含 Creator 的全部功能,并提供<strong>更长的运行时长(最长 1 小时)</strong>,API 支持最多 5 个并发工作流。适合在生产环境中运行 Comfy 的团队。'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'how-does-team-plan-work',
|
||||
question: {
|
||||
en: 'How does the Team Plan work?',
|
||||
'zh-CN': '团队计划是如何运作的?'
|
||||
},
|
||||
answer: {
|
||||
en: 'The Team Plan puts your whole team on <strong>one shared credit pool.</strong> Every member draws from the same balance, so you\'re not juggling separate subscriptions. Key things to know:\n\n<strong>One pool, shared.</strong> Everyone generates against the same credit balance.\n<strong>Invite by email.</strong> Add teammates, and resend or revoke access any time.\n<strong>Owners manage billing.</strong> Assign owners who handle payment and buy top-ups for the team.\n<strong>Upgrade in place.</strong> Move an existing workspace to a team and your workflows, models, and assets stay attached.\n\nChoose your monthly credit commitment that fits your team. <a href="https://cloud.comfy.org/?pricing=team" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">Get started today.</a>',
|
||||
'zh-CN':
|
||||
'团队计划让整个团队共享<strong>一个积分池</strong>。每位成员都从同一余额中扣费,无需分别管理多个订阅。要点:\n\n<strong>一池共享。</strong>所有人都从同一积分余额中生成内容。\n<strong>邮箱邀请。</strong>添加团队成员,随时重新发送或撤销访问权限。\n<strong>所有者管理账单。</strong>指定所有者负责付款,并为团队购买充值积分。\n<strong>原地升级。</strong>将现有工作区升级为团队工作区,您的工作流、模型和资产都将保留。\n\n选择适合您团队的每月积分承诺。<a href="https://cloud.comfy.org/?pricing=team" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">立即开始。</a>'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'team-spending-controls',
|
||||
question: {
|
||||
en: 'Can I control how my team spends credits?',
|
||||
'zh-CN': '我可以控制团队消耗积分的方式吗?'
|
||||
},
|
||||
answer: {
|
||||
en: "Today, owners control the shared pool and top-ups. We're actively building finer-grained controls: <strong>spending limits</strong> at the user, project, and workspace level, <strong>per-project budgets and chargebacks</strong>, <strong>auto-recharge</strong> when the pool runs low, and <strong>self-serve teams beyond 25 seats</strong>.",
|
||||
'zh-CN':
|
||||
'目前,所有者掌控共享积分池和充值。我们正在积极开发更细粒度的控制功能:用户、项目和工作区级别的<strong>消费上限</strong>、<strong>按项目预算与分摊</strong>、积分池余额不足时的<strong>自动充值</strong>,以及<strong>超过 25 个席位的自助式团队</strong>。'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'team-per-seat-pricing',
|
||||
question: {
|
||||
en: 'Is Team pricing per-seat? Can I add a freelancer just for a project?',
|
||||
'zh-CN':
|
||||
'团队计划是按席位计费吗?我可以为某个项目临时加入一位自由职业者吗?'
|
||||
},
|
||||
answer: {
|
||||
en: 'No. Team pricing is based on <strong>your monthly credit commit</strong>, not per-seat. Invite a freelancer, they draw from the shared credit pool while they\'re working, then remove them when the project wraps. <strong>No charge for adding or removing people.</strong> Member count is capped at <strong>25</strong> today; if you hit the cap, <a href="https://portal.usepylon.com/comfy-org/forms/question" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">contact support</a> for additional seats.',
|
||||
'zh-CN':
|
||||
'不是。团队定价基于<strong>您每月承诺的积分量</strong>,而非按席位计费。邀请自由职业者后,他们在工作期间从共享积分池中扣费,项目结束后再将其移除即可。<strong>添加或移除成员都不收取额外费用。</strong>目前成员数量上限为 <strong>25</strong> 人;如果您达到上限,请<a href="https://portal.usepylon.com/comfy-org/forms/question" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">联系支持</a>以增加席位。'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'team-upgrade-carryover',
|
||||
question: {
|
||||
en: 'What carries over when I upgrade my workspace to a Team plan?',
|
||||
'zh-CN': '将工作区升级为团队计划时,哪些内容会保留?'
|
||||
},
|
||||
answer: {
|
||||
en: "Everything stays. You're upgrading the workspace itself, so <strong>workflows, models, run history, and top-up credits all remain attached</strong>. The only exception: unused monthly credits from your old plan expire at the end of your current billing cycle, since you're moving to a new credit allowance. <strong>Top-up credits carry over.</strong>",
|
||||
'zh-CN':
|
||||
'全部保留。您升级的是工作区本身,因此<strong>工作流、模型、运行历史和充值积分都会保留</strong>。唯一例外:原计划中未使用的月度积分会在当前计费周期结束时失效,因为您将获得新的月度积分额度。<strong>充值积分会顺延。</strong>'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'team-tier-pricing',
|
||||
question: {
|
||||
en: 'What do the Team plan tiers cost, and how does the discount work?',
|
||||
'zh-CN': '团队计划各档次的价格是多少?折扣是怎么算的?'
|
||||
},
|
||||
answer: {
|
||||
en: 'Team plans come in <strong>five tiers from $200 to $2,500/month</strong>, set by a credit-commit slider. A bigger monthly commit means a bigger discount: <strong>annual plans go up to 20% off, monthly plans up to 10%</strong>. The discount starts at the $400 tier (5% annual / 2.5% monthly) and scales from there.',
|
||||
'zh-CN':
|
||||
'团队计划共有<strong>五个档次,从每月 $200 到 $2,500</strong>,通过积分承诺滑块进行调整。月度承诺越高,折扣越大:<strong>年付计划最高 20% 折扣,月付计划最高 10% 折扣</strong>。折扣自 $400 档位起(年付 5% / 月付 2.5%),并由此递增。'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'team-collaboration-features',
|
||||
question: {
|
||||
en: 'What collaboration features are included at launch?',
|
||||
'zh-CN': '首发时包含哪些协作功能?'
|
||||
},
|
||||
answer: {
|
||||
en: 'At launch, a Team plan gives you <strong>shared infrastructure</strong>: one credit pool, one bill, one set of admins. <strong>Workflow and asset sharing inside the workspace is coming soon.</strong> In the meantime, to hand off, share the workflow, export the workflow JSON, or drop a Comfy-generated asset into another canvas.',
|
||||
'zh-CN':
|
||||
'在首发阶段,团队计划为您提供<strong>共享基础设施</strong>:一个积分池、一份账单、一组管理员。<strong>工作区内的工作流与资产共享功能即将上线。</strong>在此之前,您可以通过共享工作流、导出工作流 JSON 或将 Comfy 生成的资产拖入另一画布来完成交接。'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'team-concurrency',
|
||||
question: {
|
||||
en: 'How does concurrency work on a Team plan? Can multiple members run workflows at the same time?',
|
||||
'zh-CN': '团队计划的并发是如何运作的?多名成员可以同时运行工作流吗?'
|
||||
},
|
||||
answer: {
|
||||
en: 'Yes. <strong>The workspace has 25 concurrent slots (matches the number of members), shared across the team</strong>. A ten-person team and a 25-person team both get the same 25 slots. If a few teammates saturate the pool, the rest queue up until slots free.',
|
||||
'zh-CN':
|
||||
'可以。<strong>工作区拥有 25 个并发槽位(与成员上限一致),由整个团队共享</strong>。无论是 10 人团队还是 25 人团队,都享有相同的 25 个槽位。如果少数成员占满了槽位,其他人会排队等待,直到有槽位空出。'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'runtime-and-concurrency-limits',
|
||||
question: {
|
||||
en: 'What are the runtime and concurrency limits?',
|
||||
'zh-CN': '运行时长和并发的限制是什么?'
|
||||
},
|
||||
answer: {
|
||||
en: 'Each workflow has a max runtime of <strong>30 minutes</strong> on Standard and Creator, raised to <strong>1 hour</strong> on Pro. Jobs over the limit are cancelled automatically to keep the system fair and stable. You can queue up to <strong>100 workflows</strong> at once, and run <strong>1 / 3 / 5</strong> concurrently via API on Standard / Creator / Pro. On Team plan, the limit is raised to 25, matching the number of members on the team. Need higher API rate limits? Contact <a href="mailto:enterprise@comfy.org" class="text-primary-comfy-yellow underline">enterprise@comfy.org</a>.',
|
||||
'zh-CN':
|
||||
'Standard 和 Creator 上,单个工作流的最长运行时长为 <strong>30 分钟</strong>;Pro 上提升至 <strong>1 小时</strong>。超出限制的任务会被自动取消,以保持系统的公平与稳定。您可以同时排队最多 <strong>100 个工作流</strong>,并在 Standard / Creator / Pro 上通过 API 分别并发运行 <strong>1 / 3 / 5</strong> 个工作流。在团队计划中,此上限提升至 25,与团队成员上限一致。需要更高的 API 速率限制?请联系 <a href="mailto:enterprise@comfy.org" class="text-primary-comfy-yellow underline">enterprise@comfy.org</a>。'
|
||||
}
|
||||
}
|
||||
] as const
|
||||
@@ -1,108 +0,0 @@
|
||||
import type { TranslationKey } from '../i18n/translations'
|
||||
|
||||
import { SHOW_FREE_TIER } from '../config/features'
|
||||
import { externalLinks } from '../config/routes'
|
||||
|
||||
export type BillingCycle = 'monthly' | 'yearly'
|
||||
|
||||
interface PlanFeature {
|
||||
text: TranslationKey
|
||||
included?: boolean
|
||||
}
|
||||
|
||||
export interface PricingPlan {
|
||||
id: string
|
||||
labelKey: TranslationKey
|
||||
priceKey?: TranslationKey
|
||||
yearlyPriceKey?: TranslationKey
|
||||
yearlyTotalKey?: TranslationKey
|
||||
creditsKey?: TranslationKey
|
||||
estimateKey?: TranslationKey
|
||||
ctaKey: TranslationKey
|
||||
ctaHref: (cycle: BillingCycle) => string
|
||||
features: PlanFeature[]
|
||||
isPopular?: boolean
|
||||
}
|
||||
|
||||
export const subscribeUrl = (
|
||||
tier: string,
|
||||
cycle: BillingCycle,
|
||||
stop?: string
|
||||
): string => {
|
||||
const params = new URLSearchParams({ tier, cycle })
|
||||
if (stop) params.set('stop', stop)
|
||||
return `${externalLinks.cloud}/cloud/subscribe?${params.toString()}`
|
||||
}
|
||||
|
||||
const freePlan: PricingPlan = {
|
||||
id: 'free',
|
||||
labelKey: 'pricing.plan.free.label',
|
||||
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 standardPricingPlans: PricingPlan[] = [
|
||||
{
|
||||
id: 'standard',
|
||||
labelKey: 'pricing.plan.standard.label',
|
||||
priceKey: 'pricing.plan.standard.price',
|
||||
yearlyPriceKey: 'pricing.plan.standard.yearlyPrice',
|
||||
yearlyTotalKey: 'pricing.plan.standard.yearlyTotal',
|
||||
creditsKey: 'pricing.plan.standard.credits',
|
||||
estimateKey: 'pricing.plan.standard.estimate',
|
||||
ctaKey: 'pricing.plan.standard.cta',
|
||||
ctaHref: (cycle) => subscribeUrl('standard', cycle),
|
||||
features: [
|
||||
{ text: 'pricing.feature.shortRuntime' },
|
||||
{ text: 'pricing.feature.addCredits' },
|
||||
{ text: 'pricing.feature.importModels', included: false },
|
||||
{ text: 'pricing.feature.longRuntime', included: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'creator',
|
||||
labelKey: 'pricing.plan.creator.label',
|
||||
priceKey: 'pricing.plan.creator.price',
|
||||
yearlyPriceKey: 'pricing.plan.creator.yearlyPrice',
|
||||
yearlyTotalKey: 'pricing.plan.creator.yearlyTotal',
|
||||
creditsKey: 'pricing.plan.creator.credits',
|
||||
estimateKey: 'pricing.plan.creator.estimate',
|
||||
ctaKey: 'pricing.plan.creator.cta',
|
||||
ctaHref: (cycle) => subscribeUrl('creator', cycle),
|
||||
features: [
|
||||
{ text: 'pricing.feature.shortRuntime' },
|
||||
{ text: 'pricing.feature.addCredits' },
|
||||
{ text: 'pricing.feature.importModels' },
|
||||
{ text: 'pricing.feature.longRuntime', included: false }
|
||||
],
|
||||
isPopular: true
|
||||
},
|
||||
{
|
||||
id: 'pro',
|
||||
labelKey: 'pricing.plan.pro.label',
|
||||
priceKey: 'pricing.plan.pro.price',
|
||||
yearlyPriceKey: 'pricing.plan.pro.yearlyPrice',
|
||||
yearlyTotalKey: 'pricing.plan.pro.yearlyTotal',
|
||||
creditsKey: 'pricing.plan.pro.credits',
|
||||
estimateKey: 'pricing.plan.pro.estimate',
|
||||
ctaKey: 'pricing.plan.pro.cta',
|
||||
ctaHref: (cycle) => subscribeUrl('pro', cycle),
|
||||
features: [
|
||||
{ text: 'pricing.feature.shortRuntime' },
|
||||
{ text: 'pricing.feature.addCredits' },
|
||||
{ text: 'pricing.feature.importModels' },
|
||||
{ text: 'pricing.feature.longRuntime' }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
export const pricingPlans: PricingPlan[] = SHOW_FREE_TIER
|
||||
? [freePlan, ...standardPricingPlans]
|
||||
: standardPricingPlans
|
||||
@@ -1,54 +0,0 @@
|
||||
export interface TeamCreditTier {
|
||||
credits: number
|
||||
basePrice: number
|
||||
monthlyPrice: number
|
||||
yearlyPrice: number
|
||||
videos: number
|
||||
}
|
||||
|
||||
export const teamCreditTiers: readonly TeamCreditTier[] = [
|
||||
{
|
||||
credits: 42200,
|
||||
basePrice: 200,
|
||||
monthlyPrice: 200,
|
||||
yearlyPrice: 200,
|
||||
videos: 3830
|
||||
},
|
||||
{
|
||||
credits: 84400,
|
||||
basePrice: 400,
|
||||
monthlyPrice: 390,
|
||||
yearlyPrice: 380,
|
||||
videos: 7660
|
||||
},
|
||||
{
|
||||
credits: 147700,
|
||||
basePrice: 700,
|
||||
monthlyPrice: 665,
|
||||
yearlyPrice: 630,
|
||||
videos: 13405
|
||||
},
|
||||
{
|
||||
credits: 295400,
|
||||
basePrice: 1400,
|
||||
monthlyPrice: 1295,
|
||||
yearlyPrice: 1190,
|
||||
videos: 26810
|
||||
},
|
||||
{
|
||||
credits: 527500,
|
||||
basePrice: 2500,
|
||||
monthlyPrice: 2250,
|
||||
yearlyPrice: 2000,
|
||||
videos: 47830
|
||||
}
|
||||
] as const
|
||||
|
||||
export function formatTeamCreditsLong(n: number): string {
|
||||
return n.toLocaleString('en-US')
|
||||
}
|
||||
|
||||
export function formatTeamCreditsShort(n: number): string {
|
||||
const k = n / 1000
|
||||
return k % 1 === 0 ? `${k}K` : `${k.toFixed(1)}K`
|
||||
}
|
||||
@@ -1189,72 +1189,27 @@ const translations = {
|
||||
'buildWhat.row2a': { en: "DOESN'T EXIST", 'zh-CN': '尚不存在的' },
|
||||
'buildWhat.row2b': { en: 'YET', 'zh-CN': '事物' },
|
||||
|
||||
// PricingSection
|
||||
'pricing.title': { en: 'Choose a plan', 'zh-CN': '价格' },
|
||||
// PriceSection
|
||||
'pricing.title': { en: 'Pricing', 'zh-CN': '价格' },
|
||||
'pricing.subtitle': {
|
||||
en: 'Access cloud-powered ComfyUI workflows with straightforward, usage-based pricing.',
|
||||
'zh-CN': '通过简单透明、按使用量计费的方式,访问云端 ComfyUI 工作流。'
|
||||
},
|
||||
'pricing.badge.popular': { en: 'MOST POPULAR', 'zh-CN': '最受欢迎' },
|
||||
'pricing.period.monthly': { en: 'Monthly', 'zh-CN': '按月' },
|
||||
'pricing.period.yearly': {
|
||||
en: 'Yearly (Up to 20% off)',
|
||||
'zh-CN': '按年(最高 20% 优惠)'
|
||||
},
|
||||
'pricing.period.billedMonthly': { en: 'Billed monthly', 'zh-CN': '按月计费' },
|
||||
'pricing.period.billedYearly': {
|
||||
en: '{total} billed yearly',
|
||||
'zh-CN': '按年计费 {total}'
|
||||
},
|
||||
'pricing.savePercent': {
|
||||
en: 'Save {pct}% ({amount})',
|
||||
'zh-CN': '节省 {pct}%({amount})'
|
||||
},
|
||||
'pricing.team.videosEstimate': {
|
||||
en: 'Generates ~{count} 5s videos*',
|
||||
'zh-CN': '约可生成 {count} 个 5 秒视频*'
|
||||
},
|
||||
'pricing.plan.period': { en: '/month', 'zh-CN': '/月' },
|
||||
'pricing.creditsLabel': { en: 'monthly credits', 'zh-CN': '每月积分' },
|
||||
|
||||
'pricing.feature.shortRuntime': {
|
||||
en: '30 minute max workflow runtime',
|
||||
'zh-CN': '单个工作流最长运行 30 分钟'
|
||||
},
|
||||
'pricing.feature.addCredits': {
|
||||
en: 'Add more credits anytime',
|
||||
'zh-CN': '可随时增加积分'
|
||||
},
|
||||
'pricing.feature.importModels': {
|
||||
en: 'Import your own models',
|
||||
'zh-CN': '导入你自己的模型'
|
||||
},
|
||||
'pricing.feature.longRuntime': {
|
||||
en: 'Longer workflow runtime (up to 1 hr)',
|
||||
'zh-CN': '更长工作流运行时长(最长 1 小时)'
|
||||
},
|
||||
'pricing.feature.inviteMembers': {
|
||||
en: 'Invite members',
|
||||
'zh-CN': '邀请成员'
|
||||
},
|
||||
'pricing.feature.concurrentWorkflows': {
|
||||
en: 'Members can run workflows concurrently',
|
||||
'zh-CN': '成员可并行运行工作流'
|
||||
},
|
||||
'pricing.feature.sharedCreditPool': {
|
||||
en: 'Shared credit pool for all members',
|
||||
'zh-CN': '所有成员共享积分池'
|
||||
},
|
||||
'pricing.feature.roleBasedPermissions': {
|
||||
en: 'Role-based permissions',
|
||||
'zh-CN': '基于角色的权限'
|
||||
},
|
||||
|
||||
'pricing.plan.free.label': { en: 'FREE', 'zh-CN': '免费版' },
|
||||
'pricing.plan.free.summary': {
|
||||
en: "Explore Comfy's possibilities",
|
||||
'zh-CN': '探索 Comfy 的可能性'
|
||||
},
|
||||
'pricing.plan.free.price': { en: '$0', 'zh-CN': '$0' },
|
||||
'pricing.plan.free.credits': { en: '400', 'zh-CN': '400' },
|
||||
'pricing.plan.free.credits': {
|
||||
en: 'Includes 400 monthly credits',
|
||||
'zh-CN': '每月包含 400 积分'
|
||||
},
|
||||
'pricing.plan.free.estimate': {
|
||||
en: 'Generates ~35 5s videos*',
|
||||
en: '~35 5s videos*',
|
||||
'zh-CN': '约可生成 35 个 5 秒视频*'
|
||||
},
|
||||
'pricing.plan.free.cta': { en: 'START FREE', 'zh-CN': '免费开始' },
|
||||
@@ -1268,88 +1223,113 @@ const translations = {
|
||||
},
|
||||
|
||||
'pricing.plan.standard.label': { en: 'STANDARD', 'zh-CN': '标准版' },
|
||||
'pricing.plan.standard.summary': {
|
||||
en: 'For individuals creating workflows',
|
||||
'zh-CN': '面向个人工作流创作者'
|
||||
},
|
||||
'pricing.plan.standard.price': { en: '$20', 'zh-CN': '$20' },
|
||||
'pricing.plan.standard.yearlyPrice': { en: '$16', 'zh-CN': '$16' },
|
||||
'pricing.plan.standard.yearlyTotal': { en: '$192', 'zh-CN': '$192' },
|
||||
'pricing.plan.standard.credits': { en: '4,200', 'zh-CN': '4,200' },
|
||||
'pricing.plan.standard.credits': {
|
||||
en: 'Includes 4,200 monthly credits with top-ups available',
|
||||
'zh-CN': '每月包含 4,200 积分,并支持充值'
|
||||
},
|
||||
'pricing.plan.standard.estimate': {
|
||||
en: 'Generates ~380 5s videos*',
|
||||
en: '~380 5s videos*',
|
||||
'zh-CN': '约可生成 380 个 5 秒视频*'
|
||||
},
|
||||
'pricing.plan.standard.cta': {
|
||||
en: 'SUBSCRIBE TO STANDARD',
|
||||
'zh-CN': '订阅标准版'
|
||||
},
|
||||
'pricing.plan.standard.featureIntro': {
|
||||
en: 'Everything in Free, plus:',
|
||||
'zh-CN': '包含免费版全部能力,另加:'
|
||||
},
|
||||
'pricing.plan.standard.feature1': {
|
||||
en: '30-minute max runtime per workflow',
|
||||
'zh-CN': '单个工作流最长运行 30 分钟'
|
||||
},
|
||||
'pricing.plan.standard.feature2': {
|
||||
en: 'Add more credits anytime',
|
||||
'zh-CN': '可随时增加积分'
|
||||
},
|
||||
'pricing.plan.standard.feature3': {
|
||||
en: 'Run 1 workflow concurrently (via API)',
|
||||
'zh-CN': '通过 API 并发运行 1 个工作流'
|
||||
},
|
||||
|
||||
'pricing.plan.creator.label': { en: 'CREATOR', 'zh-CN': '创作者版' },
|
||||
'pricing.plan.creator.summary': {
|
||||
en: 'Small teams building fine-tuned, repeatable workflows',
|
||||
'zh-CN': '小团队构建精细调优、可复用的工作流'
|
||||
},
|
||||
'pricing.plan.creator.price': { en: '$35', 'zh-CN': '$35' },
|
||||
'pricing.plan.creator.yearlyPrice': { en: '$28', 'zh-CN': '$28' },
|
||||
'pricing.plan.creator.yearlyTotal': { en: '$336', 'zh-CN': '$336' },
|
||||
'pricing.plan.creator.credits': { en: '7,400', 'zh-CN': '7,400' },
|
||||
'pricing.plan.creator.credits': {
|
||||
en: 'Includes 7,400 monthly credits with top-ups available',
|
||||
'zh-CN': '每月包含 7,400 积分,并支持充值'
|
||||
},
|
||||
'pricing.plan.creator.estimate': {
|
||||
en: 'Generates ~670 5s videos*',
|
||||
en: '~670 5s videos*',
|
||||
'zh-CN': '约可生成 670 个 5 秒视频*'
|
||||
},
|
||||
'pricing.plan.creator.cta': {
|
||||
en: 'SUBSCRIBE TO CREATOR',
|
||||
'zh-CN': '订阅创作者版'
|
||||
},
|
||||
'pricing.plan.creator.featureIntro': {
|
||||
en: 'Everything in Standard, plus:',
|
||||
'zh-CN': '包含标准版全部能力,另加:'
|
||||
},
|
||||
'pricing.plan.creator.feature1': {
|
||||
en: 'Import your own LoRAs',
|
||||
'zh-CN': '导入你自己的 LoRA'
|
||||
},
|
||||
'pricing.plan.creator.feature2': {
|
||||
en: 'Run up to 3 workflows concurrently (via API)',
|
||||
'zh-CN': '通过 API 最多并发运行 3 个工作流'
|
||||
},
|
||||
|
||||
'pricing.plan.pro.label': { en: 'PRO', 'zh-CN': '专业版' },
|
||||
'pricing.plan.pro.summary': {
|
||||
en: 'For growing teams running Comfy in production',
|
||||
'zh-CN': '面向在生产环境使用 Comfy 的成长型团队'
|
||||
},
|
||||
'pricing.plan.pro.price': { en: '$100', 'zh-CN': '$100' },
|
||||
'pricing.plan.pro.yearlyPrice': { en: '$80', 'zh-CN': '$80' },
|
||||
'pricing.plan.pro.yearlyTotal': { en: '$960', 'zh-CN': '$960' },
|
||||
'pricing.plan.pro.credits': { en: '21,100', 'zh-CN': '21,100' },
|
||||
'pricing.plan.pro.credits': {
|
||||
en: 'Includes 21,100 monthly credits with top-ups available',
|
||||
'zh-CN': '每月包含 21,100 积分,并支持充值'
|
||||
},
|
||||
'pricing.plan.pro.estimate': {
|
||||
en: 'Generates ~1,915 5s videos*',
|
||||
en: '~1,915 5s videos*',
|
||||
'zh-CN': '约可生成 1,915 个 5 秒视频*'
|
||||
},
|
||||
'pricing.plan.pro.cta': { en: 'SUBSCRIBE TO PRO', 'zh-CN': '订阅专业版' },
|
||||
|
||||
'pricing.plan.team.label': { en: 'TEAM', 'zh-CN': '团队版' },
|
||||
'pricing.plan.team.cta': {
|
||||
en: 'SUBSCRIBE TO TEAM',
|
||||
'zh-CN': '订阅团队版'
|
||||
'pricing.plan.pro.featureIntro': {
|
||||
en: 'Everything in Creator, plus:',
|
||||
'zh-CN': '包含创作者版全部能力,另加:'
|
||||
},
|
||||
'pricing.plan.team.everythingInProPlus': {
|
||||
en: 'Everything in Pro, plus:',
|
||||
'zh-CN': '包含专业版的全部功能,另加:'
|
||||
'pricing.plan.pro.feature1': {
|
||||
en: 'Longer workflow runtime (up to 1 hour)',
|
||||
'zh-CN': '更长工作流运行时长(最长 1 小时)'
|
||||
},
|
||||
'pricing.team.description': {
|
||||
en: 'Built for teams collaborating on workflows together.',
|
||||
'zh-CN': '为协作开发工作流的团队打造。'
|
||||
},
|
||||
'pricing.plan.team.comingSoon': {
|
||||
en: 'Coming soon...',
|
||||
'zh-CN': '即将推出…'
|
||||
},
|
||||
'pricing.plan.team.sharedWorkflowsAndAssets': {
|
||||
en: 'Shared workflows & assets',
|
||||
'zh-CN': '共享工作流与资产'
|
||||
},
|
||||
'pricing.plan.team.projects': {
|
||||
en: 'Projects',
|
||||
'zh-CN': '项目'
|
||||
},
|
||||
'pricing.plan.feature.status.coming': {
|
||||
en: 'Coming soon',
|
||||
'zh-CN': '即将推出'
|
||||
},
|
||||
'pricing.plan.feature.status.included': {
|
||||
en: 'Included',
|
||||
'zh-CN': '已包含'
|
||||
},
|
||||
'pricing.plan.feature.status.notIncluded': {
|
||||
en: 'Not included',
|
||||
'zh-CN': '未包含'
|
||||
'pricing.plan.pro.feature2': {
|
||||
en: 'Run up to 5 workflows concurrently (via API)',
|
||||
'zh-CN': '通过 API 最多并发运行 5 个工作流'
|
||||
},
|
||||
|
||||
'pricing.enterprise.label': { en: 'ENTERPRISE', 'zh-CN': '企业版' },
|
||||
'pricing.enterprise.description': {
|
||||
en: 'Need more members? Looking for more flexibility or custom features?',
|
||||
'zh-CN': '需要更多成员?想要更多灵活性或定制功能?'
|
||||
'pricing.enterprise.heading': {
|
||||
en: 'Looking for Enterprise Solutions?',
|
||||
'zh-CN': '在寻找企业级解决方案?'
|
||||
},
|
||||
'pricing.enterprise.description': {
|
||||
en: 'For teams running Comfy in production, and at scale.',
|
||||
'zh-CN': '面向在生产环境和规模化场景中运行 Comfy 的团队。'
|
||||
},
|
||||
'pricing.enterprise.cta': { en: 'LEARN MORE', 'zh-CN': '了解更多' },
|
||||
'pricing.enterprise.featureIntro': {
|
||||
en: 'Everything in Pro, plus:',
|
||||
'zh-CN': '包含专业版全部能力,另加:'
|
||||
},
|
||||
'pricing.enterprise.cta': { en: 'Contact Us', 'zh-CN': '联系我们' },
|
||||
'pricing.enterprise.feature1': {
|
||||
en: 'Annual commitments with bulk pricing and custom compute packages',
|
||||
'zh-CN': '支持年度承诺、批量定价与定制算力套餐'
|
||||
@@ -1377,10 +1357,6 @@ const translations = {
|
||||
en: "What's included\nin the Comfy plan",
|
||||
'zh-CN': 'Comfy 计划\n包含哪些内容'
|
||||
},
|
||||
'pricing.included.comingSoon': {
|
||||
en: '(coming soon)',
|
||||
'zh-CN': '(即将推出)'
|
||||
},
|
||||
'pricing.included.feature1.title': {
|
||||
en: 'Machine Setup',
|
||||
'zh-CN': '机器配置'
|
||||
@@ -1394,9 +1370,9 @@ const translations = {
|
||||
'zh-CN': '单个任务时限'
|
||||
},
|
||||
'pricing.included.feature2.description': {
|
||||
en: 'Each workflow run has a maximum duration of 30 minutes. On the Pro plan, the time limit is increased to 1 hour. Jobs exceeding that limit are automatically cancelled to ensure fair usage and system stability.',
|
||||
en: 'On our Standard and Creator plans, each workflow has a maximum run time of 30 minutes. On the Pro plan, the limit is increased to 1 hour. Jobs exceeding that limit are automatically cancelled to ensure fair usage and system stability.',
|
||||
'zh-CN':
|
||||
'每个工作流运行的最长时长为 30 分钟。Pro 计划的时限可延长至 1 小时。超时任务将自动取消,以确保公平使用和系统稳定。'
|
||||
'Standard 和 Creator 计划下,每个工作流最长运行时间为 30 分钟。Pro 计划的时限可延长至 1 小时。超时任务将自动取消,以确保公平使用和系统稳定。'
|
||||
},
|
||||
'pricing.included.feature3.title': {
|
||||
en: 'Usage',
|
||||
@@ -1421,9 +1397,9 @@ const translations = {
|
||||
'zh-CN': '随时加购积分'
|
||||
},
|
||||
'pricing.included.feature5.description': {
|
||||
en: 'Purchase additional credits at any time. Unused top-ups roll over to the next month automatically for up to 1 year.',
|
||||
en: 'Purchase additional credits at any time. Top-up credits are valid for 1 year from the date of purchase and do not roll over with your monthly plan.',
|
||||
'zh-CN':
|
||||
'可随时购买额外积分。未使用的充值积分将自动顺延至下个月,最长可保留 1 年。'
|
||||
'可随时购买额外积分。充值积分自购买之日起 1 年内有效,且不会随月度计划结转。'
|
||||
},
|
||||
'pricing.included.feature6.title': {
|
||||
en: 'Pre-installed models',
|
||||
@@ -1440,16 +1416,16 @@ const translations = {
|
||||
'pricing.included.feature7.description': {
|
||||
en: "Comfy Cloud currently supports a variety of the most-used custom nodes from the ComfyUI community. We're expanding support regularly based on demand and compatibility.",
|
||||
'zh-CN':
|
||||
'Comfy Cloud 目前支持 ComfyUI 社区中最常用的多种自定义节点。我们会根据需求和兼容性持续扩展支持范围。'
|
||||
'Comfy Cloud 目前支持 ComfyUI 社区中最常用的多种自定义节点,并根据需求和兼容性持续扩展支持范围。'
|
||||
},
|
||||
'pricing.included.feature8.title': {
|
||||
en: 'Partner Nodes',
|
||||
'zh-CN': '合作伙伴节点'
|
||||
},
|
||||
'pricing.included.feature8.description': {
|
||||
en: 'Run <strong>proprietary models</strong> through Comfy\'s <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">Partner Nodes</a>, such as Nano Banana. The amount of credits each node uses depends on the model and parameters you set in the node, but these credits are the same ones that your monthly subscription comes with. These credits can also be used across <strong>Comfy Cloud and local ComfyUI</strong>. Read more about Partner nodes <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">here</a>.',
|
||||
en: 'Run <strong>proprietary models</strong> through Comfy\'s <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">Partner Nodes</a>, such as Nano Banana. The amount of credits each node uses depends on the model and parameters you set in the node, but these credits are the same ones that your monthly subscription comes with. These credits can also be used across Comfy Cloud and Comfy Desktop. Read more about Partner nodes <a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">here</a>.',
|
||||
'zh-CN':
|
||||
'通过 Comfy 的<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">合作伙伴节点</a>运行<strong>专有模型</strong>,如 Nano Banana。每个节点消耗的积分取决于所用模型和参数设置,且与月度订阅积分通用。积分可在 <strong>Comfy Cloud 和本地 ComfyUI</strong> 间通用。了解更多关于合作伙伴节点的信息请点击<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">此处</a>。'
|
||||
'通过 Comfy 的<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">合作伙伴节点</a>运行<strong>专有模型</strong>,如 Nano Banana。每个节点消耗的积分取决于所用模型和参数设置,且与月度订阅积分通用。积分可在 Comfy Cloud 和 Comfy 桌面版间通用。了解更多关于合作伙伴节点的信息请点击<a href="https://docs.comfy.org/tutorials/partner-nodes/overview" class="text-primary-comfy-yellow underline">此处</a>。'
|
||||
},
|
||||
'pricing.included.feature9.title': {
|
||||
en: 'Job queue',
|
||||
@@ -1469,19 +1445,23 @@ const translations = {
|
||||
'Creator 或 Pro 计划用户可从 CivitAI 或 Huggingface 导入自己的模型和 LoRA,打造专属风格。'
|
||||
},
|
||||
'pricing.included.feature11.title': {
|
||||
en: 'Run Workflows via API',
|
||||
'zh-CN': '通过 API 运行工作流'
|
||||
},
|
||||
'pricing.included.feature11.description': {
|
||||
en: 'Run Comfy workflows programmatically via API, with concurrency limits based on your plan. Perfect for integrating ComfyUI into your applications, automating batch processing, or building production pipelines. For higher rate limits, reach out to <a href="mailto:enterprise@comfy.org" class="text-primary-comfy-yellow underline">enterprise@comfy.org</a>.',
|
||||
'zh-CN':
|
||||
'通过 API 以编程方式运行 Comfy 工作流,并发上限由您的计划决定。非常适合将 ComfyUI 集成到您的应用、自动化批量处理或构建生产级流水线。如需更高的速率限制,请联系 <a href="mailto:enterprise@comfy.org" class="text-primary-comfy-yellow underline">enterprise@comfy.org</a>。'
|
||||
},
|
||||
'pricing.included.feature12.title': {
|
||||
en: 'Parallel job execution',
|
||||
'zh-CN': '并行任务执行'
|
||||
},
|
||||
'pricing.included.feature11.description': {
|
||||
'pricing.included.feature12.description': {
|
||||
en: 'Run multiple workflows in parallel to speed up your pipeline.',
|
||||
'zh-CN': '并行运行多个工作流,加速你的流程。'
|
||||
},
|
||||
|
||||
'pricing.faq.heading': {
|
||||
en: 'Q&A',
|
||||
'zh-CN': '问答'
|
||||
},
|
||||
|
||||
// VideoPlayer
|
||||
'player.play': { en: 'Play', 'zh-CN': '播放' },
|
||||
'player.pause': { en: 'Pause', 'zh-CN': '暂停' },
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import PricingSection from '../../components/pricing/PricingSection.vue'
|
||||
import PriceSection from '../../components/pricing/PriceSection.vue'
|
||||
import WhatsIncludedSection from '../../components/pricing/WhatsIncludedSection.vue'
|
||||
import FAQSection from '../../components/pricing/FAQSection.vue'
|
||||
---
|
||||
|
||||
<BaseLayout title="Pricing — Comfy Cloud">
|
||||
<PricingSection client:load />
|
||||
<PriceSection client:load />
|
||||
<WhatsIncludedSection />
|
||||
<FAQSection client:visible />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
---
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import PricingSection from '../../../components/pricing/PricingSection.vue'
|
||||
import PriceSection from '../../../components/pricing/PriceSection.vue'
|
||||
import WhatsIncludedSection from '../../../components/pricing/WhatsIncludedSection.vue'
|
||||
import FAQSection from '../../../components/pricing/FAQSection.vue'
|
||||
---
|
||||
|
||||
<BaseLayout title="定价 — Comfy Cloud">
|
||||
<PricingSection locale="zh-CN" client:load />
|
||||
<PriceSection locale="zh-CN" client:load />
|
||||
<WhatsIncludedSection locale="zh-CN" />
|
||||
<FAQSection locale="zh-CN" client:visible />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -62,7 +62,6 @@
|
||||
@theme {
|
||||
--color-site-dropdown: #332b38;
|
||||
--color-primary-comfy-yellow: #f2ff59;
|
||||
--color-primary-comfy-orange: #fabc25;
|
||||
--color-primary-comfy-ink: #211927;
|
||||
--color-primary-comfy-ink-light: #2a2330;
|
||||
--color-primary-comfy-canvas: #c2bfb9;
|
||||
|
||||
@@ -19,7 +19,10 @@
|
||||
"size:collect": "node scripts/size-collect.js",
|
||||
"size:report": "node scripts/size-report.js",
|
||||
"collect-i18n": "pnpm exec playwright test --config=playwright.i18n.config.ts",
|
||||
"dev:cloud": "cross-env DEV_SERVER_COMFYUI_URL='https://testcloud.comfy.org/' vite --config vite.config.mts",
|
||||
"dev:cloud": "pnpm dev:cloud:test",
|
||||
"dev:cloud:test": "cross-env DEV_SERVER_COMFYUI_URL=https://testcloud.comfy.org/ vite --config vite.config.mts",
|
||||
"dev:cloud:staging": "cross-env DEV_SERVER_COMFYUI_URL=https://stagingcloud.comfy.org/ vite --config vite.config.mts",
|
||||
"dev:cloud:prod": "cross-env DEV_SERVER_COMFYUI_URL=https://cloud.comfy.org/ vite --config vite.config.mts",
|
||||
"dev:desktop": "pnpm --filter @comfyorg/desktop-ui run dev",
|
||||
"dev:electron": "cross-env DISTRIBUTION=desktop vite --config vite.electron.config.mts",
|
||||
"dev:no-vue": "cross-env DISABLE_VUE_PLUGINS=true vite --config vite.config.mts",
|
||||
|
||||
@@ -8,12 +8,12 @@ export function useWorkflowStatusDismissal() {
|
||||
const executionStore = useExecutionStore()
|
||||
|
||||
watch(
|
||||
() => workflowStore.activeWorkflow,
|
||||
(workflow) => {
|
||||
if (
|
||||
workflow &&
|
||||
executionStore.getWorkflowStatus(workflow) !== 'running'
|
||||
) {
|
||||
() => {
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
return [workflow, executionStore.getWorkflowStatus(workflow)] as const
|
||||
},
|
||||
([workflow, status]) => {
|
||||
if (workflow && status !== undefined && status !== 'running') {
|
||||
executionStore.clearWorkflowStatus(workflow)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -26,6 +26,7 @@ vi.mock('@/scripts/app', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/core/load3d', () => ({}))
|
||||
vi.mock('@/extensions/core/load3dAdvanced', () => ({}))
|
||||
vi.mock('@/extensions/core/load3dPreviewExtensions', () => ({}))
|
||||
vi.mock('@/extensions/core/saveMesh', () => ({}))
|
||||
|
||||
|
||||
@@ -246,3 +246,37 @@ describe('Comfy.UploadAudio AUDIOUPLOAD widget', () => {
|
||||
expect(mockFetchApi).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
type AudioUIWidget = (node: LGraphNode, inputName: string) => unknown
|
||||
|
||||
async function loadAudioUIWidget() {
|
||||
vi.resetModules()
|
||||
mockRegisterExtension.mockClear()
|
||||
await import('./uploadAudio')
|
||||
const extension = mockRegisterExtension.mock.calls
|
||||
.map(([extension]) => extension as ComfyExtension)
|
||||
.find((extension) => extension.name === 'Comfy.AudioWidget')
|
||||
if (!extension)
|
||||
throw new Error('Comfy.AudioWidget extension was not registered')
|
||||
const widgets = await extension.getCustomWidgets!(fromAny({}))
|
||||
return (widgets as Record<string, AudioUIWidget>).AUDIO_UI
|
||||
}
|
||||
|
||||
describe('Comfy.AudioWidget AUDIO_UI widget', () => {
|
||||
it('excludes the audio player from workflow and prompt serialization', async () => {
|
||||
const AUDIO_UI = await loadAudioUIWidget()
|
||||
const domWidget = {
|
||||
serialize: true,
|
||||
options: {} as Record<string, unknown>
|
||||
}
|
||||
const node = fromAny<LGraphNode, unknown>({
|
||||
addDOMWidget: vi.fn(() => domWidget),
|
||||
constructor: { nodeData: { output_node: false } }
|
||||
})
|
||||
|
||||
AUDIO_UI(node, 'audioUI')
|
||||
|
||||
expect(domWidget.serialize).toBe(false)
|
||||
expect(domWidget.options.serialize).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -128,6 +128,7 @@ app.registerExtension({
|
||||
const audioUIWidget: DOMWidget<HTMLAudioElement, string> =
|
||||
node.addDOMWidget(inputName, /* name=*/ 'audioUI', audio)
|
||||
audioUIWidget.serialize = false
|
||||
audioUIWidget.options.serialize = false
|
||||
const { nodeData } = node.constructor
|
||||
if (nodeData == null) throw new TypeError('nodeData is null')
|
||||
|
||||
|
||||
@@ -129,6 +129,21 @@ describe('useSubscriptionDialog', () => {
|
||||
expect(props).not.toHaveProperty('onChooseTeam')
|
||||
})
|
||||
|
||||
it('sizes the unified pricing dialog via the Reka contentClass, not the ignored PrimeVue style', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsInPersonalWorkspace.value = true
|
||||
const { showPricingTable } = useSubscriptionDialog()
|
||||
|
||||
showPricingTable()
|
||||
|
||||
const { dialogComponentProps } = mockShowLayoutDialog.mock.calls[0][0]
|
||||
// Reka (the default renderer) sizes via size/contentClass; a PrimeVue
|
||||
// `style` width is silently ignored and collapses the wide table to the
|
||||
// default md (576px) frame.
|
||||
expect(dialogComponentProps).toHaveProperty('contentClass')
|
||||
expect(dialogComponentProps).not.toHaveProperty('style')
|
||||
})
|
||||
|
||||
it('defaults to the personal tab in a personal workspace', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsInPersonalWorkspace.value = true
|
||||
|
||||
@@ -129,18 +129,15 @@ export const useSubscriptionDialog = () => {
|
||||
(workspaceStore.isInPersonalWorkspace ? 'personal' : 'team')
|
||||
},
|
||||
dialogComponentProps: {
|
||||
// The dialog hugs its content so each step sizes itself: the pricing
|
||||
// table stays wide/fixed (cards fill it, DES QA 2026-06-13) while the
|
||||
// compact confirm/success steps shrink instead of floating in the big
|
||||
// pricing modal. Sizes are set on the content root per checkoutStep.
|
||||
style: 'max-width: 95vw; max-height: 90vh;',
|
||||
pt: {
|
||||
root: { class: 'rounded-2xl bg-transparent' },
|
||||
content: {
|
||||
class:
|
||||
'!p-0 rounded-2xl border border-border-default bg-secondary-background shadow-[0_25px_80px_rgba(5,6,12,0.45)]'
|
||||
}
|
||||
}
|
||||
// Reka (the default renderer) sizes via size/contentClass; a PrimeVue
|
||||
// `style` width is ignored here and collapses the table to the default
|
||||
// `md` frame. `w-fit` lets each step hug its content — the pricing
|
||||
// table fills its 1280px content while the compact confirm/success
|
||||
// steps shrink (the content root sets its own width per checkoutStep).
|
||||
renderer: 'reka',
|
||||
size: 'full',
|
||||
contentClass:
|
||||
'w-fit max-w-[min(1280px,95vw)] sm:max-w-[min(1280px,95vw)] max-h-[90vh] rounded-2xl border border-border-default bg-secondary-background shadow-[0_25px_80px_rgba(5,6,12,0.45)]'
|
||||
}
|
||||
})
|
||||
return
|
||||
|
||||
227
src/utils/queueDisplay.test.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import type { JobState } from '@/types/queue'
|
||||
import type { BuildJobDisplayCtx } from '@/utils/queueDisplay'
|
||||
import { buildJobDisplay, iconForJobState } from '@/utils/queueDisplay'
|
||||
|
||||
type QueueDisplayTask = Parameters<typeof buildJobDisplay>[0]
|
||||
type PreviewOutput = NonNullable<QueueDisplayTask['previewOutput']>
|
||||
|
||||
function createJob(
|
||||
status: JobListItem['status'],
|
||||
overrides: Partial<JobListItem> = {}
|
||||
): JobListItem {
|
||||
return {
|
||||
id: 'job-123456',
|
||||
status,
|
||||
create_time: 1_710_000_000_000,
|
||||
priority: 12,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function createTask({
|
||||
job,
|
||||
jobId = 'job-123456',
|
||||
createTime = 1_710_000_000_000,
|
||||
executionTime,
|
||||
executionTimeInSeconds,
|
||||
previewOutput
|
||||
}: {
|
||||
job?: Partial<JobListItem>
|
||||
jobId?: string
|
||||
createTime?: number
|
||||
executionTime?: number
|
||||
executionTimeInSeconds?: number
|
||||
previewOutput?: PreviewOutput
|
||||
} = {}): QueueDisplayTask {
|
||||
return {
|
||||
job: createJob(job?.status ?? 'pending', job),
|
||||
jobId,
|
||||
createTime,
|
||||
executionTime,
|
||||
executionTimeInSeconds,
|
||||
previewOutput
|
||||
} as QueueDisplayTask
|
||||
}
|
||||
|
||||
function createCtx(
|
||||
overrides: Partial<BuildJobDisplayCtx> = {}
|
||||
): BuildJobDisplayCtx {
|
||||
return {
|
||||
t: (key, values) => {
|
||||
const entries = Object.entries(values ?? {})
|
||||
if (!entries.length) return key
|
||||
|
||||
return `${key}(${entries
|
||||
.map(([name, value]) => `${name}=${String(value)}`)
|
||||
.join(',')})`
|
||||
},
|
||||
locale: 'en-US',
|
||||
formatClockTimeFn: (ts, locale) => `${locale}:${ts}`,
|
||||
isActive: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('iconForJobState', () => {
|
||||
it.for<[JobState, string]>([
|
||||
['pending', 'icon-[lucide--loader-circle]'],
|
||||
['initialization', 'icon-[lucide--server-crash]'],
|
||||
['running', 'icon-[lucide--zap]'],
|
||||
['completed', 'icon-[lucide--check-check]'],
|
||||
['failed', 'icon-[lucide--alert-circle]']
|
||||
])('maps %s to its icon', ([state, icon]) => {
|
||||
expect(iconForJobState(state)).toBe(icon)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildJobDisplay', () => {
|
||||
it('shows the added hint for pending jobs when requested', () => {
|
||||
expect(
|
||||
buildJobDisplay(
|
||||
createTask(),
|
||||
'pending',
|
||||
createCtx({ showAddedHint: true })
|
||||
)
|
||||
).toEqual({
|
||||
iconName: 'icon-[lucide--check]',
|
||||
primary: 'queue.jobAddedToQueue',
|
||||
secondary: 'en-US:1710000000000',
|
||||
showClear: true
|
||||
})
|
||||
})
|
||||
|
||||
it('shows queued time for pending and initializing jobs', () => {
|
||||
expect(buildJobDisplay(createTask(), 'pending', createCtx())).toMatchObject(
|
||||
{
|
||||
iconName: 'icon-[lucide--loader-circle]',
|
||||
primary: 'queue.inQueue',
|
||||
secondary: 'en-US:1710000000000',
|
||||
showClear: true
|
||||
}
|
||||
)
|
||||
|
||||
expect(
|
||||
buildJobDisplay(createTask(), 'initialization', createCtx())
|
||||
).toMatchObject({
|
||||
iconName: 'icon-[lucide--server-crash]',
|
||||
primary: 'queue.initializingAlmostReady',
|
||||
secondary: 'en-US:1710000000000',
|
||||
showClear: true
|
||||
})
|
||||
})
|
||||
|
||||
it('formats active running progress from the injected context', () => {
|
||||
expect(
|
||||
buildJobDisplay(
|
||||
createTask({ job: { status: 'in_progress' } }),
|
||||
'running',
|
||||
createCtx({
|
||||
isActive: true,
|
||||
totalPercent: 42.7,
|
||||
currentNodePercent: -10,
|
||||
currentNodeName: 'KSampler'
|
||||
})
|
||||
)
|
||||
).toEqual({
|
||||
iconName: 'icon-[lucide--zap]',
|
||||
primary: 'sideToolbar.queueProgressOverlay.total(percent=43%)',
|
||||
secondary:
|
||||
'KSampler sideToolbar.queueProgressOverlay.colonPercent(percent=0%)',
|
||||
showClear: true
|
||||
})
|
||||
})
|
||||
|
||||
it('uses a compact running label when the job is not active', () => {
|
||||
expect(
|
||||
buildJobDisplay(
|
||||
createTask({ job: { status: 'in_progress' } }),
|
||||
'running',
|
||||
createCtx()
|
||||
)
|
||||
).toEqual({
|
||||
iconName: 'icon-[lucide--zap]',
|
||||
primary: 'g.running',
|
||||
secondary: '',
|
||||
showClear: true
|
||||
})
|
||||
})
|
||||
|
||||
it('shows local completed jobs as the preview filename', () => {
|
||||
expect(
|
||||
buildJobDisplay(
|
||||
createTask({
|
||||
job: {
|
||||
status: 'completed'
|
||||
},
|
||||
executionTimeInSeconds: 3.51,
|
||||
previewOutput: {
|
||||
filename: 'preview.png',
|
||||
isImage: true,
|
||||
url: '/api/view?filename=preview.png&type=output&subfolder='
|
||||
} as PreviewOutput
|
||||
}),
|
||||
'completed',
|
||||
createCtx()
|
||||
)
|
||||
).toEqual({
|
||||
iconName: 'icon-[lucide--check-check]',
|
||||
iconImageUrl: '/api/view?filename=preview.png&type=output&subfolder=',
|
||||
primary: 'preview.png',
|
||||
secondary: '3.51s',
|
||||
showClear: false
|
||||
})
|
||||
})
|
||||
|
||||
it('shows cloud completed jobs as elapsed time', () => {
|
||||
expect(
|
||||
buildJobDisplay(
|
||||
createTask({
|
||||
job: {
|
||||
status: 'completed'
|
||||
},
|
||||
executionTime: 64_000,
|
||||
executionTimeInSeconds: 64
|
||||
}),
|
||||
'completed',
|
||||
createCtx({ isCloud: true })
|
||||
)
|
||||
).toMatchObject({
|
||||
iconName: 'icon-[lucide--check-check]',
|
||||
primary: 'queue.completedIn(duration=1m 4s)',
|
||||
secondary: '64.00s',
|
||||
showClear: false
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to job title for completed jobs without a preview filename', () => {
|
||||
expect(
|
||||
buildJobDisplay(
|
||||
createTask({
|
||||
job: {
|
||||
status: 'completed',
|
||||
priority: 42
|
||||
}
|
||||
}),
|
||||
'completed',
|
||||
createCtx()
|
||||
)
|
||||
).toMatchObject({
|
||||
iconName: 'icon-[lucide--check-check]',
|
||||
primary: 'g.job #42',
|
||||
secondary: '',
|
||||
showClear: false
|
||||
})
|
||||
})
|
||||
|
||||
it('shows failed jobs as clearable failures', () => {
|
||||
expect(buildJobDisplay(createTask(), 'failed', createCtx())).toEqual({
|
||||
iconName: 'icon-[lucide--alert-circle]',
|
||||
primary: 'g.failed',
|
||||
secondary: 'g.failed',
|
||||
showClear: true
|
||||
})
|
||||
})
|
||||
})
|
||||