Compare commits
63 Commits
fix/label-
...
feat/websi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
adcedf684d | ||
|
|
79ccc9e2e9 | ||
|
|
c60307550d | ||
|
|
2aefd12372 | ||
|
|
46f4c28cc9 | ||
|
|
67a1a858de | ||
|
|
5d17502e35 | ||
|
|
cee60e55df | ||
|
|
e27b388420 | ||
|
|
99a01476a2 | ||
|
|
4c248eef4d | ||
|
|
2a71e32caf | ||
|
|
72633ea771 | ||
|
|
e40f33831c | ||
|
|
c6c85bf6c8 | ||
|
|
4cc0402325 | ||
|
|
a2adfe5124 | ||
|
|
49a90d4e2e | ||
|
|
d6c582c399 | ||
|
|
a6db1ab3d6 | ||
|
|
2ec2a0e091 | ||
|
|
9cf5c9a93f | ||
|
|
9e5fb67b76 | ||
|
|
690e0e3590 | ||
|
|
01738b7b19 | ||
|
|
be9de941c9 | ||
|
|
f4e0430072 | ||
|
|
c78592c1ec | ||
|
|
00b0c6b434 | ||
|
|
da34fa3944 | ||
|
|
c8ed15da31 | ||
|
|
b132abc64a | ||
|
|
55c52a730a | ||
|
|
fbe462143a | ||
|
|
61cb1bcde0 | ||
|
|
9dcab4ee96 | ||
|
|
dc29f30b02 | ||
|
|
fb3350ee0e | ||
|
|
be8e0010ee | ||
|
|
d0e97d6933 | ||
|
|
3377b8e07e | ||
|
|
4a2393be48 | ||
|
|
a451a90868 | ||
|
|
be102899d7 | ||
|
|
abd1a6f10a | ||
|
|
c16f10b49e | ||
|
|
64253de713 | ||
|
|
b4ae6344d7 | ||
|
|
feaa4ce82f | ||
|
|
f5d059b720 | ||
|
|
e2c670bbc3 | ||
|
|
dfcb336499 | ||
|
|
caabebe145 | ||
|
|
7d3d8ce63f | ||
|
|
506e33e7dd | ||
|
|
7376402fc6 | ||
|
|
be38f14619 | ||
|
|
13b42d9b59 | ||
|
|
e604c85b88 | ||
|
|
7ae3ad936c | ||
|
|
7b83228cdd | ||
|
|
d26f578c81 | ||
|
|
cfbc378df3 |
@@ -33,15 +33,15 @@ Flag:
|
||||
- **New circular entity dependencies** — New circular imports between `LGraph` ↔ `Subgraph`, `LGraphNode` ↔ `LGraphCanvas`, or similar entity classes.
|
||||
- **Direct `graph._version++`** — Mutating the private version counter directly instead of through a public API. Extensions already depend on this side-channel; it must become a proper API.
|
||||
|
||||
### Centralized Registries and ECS-Style Access
|
||||
### Dedicated Stores and Data/Behavior Separation
|
||||
|
||||
All entity data access should move toward centralized query patterns, not instance property access.
|
||||
Entity data lives in dedicated Pinia stores keyed by string IDs (`widgetValueStore`, `domWidgetStore`, `layoutStore`, `nodeOutputStore`, `subgraphNavigationStore`, `previewExposureStore`), not on entity instances.
|
||||
|
||||
Flag:
|
||||
|
||||
- **New instance method/property patterns** — Adding `node.someProperty` or `node.someMethod()` for data that should be a component in the World, queried via `world.getComponent(entityId, ComponentType)`.
|
||||
- **New instance method/property patterns** — Adding `node.someProperty` or `node.someMethod()` for data that belongs in a dedicated store (e.g. widget values → `widgetValueStore` keyed by `WidgetId`).
|
||||
- **OOP inheritance for entity modeling** — Extending entity classes with new subclasses instead of composing behavior through components and systems.
|
||||
- **Scattered state** — New entity state stored in multiple locations (class properties, stores, local variables) instead of being consolidated in the World or in a single store.
|
||||
- **Duplicated authority** — Storing the same entity state in both a class property and a store, or across two stores, so ownership becomes ambiguous. Each piece of state should have one owning store.
|
||||
|
||||
### Extension Ecosystem Impact
|
||||
|
||||
|
||||
5
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
@@ -88,9 +88,9 @@ jobs:
|
||||
- name: Strip non-source entries from coverage
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
run: |
|
||||
# Drop served bundle scripts (localhost-8188/assets/*.js) that V8 records but have no source file on disk, which would abort genhtml.
|
||||
lcov --remove coverage/playwright/coverage.lcov \
|
||||
'*localhost-8188*' \
|
||||
'assets/images/*' \
|
||||
-o coverage/playwright/coverage.lcov \
|
||||
--ignore-errors unused
|
||||
wc -l coverage/playwright/coverage.lcov
|
||||
@@ -121,7 +121,8 @@ jobs:
|
||||
--title "ComfyUI E2E Coverage" \
|
||||
--no-function-coverage \
|
||||
--precision 1 \
|
||||
--ignore-errors source,unmapped
|
||||
--ignore-errors source,unmapped \
|
||||
--synthesize-missing
|
||||
|
||||
- name: Upload HTML report artifact
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
|
||||
3
.github/workflows/ci-tests-unit.yaml
vendored
@@ -55,3 +55,6 @@ jobs:
|
||||
flags: unit
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: false
|
||||
|
||||
- name: Enforce critical coverage gate
|
||||
run: pnpm test:coverage:critical
|
||||
|
||||
2
.github/workflows/cla.yml
vendored
@@ -41,7 +41,7 @@ jobs:
|
||||
|
||||
# Allowlist bots so they don't need to sign (optional, comma-separated).
|
||||
# *[bot] is a catch-all for any GitHub App bot account.
|
||||
allowlist: actions-user,ampagent,claude,comfy-pr-bot,github-actions,*[bot],Glary Bot
|
||||
allowlist: action@github.com,actions-user,ampagent,claude,comfy-pr-bot,GitHub Action,github-actions,Glary Bot,Glary-Bot,*[bot]
|
||||
|
||||
# Custom PR comment messages
|
||||
custom-notsigned-prcomment: |
|
||||
|
||||
5
.github/workflows/pr-backport.yaml
vendored
@@ -67,6 +67,11 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# Persist a token with `workflow` scope so the backport push can
|
||||
# include changes to .github/workflows/**. The default GITHUB_TOKEN
|
||||
# is refused by GitHub when a push creates/updates workflow files,
|
||||
# which silently aborted the whole job (see PR #12804 backport).
|
||||
token: ${{ secrets.PR_GH_TOKEN }}
|
||||
|
||||
- name: Configure git
|
||||
run: |
|
||||
|
||||
@@ -21,7 +21,8 @@ module.exports = defineConfig({
|
||||
'ar',
|
||||
'tr',
|
||||
'pt-BR',
|
||||
'fa'
|
||||
'fa',
|
||||
'he'
|
||||
],
|
||||
reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora, HiDream, Civitai, Hugging Face.
|
||||
'latent' is the short form of 'latent space'.
|
||||
@@ -37,5 +38,11 @@ module.exports = defineConfig({
|
||||
- Keep commonly used technical terms in English when they are standard in Persian software (e.g., node, workflow).
|
||||
- Use Arabic-Indic numerals (۰-۹) for numbers where appropriate.
|
||||
- Maintain consistency with terminology used in Persian software and design applications.
|
||||
|
||||
IMPORTANT Hebrew Translation Guidelines:
|
||||
- For 'he' locale: Use modern, formal Hebrew (עברית תקנית) for a professional tone throughout the UI.
|
||||
- Hebrew is a right-to-left (RTL) language. Keep all interpolation placeholders ({name}, {count}), pipe-separated plural forms, and English technical terms intact and in their original positions.
|
||||
- Preferred glossary: node = צומת (plural צמתים), workflow = תהליך עבודה, queue = תור, canvas = קנבס, widget = פקד, subgraph = תת-גרף, prompt = פרומפט/הנחיה (per context), bypass = עקיפה, mute = השתקה.
|
||||
- Keep widely-recognized technical terms in English (Latin script): API, GPU, CUDA, VAE, CLIP, LoRA, ControlNet, Civitai, Hugging Face, Nodes 2.0, etc.
|
||||
`
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"packages/registry-types/src/comfyRegistryTypes.ts",
|
||||
"public/materialdesignicons.min.css",
|
||||
"src/types/generatedManagerTypes.ts",
|
||||
"**/__fixtures__/**/*.json"
|
||||
"**/__fixtures__/**/*.json",
|
||||
"apps/website/src/content/**/*.mdx"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -179,6 +179,9 @@ This project uses **pnpm**. Always prefer scripts defined in `package.json` (e.g
|
||||
23. Favor pure functions (especially testable ones)
|
||||
24. Do not use function expressions if it's possible to use function declarations instead
|
||||
25. Watch out for [Code Smells](https://wiki.c2.com/?CodeSmell) and refactor to avoid them
|
||||
26. Do not add alias helpers whose implementation is just a single-line call to another function
|
||||
- Bad: `function id(value) { return nodeId(value) }`
|
||||
- Use the real function directly, or introduce a named helper only when it adds validation, branching, domain meaning, or shared behavior beyond renaming
|
||||
|
||||
## Design Standards
|
||||
|
||||
@@ -246,7 +249,7 @@ All architectural decisions are documented in `docs/adr/`. Code changes must be
|
||||
### Entity Architecture Constraints (ADR 0003 + ADR 0008)
|
||||
|
||||
1. **Command pattern for all mutations**: Every entity state change must be a serializable, idempotent, deterministic command — replayable, undoable, and transmittable over CRDT. No imperative fire-and-forget mutation APIs. Systems produce command batches, not direct side effects.
|
||||
2. **Centralized registries and ECS-style access**: Entity data lives in the World (centralized registry), queried via `world.getComponent(entityId, ComponentType)`. Do not add new instance properties/methods to entity classes. Do not use OOP inheritance for entity modeling.
|
||||
2. **Dedicated stores over instance state**: Entity data lives in dedicated Pinia stores keyed by string IDs — widget values in `widgetValueStore` keyed by `WidgetId` (`graphId:nodeId:name`, see `src/types/widgetId.ts`), plus `domWidgetStore`, `layoutStore`, `nodeOutputStore`, `subgraphNavigationStore`, and `previewExposureStore`. Prefer a focused store to a single unified registry. Do not add new instance properties/methods to entity classes for data that belongs in a store. Do not use OOP inheritance for entity modeling.
|
||||
3. **No god-object growth**: Do not add methods to `LGraphNode`, `LGraphCanvas`, `LGraph`, or `Subgraph`. Extract to systems, stores, or composables.
|
||||
4. **Plain data components**: ECS components are plain data objects — no methods, no back-references to parent entities. Behavior belongs in systems (pure functions).
|
||||
5. **Extension ecosystem impact**: Changes to entity callbacks (`onConnectionsChange`, `onRemoved`, `onAdded`, `onConnectInput/Output`, `onConfigure`, `onWidgetChanged`), `node.widgets` access, `node.serialize`, or `graph._version++` affect 40+ custom node repos and require migration guidance.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { defineConfig } from 'astro/config'
|
||||
import mdx from '@astrojs/mdx'
|
||||
import sitemap from '@astrojs/sitemap'
|
||||
import vue from '@astrojs/vue'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
@@ -24,6 +25,9 @@ export default defineConfig({
|
||||
site: 'https://comfy.org',
|
||||
output: 'static',
|
||||
prefetch: { prefetchAll: true },
|
||||
// Keep MDX punctuation verbatim; SmartyPants would turn the source's straight
|
||||
// quotes into curly ones and drift from the rest of the site's copy.
|
||||
markdown: { smartypants: false },
|
||||
redirects: {
|
||||
'/cloud/enterprise-case-studies/comfyui-at-architectural-scale-how-moment-factory-reimagined-3d-projection-mapping':
|
||||
'/customers/moment-factory/',
|
||||
@@ -37,6 +41,7 @@ export default defineConfig({
|
||||
devToolbar: { enabled: !process.env.NO_TOOLBAR },
|
||||
integrations: [
|
||||
vue(),
|
||||
mdx(),
|
||||
sitemap({
|
||||
filter: (page) => !isExcludedFromSitemap(page)
|
||||
})
|
||||
|
||||
@@ -111,8 +111,14 @@ test.describe('Affiliates landing — desktop interactions', () => {
|
||||
await firstQuestion.scrollIntoViewIfNeeded()
|
||||
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'false')
|
||||
|
||||
await firstQuestion.click()
|
||||
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'true')
|
||||
// The FAQ accordion is a Reka (client:visible) island whose trigger already
|
||||
// renders aria-expanded="false" server-side, so that attribute is not a
|
||||
// reliable hydration gate. Re-click until the island has hydrated and the
|
||||
// trigger actually toggles open.
|
||||
await expect(async () => {
|
||||
await firstQuestion.click()
|
||||
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'true')
|
||||
}).toPass()
|
||||
await expect(page.getByText(FIRST_FAQ.answer.en)).toBeVisible()
|
||||
|
||||
await firstQuestion.click()
|
||||
|
||||
@@ -87,11 +87,11 @@ test.describe('Cloud page @smoke', () => {
|
||||
await expect(cards).toHaveCount(3)
|
||||
})
|
||||
|
||||
test('FAQSection heading is visible with 15 items', async ({ page }) => {
|
||||
test('FAQSection heading is visible with 12 items', async ({ page }) => {
|
||||
await expect(page.getByRole('heading', { name: /FAQ/i })).toBeVisible()
|
||||
|
||||
const faqButtons = page.locator('button[aria-controls^="faq-panel-"]')
|
||||
await expect(faqButtons).toHaveCount(15)
|
||||
await expect(faqButtons).toHaveCount(12)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
131
apps/website/e2e/customers-detail.spec.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
test.describe('Customer story detail @smoke', () => {
|
||||
test('renders the migrated article: hero, section nav, and body', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/customers/series-entertainment')
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
level: 1,
|
||||
name: /Series Entertainment Rebuilt Game and Video Production/i
|
||||
})
|
||||
).toBeVisible()
|
||||
|
||||
const nav = page.getByRole('navigation', { name: 'Category filter' })
|
||||
await expect(nav.getByRole('button', { name: 'INTRO' })).toBeVisible()
|
||||
await expect(nav.getByRole('button', { name: 'CONCLUSION' })).toBeVisible()
|
||||
|
||||
// Section title rendered from the MDX <Section title> wrapper.
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name: 'The Output Series Achieved Using ComfyUI'
|
||||
})
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('section nav highlights the section the reader selects', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/customers/series-entertainment')
|
||||
const nav = page.getByRole('navigation', { name: 'Category filter' })
|
||||
const intro = nav.getByRole('button', { name: 'INTRO' })
|
||||
const problem = nav.getByRole('button', { name: 'THE PROBLEM' })
|
||||
|
||||
await expect(intro).toHaveAttribute('aria-pressed', 'true')
|
||||
|
||||
await problem.click()
|
||||
await expect(problem).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
|
||||
test('shows the read-more link only when an external source exists', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/customers/open-story-movement')
|
||||
await expect(
|
||||
page.getByRole('link', { name: /read more on this topic/i })
|
||||
).toBeVisible()
|
||||
|
||||
// series-entertainment only redirected back to itself, so the link is gone.
|
||||
await page.goto('/customers/series-entertainment')
|
||||
await expect(
|
||||
page.getByRole('link', { name: /read more on this topic/i })
|
||||
).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('links to the next story in the what-is-next section', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/customers/series-entertainment')
|
||||
const nextLink = page.getByRole('link', { name: /view article/i })
|
||||
await expect(nextLink).toBeVisible()
|
||||
// Links to another customer story, without coupling the test to the
|
||||
// specific slug or sort order.
|
||||
await expect(nextLink).toHaveAttribute('href', /^\/customers\/[a-z0-9-]+$/)
|
||||
await expect(nextLink).not.toHaveAttribute(
|
||||
'href',
|
||||
'/customers/series-entertainment'
|
||||
)
|
||||
})
|
||||
|
||||
test('renders a Creative Campus story with its education blocks', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/customers/xindi-zhang')
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
level: 1,
|
||||
name: /The tool that expands my art/i
|
||||
})
|
||||
).toBeVisible()
|
||||
|
||||
const nav = page.getByRole('navigation', { name: 'Category filter' })
|
||||
await expect(nav.getByRole('button', { name: 'INTRO' })).toBeVisible()
|
||||
await expect(nav.getByRole('button', { name: 'AT A GLANCE' })).toBeVisible()
|
||||
|
||||
// At a glance block (AtAGlance component) with its spec rows.
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'At a glance' })
|
||||
).toBeVisible()
|
||||
await expect(page.getByText('Program', { exact: true })).toBeVisible()
|
||||
|
||||
// Workflow download button (Download component).
|
||||
await expect(
|
||||
page.getByRole('link', {
|
||||
name: /Download Xindi's style transfer workflow/i
|
||||
})
|
||||
).toBeVisible()
|
||||
|
||||
// Shared education call to action (EducationCta component).
|
||||
await expect(
|
||||
page.getByRole('link', { name: /Explore the Education Program/i })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('emits one connected JSON-LD graph describing the story as an Article', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/customers/series-entertainment')
|
||||
|
||||
const blocks = await page
|
||||
.locator('script[type="application/ld+json"]')
|
||||
.allTextContents()
|
||||
|
||||
// Single self-contained @graph, so the site-wide Organization/WebSite are
|
||||
// not duplicated (guards the head-slot triple-render regression too).
|
||||
expect(blocks).toHaveLength(1)
|
||||
|
||||
const graph = JSON.parse(blocks[0])['@graph'] as Record<string, unknown>[]
|
||||
const types = graph.map((node) => node['@type'])
|
||||
expect(types.filter((type) => type === 'Organization')).toHaveLength(1)
|
||||
expect(types).toContain('Article')
|
||||
expect(types).toContain('BreadcrumbList')
|
||||
|
||||
const article = graph.find((node) => node['@type'] === 'Article')
|
||||
expect(article?.headline).toMatch(/Series Entertainment/)
|
||||
})
|
||||
})
|
||||
@@ -30,4 +30,40 @@ test.describe('Customers @smoke', () => {
|
||||
expect(heightWhileUnloaded).not.toBeNull()
|
||||
expect(heightWhileUnloaded!).toBeGreaterThan(100)
|
||||
})
|
||||
|
||||
test('emits one connected JSON-LD graph describing the story collection', async ({
|
||||
page
|
||||
}) => {
|
||||
const blocks = await page
|
||||
.locator('script[type="application/ld+json"]')
|
||||
.allTextContents()
|
||||
|
||||
// A single self-contained @graph; the site-wide Organization/WebSite are
|
||||
// opted out here so they are not duplicated (also guards the head-slot
|
||||
// triple-render regression).
|
||||
expect(blocks).toHaveLength(1)
|
||||
|
||||
const graph = JSON.parse(blocks[0])['@graph'] as Record<string, unknown>[]
|
||||
const types = graph.map((node) => node['@type'])
|
||||
expect(types.filter((type) => type === 'Organization')).toHaveLength(1)
|
||||
expect(types).toContain('CollectionPage')
|
||||
|
||||
// Tie the list length to the story cards actually rendered, so the test
|
||||
// tracks real content rather than a hardcoded count.
|
||||
const cardSlugs = await page
|
||||
.locator('a[href^="/customers/"]')
|
||||
.evaluateAll((links) => [
|
||||
...new Set(
|
||||
links
|
||||
.map((link) => link.getAttribute('href'))
|
||||
.filter((href): href is string =>
|
||||
/^\/customers\/[a-z0-9-]+$/.test(href ?? '')
|
||||
)
|
||||
)
|
||||
])
|
||||
expect(cardSlugs.length).toBeGreaterThan(0)
|
||||
|
||||
const list = graph.find((node) => node['@type'] === 'ItemList')
|
||||
expect(list?.itemListElement as unknown[]).toHaveLength(cardSlugs.length)
|
||||
})
|
||||
})
|
||||
|
||||
329
apps/website/e2e/education.spec.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { externalLinks } from '../src/config/routes'
|
||||
import { educationFaqs } from '../src/data/educationFaq'
|
||||
import { t } from '../src/i18n/translations'
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
const PATH = '/edu'
|
||||
const LEARNING_PATH = '/learning'
|
||||
const PRICING_PATH = '/cloud/pricing'
|
||||
const STUDENT_AMBASSADOR_FORM = externalLinks.studentAmbassadorForm
|
||||
|
||||
const MONTHLY_LABEL = t('pricing.period.monthly', 'en')
|
||||
const EDU_YEARLY_TOGGLE = t('pricing.period.yearly.edu', 'en')
|
||||
const eduSavings = (pct: number) =>
|
||||
t('pricing.educationalSavings', 'en').replace('{pct}', String(pct))
|
||||
const eduTeamSaving = (pct: number, amount: string) =>
|
||||
t('pricing.team.educationalSaving', 'en')
|
||||
.replace('{pct}', String(pct))
|
||||
.replace('{amount}', amount)
|
||||
|
||||
const pricingSection = (page: Page) =>
|
||||
page.locator('section').filter({
|
||||
has: page.getByRole('heading', { name: /Choose a plan/i })
|
||||
})
|
||||
|
||||
// The pricing section is an Astro `client:visible` island, so the billing
|
||||
// toggle only becomes interactive once it hydrates — a click can otherwise
|
||||
// land before the handler is attached. Retry the click (only while the target
|
||||
// state is absent, so a re-click can't deselect) until a sentinel price shows.
|
||||
const switchToMonthly = async (page: Page, sentinel: Locator) => {
|
||||
const monthly = page.getByText(MONTHLY_LABEL, { exact: true })
|
||||
await expect(async () => {
|
||||
if (!(await sentinel.isVisible())) await monthly.click()
|
||||
await expect(sentinel).toBeVisible({ timeout: 1000 })
|
||||
}).toPass({ timeout: 15_000 })
|
||||
}
|
||||
const FAQ_COUNT = educationFaqs.length
|
||||
const FIRST_FAQ = educationFaqs[0]
|
||||
const HERO_TITLE_TEXT = t('education.hero.title', 'en').replace(/\s+/g, ' ')
|
||||
const HERO_BADGE_TEXT = t('education.hero.badge', 'en')
|
||||
const FAQ_HEADING_TEXT = t('education.faq.heading', 'en')
|
||||
const CTA_HEADING_TEXT = t('education.cta.heading', 'en')
|
||||
const CTA_CHOOSE_PLAN_LABEL = t('education.cta.choosePlan', 'en')
|
||||
const CTA_START_LEARNING_LABEL = t('education.cta.startLearning', 'en')
|
||||
const CTA_TERMS_LABEL = t('education.cta.termsLabel', 'en')
|
||||
|
||||
test.describe('Education landing — desktop @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(PATH)
|
||||
})
|
||||
|
||||
test('renders the hero badge and headline', async ({ page }) => {
|
||||
await expect(page.getByText(HERO_BADGE_TEXT, { exact: true })).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 1, name: HERO_TITLE_TEXT })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('renders the Q&A heading and is indexable', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 2, name: FAQ_HEADING_TEXT })
|
||||
).toBeVisible()
|
||||
|
||||
await expect(page.locator('meta[name="robots"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('renders the closing CTA heading and both buttons', async ({ page }) => {
|
||||
const ctaSection = page.locator('section').filter({
|
||||
has: page.getByRole('heading', { level: 2, name: CTA_HEADING_TEXT })
|
||||
})
|
||||
const ctaHeading = ctaSection.getByRole('heading', {
|
||||
level: 2,
|
||||
name: CTA_HEADING_TEXT
|
||||
})
|
||||
await ctaHeading.scrollIntoViewIfNeeded()
|
||||
await expect(ctaHeading).toBeVisible()
|
||||
|
||||
const choosePlan = ctaSection.getByRole('link', {
|
||||
name: CTA_CHOOSE_PLAN_LABEL
|
||||
})
|
||||
await expect(choosePlan).toBeVisible()
|
||||
await expect(choosePlan).toHaveAttribute('href', '#plans')
|
||||
|
||||
const startLearning = ctaSection.getByRole('link', {
|
||||
name: CTA_START_LEARNING_LABEL
|
||||
})
|
||||
await expect(startLearning).toBeVisible()
|
||||
await expect(startLearning).toHaveAttribute('href', LEARNING_PATH)
|
||||
await expect(startLearning).not.toHaveAttribute('target', '_blank')
|
||||
})
|
||||
|
||||
test('CTA section links to the pricing FAQs in the same tab', async ({
|
||||
page
|
||||
}) => {
|
||||
const termsLink = page.getByRole('link', { name: CTA_TERMS_LABEL })
|
||||
await termsLink.scrollIntoViewIfNeeded()
|
||||
await expect(termsLink).toBeVisible()
|
||||
await expect(termsLink).toHaveAttribute('href', PRICING_PATH)
|
||||
await expect(termsLink).not.toHaveAttribute('target', '_blank')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Education landing — desktop interactions', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(PATH)
|
||||
})
|
||||
|
||||
test('emits FAQPage structured data with one entry per FAQ', async ({
|
||||
page
|
||||
}) => {
|
||||
const faqJsonLd = await page.evaluate(() => {
|
||||
const scripts = Array.from(
|
||||
document.querySelectorAll<HTMLScriptElement>(
|
||||
'script[type="application/ld+json"]'
|
||||
)
|
||||
)
|
||||
const match = scripts.find((s) =>
|
||||
(s.textContent ?? '').includes('FAQPage')
|
||||
)
|
||||
return match?.textContent ?? null
|
||||
})
|
||||
expect(faqJsonLd, 'FAQ JSON-LD script').not.toBeNull()
|
||||
const parsed = JSON.parse(faqJsonLd!)
|
||||
expect(parsed['@type']).toBe('FAQPage')
|
||||
expect(Array.isArray(parsed.mainEntity)).toBe(true)
|
||||
expect(parsed.mainEntity.length).toBe(FAQ_COUNT)
|
||||
})
|
||||
|
||||
test('FAQ items toggle open and closed on click', async ({ page }) => {
|
||||
const firstQuestion = page.getByRole('button', {
|
||||
name: FIRST_FAQ.question.en
|
||||
})
|
||||
await firstQuestion.scrollIntoViewIfNeeded()
|
||||
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'false')
|
||||
|
||||
await firstQuestion.click()
|
||||
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'true')
|
||||
await expect(page.getByText(FIRST_FAQ.answer.en)).toBeVisible()
|
||||
|
||||
await firstQuestion.click()
|
||||
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'false')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Education pricing — desktop @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(PATH)
|
||||
})
|
||||
|
||||
test('shows yearly education prices with the monthly list price struck through', async ({
|
||||
page
|
||||
}) => {
|
||||
const section = pricingSection(page)
|
||||
await section.scrollIntoViewIfNeeded()
|
||||
|
||||
// Default billing period is yearly: 25% off the monthly list price.
|
||||
await expect(section.getByText('$15', { exact: true })).toBeVisible()
|
||||
await expect(section.getByText('$26.25', { exact: true })).toBeVisible()
|
||||
await expect(section.getByText('$75', { exact: true })).toBeVisible()
|
||||
|
||||
for (const listPrice of ['$20', '$35', '$100']) {
|
||||
await expect(
|
||||
section.locator('span.line-through', {
|
||||
hasText: new RegExp(`^\\${listPrice}$`)
|
||||
})
|
||||
).toBeVisible()
|
||||
}
|
||||
|
||||
await expect(page.getByText(EDU_YEARLY_TOGGLE)).toBeVisible()
|
||||
})
|
||||
|
||||
test('education savings label flips 25% ↔ 10% with the billing toggle', async ({
|
||||
page
|
||||
}) => {
|
||||
const section = pricingSection(page)
|
||||
await section.scrollIntoViewIfNeeded()
|
||||
|
||||
// Yearly (default): 25% off.
|
||||
await expect(section.getByText(eduSavings(25)).first()).toBeVisible()
|
||||
|
||||
// Flip to monthly: 10% off the monthly list price.
|
||||
await switchToMonthly(page, section.getByText('$18', { exact: true }))
|
||||
|
||||
await expect(section.getByText('$31.50', { exact: true })).toBeVisible()
|
||||
await expect(section.getByText('$90', { exact: true })).toBeVisible()
|
||||
|
||||
// Strikethrough remains the monthly list price in both cycles.
|
||||
for (const listPrice of ['$20', '$35', '$100']) {
|
||||
await expect(
|
||||
section.locator('span.line-through', {
|
||||
hasText: new RegExp(`^\\${listPrice}$`)
|
||||
})
|
||||
).toBeVisible()
|
||||
}
|
||||
|
||||
await expect(section.getByText(eduSavings(10)).first()).toBeVisible()
|
||||
await expect(section.getByText(eduSavings(25))).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Education pricing — team card @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(PATH)
|
||||
})
|
||||
|
||||
test('shows the team education price with basePrice struck and a saving label', async ({
|
||||
page
|
||||
}) => {
|
||||
const section = pricingSection(page)
|
||||
await section.scrollIntoViewIfNeeded()
|
||||
|
||||
// Default: yearly, default tier (basePrice $700) → 15% off → $595.
|
||||
await expect(section.getByText('$595', { exact: true })).toBeVisible()
|
||||
await expect(
|
||||
section.locator('span.line-through', { hasText: /^\$700$/ })
|
||||
).toBeVisible()
|
||||
await expect(section.getByText(eduTeamSaving(15, '$105'))).toBeVisible()
|
||||
})
|
||||
|
||||
test('slider tier change updates the team education price and saving', async ({
|
||||
page
|
||||
}) => {
|
||||
const section = pricingSection(page)
|
||||
await section.scrollIntoViewIfNeeded()
|
||||
|
||||
const slider = section.getByRole('slider')
|
||||
await slider.focus()
|
||||
await page.keyboard.press('ArrowRight')
|
||||
|
||||
// Next tier (basePrice $1,400) yearly → 20% off → $1,120.
|
||||
await expect(section.getByText('$1,120', { exact: true })).toBeVisible()
|
||||
await expect(section.getByText(eduTeamSaving(20, '$280'))).toBeVisible()
|
||||
})
|
||||
|
||||
test('billing toggle switches the team monthly/yearly education price', async ({
|
||||
page
|
||||
}) => {
|
||||
const section = pricingSection(page)
|
||||
await section.scrollIntoViewIfNeeded()
|
||||
|
||||
// Monthly, default tier (basePrice $700) → 10% off → $630.
|
||||
await switchToMonthly(page, section.getByText('$630', { exact: true }))
|
||||
|
||||
await expect(
|
||||
section.locator('span.line-through', { hasText: /^\$700$/ })
|
||||
).toBeVisible()
|
||||
await expect(section.getByText(eduTeamSaving(10, '$70'))).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Education pricing — Creative Campus band @smoke', () => {
|
||||
const CAMPUS_LABEL = t('pricing.creativeCampus.label', 'en')
|
||||
const CAMPUS_DESC = t('pricing.creativeCampus.description', 'en')
|
||||
const CONTACT_CTA = t('pricing.enterprise.cta', 'en')
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(PATH)
|
||||
})
|
||||
|
||||
test('renders the Creative Campus label and description', async ({
|
||||
page
|
||||
}) => {
|
||||
const section = pricingSection(page)
|
||||
await section.scrollIntoViewIfNeeded()
|
||||
|
||||
await expect(section.getByText(CAMPUS_LABEL, { exact: true })).toBeVisible()
|
||||
await expect(section.getByText(CAMPUS_DESC)).toBeVisible()
|
||||
})
|
||||
|
||||
test('Contact Us CTA routes to /contact', async ({ page }) => {
|
||||
const section = pricingSection(page)
|
||||
await section.scrollIntoViewIfNeeded()
|
||||
|
||||
const contact = section.getByRole('link', { name: CONTACT_CTA })
|
||||
await expect(contact).toBeVisible()
|
||||
await expect(contact).toHaveAttribute('href', '/contact')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Education pricing — Student Ambassador band @smoke', () => {
|
||||
const AMBASSADOR_LABEL = t('pricing.studentAmbassador.label', 'en')
|
||||
const AMBASSADOR_TAG = t('pricing.studentAmbassador.comingSoon', 'en')
|
||||
const AMBASSADOR_DESC = t('pricing.studentAmbassador.description', 'en')
|
||||
const AMBASSADOR_CTA = t('pricing.studentAmbassador.cta', 'en')
|
||||
|
||||
test('renders the band with an active Register Interest CTA to the form', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(PATH)
|
||||
const section = pricingSection(page)
|
||||
await section.scrollIntoViewIfNeeded()
|
||||
|
||||
await expect(
|
||||
section.getByText(AMBASSADOR_LABEL, { exact: true })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
section.getByText(AMBASSADOR_TAG, { exact: true })
|
||||
).toBeVisible()
|
||||
await expect(section.getByText(AMBASSADOR_DESC)).toBeVisible()
|
||||
|
||||
const register = section.getByRole('link', { name: AMBASSADOR_CTA })
|
||||
await expect(register).toBeVisible()
|
||||
await expect(register).toHaveAttribute('href', STUDENT_AMBASSADOR_FORM)
|
||||
await expect(register).toHaveAttribute('target', '_blank')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Education landing — mobile @mobile', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(PATH)
|
||||
})
|
||||
|
||||
test('closing CTA stays within the viewport width', async ({ page }) => {
|
||||
const ctaHeading = page.getByRole('heading', {
|
||||
level: 2,
|
||||
name: CTA_HEADING_TEXT
|
||||
})
|
||||
await ctaHeading.scrollIntoViewIfNeeded()
|
||||
await expect(ctaHeading).toBeVisible()
|
||||
|
||||
const box = await ctaHeading.boundingBox()
|
||||
expect(box, 'CTA heading bounding box').not.toBeNull()
|
||||
expect(box!.x + box!.width).toBeLessThanOrEqual(
|
||||
page.viewportSize()!.width + 1
|
||||
)
|
||||
})
|
||||
})
|
||||
231
apps/website/e2e/launches.spec.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { externalLinks } from '../src/config/routes'
|
||||
import { drops } from '../src/data/drops'
|
||||
import type { Locale } from '../src/i18n/translations'
|
||||
import { t } from '../src/i18n/translations'
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
const PATH_EN = '/launches'
|
||||
const PATH_ZH = '/zh-CN/launches'
|
||||
const CLOUD_URL = 'https://cloud.comfy.org'
|
||||
|
||||
const LOCALES: ReadonlyArray<readonly [string, Locale]> = [
|
||||
[PATH_EN, 'en'],
|
||||
[PATH_ZH, 'zh-CN']
|
||||
]
|
||||
|
||||
function heroSection(page: Page, locale: Locale) {
|
||||
return page.locator('section').filter({
|
||||
has: page.getByRole('heading', {
|
||||
level: 1,
|
||||
name: t('launches.hero.title', locale)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function ctaSection(page: Page, locale: Locale) {
|
||||
return page.locator('section').filter({
|
||||
has: page.getByRole('heading', {
|
||||
level: 2,
|
||||
name: t('launches.cta.heading', locale)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function dropsSection(page: Page, locale: Locale) {
|
||||
return page.locator('section').filter({
|
||||
has: page.getByRole('heading', {
|
||||
level: 2,
|
||||
name: t('launches.section.title', locale)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Launches landing — desktop @smoke', () => {
|
||||
test('renders the configured title at /launches', async ({ page }) => {
|
||||
await page.goto(PATH_EN)
|
||||
await expect(page).toHaveTitle(t('launches.page.title', 'en'))
|
||||
})
|
||||
|
||||
test('renders the localized title at /zh-CN/launches', async ({ page }) => {
|
||||
await page.goto(PATH_ZH)
|
||||
await expect(page).toHaveTitle(t('launches.page.title', 'zh-CN'))
|
||||
})
|
||||
|
||||
test('is indexable at both locales', async ({ page }) => {
|
||||
await page.goto(PATH_EN)
|
||||
await expect(page.locator('meta[name="robots"]')).toHaveCount(0)
|
||||
|
||||
await page.goto(PATH_ZH)
|
||||
await expect(page.locator('meta[name="robots"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('hero h1 renders the localized title in both locales', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(PATH_EN)
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
level: 1,
|
||||
name: t('launches.hero.title', 'en')
|
||||
})
|
||||
).toBeVisible()
|
||||
|
||||
await page.goto(PATH_ZH)
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
level: 1,
|
||||
name: t('launches.hero.title', 'zh-CN')
|
||||
})
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('hero primary CTA links to /download per locale', async ({ page }) => {
|
||||
for (const [path, locale, expectedHref] of [
|
||||
[PATH_EN, 'en', '/download'],
|
||||
[PATH_ZH, 'zh-CN', '/zh-CN/download']
|
||||
] as const) {
|
||||
await page.goto(path)
|
||||
const primary = heroSection(page, locale).getByRole('link', {
|
||||
name: t('launches.hero.primary', locale)
|
||||
})
|
||||
await expect(primary).toBeVisible()
|
||||
await expect(primary).toHaveAttribute('href', expectedHref)
|
||||
}
|
||||
})
|
||||
|
||||
test('hero secondary CTA opens external Cloud in a new tab on both locales', async ({
|
||||
page
|
||||
}) => {
|
||||
for (const [path, locale] of LOCALES) {
|
||||
await page.goto(path)
|
||||
const secondary = heroSection(page, locale).getByRole('link', {
|
||||
name: t('launches.hero.secondary', locale)
|
||||
})
|
||||
await expect(secondary).toBeVisible()
|
||||
await expect(secondary).toHaveAttribute('href', CLOUD_URL)
|
||||
await expect(secondary).toHaveAttribute('target', '_blank')
|
||||
await expect(secondary).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
}
|
||||
})
|
||||
|
||||
test('closing CTA shows heading and both action buttons in both locales', async ({
|
||||
page
|
||||
}) => {
|
||||
for (const [path, locale] of LOCALES) {
|
||||
await page.goto(path)
|
||||
const section = ctaSection(page, locale)
|
||||
await expect(
|
||||
section.getByRole('heading', {
|
||||
level: 2,
|
||||
name: t('launches.cta.heading', locale)
|
||||
})
|
||||
).toBeVisible()
|
||||
|
||||
const primary = section.getByRole('link', {
|
||||
name: t('launches.cta.primary', locale)
|
||||
})
|
||||
await expect(primary).toBeVisible()
|
||||
await expect(primary).toHaveAttribute('href', externalLinks.cloud)
|
||||
await expect(primary).toHaveAttribute('target', '_blank')
|
||||
await expect(primary).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
|
||||
const secondary = section.getByRole('link', {
|
||||
name: t('launches.cta.secondary', locale)
|
||||
})
|
||||
await expect(secondary).toBeVisible()
|
||||
await expect(secondary).toHaveAttribute('href', externalLinks.workflows)
|
||||
await expect(secondary).toHaveAttribute('target', '_blank')
|
||||
await expect(secondary).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
}
|
||||
})
|
||||
|
||||
test('drops section renders one card per data entry with the correct localized href in both locales', async ({
|
||||
page
|
||||
}) => {
|
||||
for (const [path, locale] of LOCALES) {
|
||||
await page.goto(path)
|
||||
const section = dropsSection(page, locale)
|
||||
|
||||
await expect(
|
||||
section.getByRole('heading', {
|
||||
level: 2,
|
||||
name: t('launches.section.title', locale)
|
||||
})
|
||||
).toBeVisible()
|
||||
|
||||
const cards = section.locator('[data-slot="card"]')
|
||||
await expect(cards).toHaveCount(drops.length)
|
||||
|
||||
for (const [i, drop] of drops.entries()) {
|
||||
const card = cards.nth(i)
|
||||
await expect(card).toContainText(drop.title[locale])
|
||||
const explore = card.getByRole('link', {
|
||||
name: drop.cta.label[locale]
|
||||
})
|
||||
await expect(explore).toBeVisible()
|
||||
await expect(explore).toHaveAttribute('href', drop.cta.href[locale])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('desktop: first 4 drop cards are wider than cards 5+', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(PATH_EN)
|
||||
const cards = dropsSection(page, 'en').locator('[data-slot="card"]')
|
||||
await expect(cards).toHaveCount(drops.length)
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const firstWidth = (await cards.nth(0).boundingBox())?.width ?? 0
|
||||
const fifthWidth = (await cards.nth(4).boundingBox())?.width ?? 0
|
||||
return firstWidth - fifthWidth
|
||||
})
|
||||
.toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Launches landing — mobile @mobile', () => {
|
||||
test('drops grid stacks in a single column at mobile width', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(PATH_EN)
|
||||
const cards = dropsSection(page, 'en').locator('[data-slot="card"]')
|
||||
await expect(cards).toHaveCount(drops.length)
|
||||
|
||||
const viewport = page.viewportSize()
|
||||
expect(viewport, 'viewport size').not.toBeNull()
|
||||
|
||||
await expect
|
||||
.poll(async () => (await cards.nth(0).boundingBox())?.width ?? 0)
|
||||
.toBeGreaterThanOrEqual(viewport!.width * 0.7)
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const firstBox = await cards.nth(0).boundingBox()
|
||||
const secondBox = await cards.nth(1).boundingBox()
|
||||
if (!firstBox || !secondBox) return false
|
||||
return secondBox.y >= firstBox.y + firstBox.height
|
||||
})
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('closing CTA heading stays within viewport width', async ({ page }) => {
|
||||
await page.goto(PATH_EN)
|
||||
const heading = page.getByRole('heading', {
|
||||
level: 2,
|
||||
name: t('launches.cta.heading', 'en')
|
||||
})
|
||||
await heading.scrollIntoViewIfNeeded()
|
||||
await expect(heading).toBeVisible()
|
||||
|
||||
const box = await heading.boundingBox()
|
||||
expect(box, 'CTA heading bounding box').not.toBeNull()
|
||||
const viewport = page.viewportSize()
|
||||
expect(viewport, 'viewport size').not.toBeNull()
|
||||
expect(box!.x + box!.width).toBeLessThanOrEqual(viewport!.width + 1)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
@@ -7,41 +8,40 @@ test.describe('Pricing page @smoke', () => {
|
||||
await page.goto('/cloud/pricing')
|
||||
})
|
||||
|
||||
test('shows the three paid tiers and Enterprise', async ({ page }) => {
|
||||
const pricingGrid = page
|
||||
.locator('section', {
|
||||
has: page.getByRole('heading', { name: /Pricing/i })
|
||||
})
|
||||
.locator('.lg\\:grid')
|
||||
const pricingSection = (page: Page) =>
|
||||
page.locator('section').filter({
|
||||
has: page.getByRole('heading', { name: /Choose a plan/i })
|
||||
})
|
||||
|
||||
for (const label of ['STANDARD', 'CREATOR', 'PRO']) {
|
||||
test('shows the three paid tiers and Enterprise', async ({ page }) => {
|
||||
const section = pricingSection(page)
|
||||
|
||||
for (const label of ['STANDARD', 'CREATOR', 'PRO', 'ENTERPRISE']) {
|
||||
await expect(
|
||||
pricingGrid.locator('span', { hasText: new RegExp(`^${label}$`) })
|
||||
section.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 pricingGrid = page
|
||||
.locator('section', {
|
||||
has: page.getByRole('heading', { name: /Pricing/i })
|
||||
})
|
||||
.locator('.lg\\:grid')
|
||||
const section = pricingSection(page)
|
||||
|
||||
await expect(
|
||||
pricingGrid.locator('span', { hasText: /^FREE$/ })
|
||||
).toHaveCount(0)
|
||||
await expect(section.locator('span', { hasText: /^FREE$/ })).toHaveCount(0)
|
||||
await expect(page.getByRole('link', { name: /^START FREE$/ })).toHaveCount(
|
||||
0
|
||||
)
|
||||
await expect(page.getByText(/Everything in Free, plus:/i)).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('stays in standard (non-education) mode', async ({ page }) => {
|
||||
await expect(page.getByText(/Yearly \(Up to 20% off\)/)).toBeVisible()
|
||||
await expect(page.getByText(/Yearly \(Up to 25% off\)/)).toHaveCount(0)
|
||||
await expect(page.getByText(/Educational savings/i)).toHaveCount(0)
|
||||
await expect(page.getByText(/Creative Campus/i)).toHaveCount(0)
|
||||
await expect(page.getByText(/Student Ambassador/i)).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Cloud pricing teaser @smoke', () => {
|
||||
|
||||
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 79 KiB |
@@ -40,6 +40,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "catalog:",
|
||||
"@astrojs/mdx": "catalog:",
|
||||
"@astrojs/vue": "catalog:",
|
||||
"@playwright/test": "catalog:",
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
@@ -48,6 +49,7 @@
|
||||
"tsx": "catalog:",
|
||||
"tw-animate-css": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
"vitest": "catalog:",
|
||||
"vue-component-type-helpers": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
apps/website/public/images/mcp/mcp-thumb-asphalt.webp
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
apps/website/public/images/mcp/mcp-thumb-concepts.webp
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
apps/website/public/images/mcp/mcp-thumb-kaiju.webp
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
apps/website/public/images/mcp/mcp-thumb-keyart.webp
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
apps/website/public/images/mcp/mcp-thumb-moodboard.webp
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
@@ -29,6 +29,5 @@ Allow: /
|
||||
Disallow: /_astro/
|
||||
Disallow: /_website/
|
||||
Disallow: /_vercel/
|
||||
Disallow: /payment/
|
||||
|
||||
Sitemap: https://comfy.org/sitemap-index.xml
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
import type { AnchorHTMLAttributes } from 'vue'
|
||||
|
||||
import Button from '../ui/button/Button.vue'
|
||||
import { resolveRel } from '../../utils/cta'
|
||||
|
||||
type Cta = {
|
||||
label: string
|
||||
href: string
|
||||
target?: '_blank' | '_self' | '_parent' | '_top'
|
||||
target?: AnchorHTMLAttributes['target']
|
||||
rel?: AnchorHTMLAttributes['rel']
|
||||
}
|
||||
|
||||
type TermsLink = {
|
||||
@@ -12,10 +16,11 @@ type TermsLink = {
|
||||
href: string
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
const { heading, primaryCta, secondaryCta, termsLink } = defineProps<{
|
||||
heading: string
|
||||
primaryCta: Cta
|
||||
termsLink: TermsLink
|
||||
secondaryCta?: Cta
|
||||
termsLink?: TermsLink
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -24,23 +29,37 @@ defineProps<{
|
||||
class="max-w-9xl mx-auto flex flex-col items-center px-6 py-16 text-center lg:py-24"
|
||||
>
|
||||
<h2
|
||||
class="text-4xl font-light tracking-tight text-primary-comfy-canvas lg:text-6xl"
|
||||
class="max-w-3xl text-4xl/snug font-light tracking-tight text-pretty text-primary-comfy-canvas lg:text-6xl/snug"
|
||||
>
|
||||
{{ heading }}
|
||||
</h2>
|
||||
|
||||
<BrandButton
|
||||
:href="primaryCta.href"
|
||||
:target="primaryCta.target"
|
||||
:rel="primaryCta.target === '_blank' ? 'noopener noreferrer' : undefined"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
class="mt-10 px-20 py-4 text-base uppercase lg:mt-12"
|
||||
>
|
||||
{{ primaryCta.label }}
|
||||
</BrandButton>
|
||||
<div class="mt-10 flex flex-col gap-4 sm:flex-row lg:mt-12">
|
||||
<Button
|
||||
as="a"
|
||||
:href="primaryCta.href"
|
||||
:target="primaryCta.target"
|
||||
:rel="resolveRel(primaryCta)"
|
||||
variant="default"
|
||||
size="lg"
|
||||
>
|
||||
{{ primaryCta.label }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="secondaryCta"
|
||||
as="a"
|
||||
:href="secondaryCta.href"
|
||||
:target="secondaryCta.target"
|
||||
:rel="resolveRel(secondaryCta)"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
>
|
||||
{{ secondaryCta.label }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<a
|
||||
v-if="termsLink"
|
||||
:href="termsLink.href"
|
||||
class="mt-8 text-sm text-primary-comfy-canvas/70 underline underline-offset-4 transition-colors hover:text-primary-comfy-canvas"
|
||||
>
|
||||
|
||||
@@ -1,32 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { reactive, watch } from 'vue'
|
||||
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'
|
||||
|
||||
type Faq = { id: string; question: string; answer: string }
|
||||
|
||||
const { faqs } = defineProps<{
|
||||
defineProps<{
|
||||
id?: string
|
||||
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>
|
||||
<section class="max-w-9xl mx-auto px-4 py-24 md:px-20 md:py-40">
|
||||
<section :id class="max-w-9xl mx-auto px-4 py-16 lg:px-20 lg:py-24">
|
||||
<div class="flex flex-col gap-6 md:flex-row md:gap-16">
|
||||
<!-- Left heading -->
|
||||
<div
|
||||
@@ -38,57 +26,23 @@ function toggle(index: number) {
|
||||
</div>
|
||||
|
||||
<!-- Right FAQ list -->
|
||||
<div class="flex-1">
|
||||
<div
|
||||
<Accordion type="multiple" class="flex-1">
|
||||
<AccordionItem
|
||||
v-for="(faq, index) in faqs"
|
||||
:key="faq.id"
|
||||
class="border-b border-primary-comfy-canvas/20"
|
||||
:value="faq.id"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
120
apps/website/src/components/blocks/FeatureGrid01.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import type { Component } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import CopyableField from '@/components/ui/copyable-field/CopyableField.vue'
|
||||
|
||||
import SectionHeader from '../common/SectionHeader.vue'
|
||||
|
||||
type CardAction =
|
||||
| {
|
||||
type: 'link'
|
||||
label: string
|
||||
href: string
|
||||
target?: '_blank'
|
||||
icon?: Component
|
||||
variant?: 'default' | 'outline'
|
||||
}
|
||||
| { type: 'code'; value: string }
|
||||
|
||||
export interface FeatureCard {
|
||||
id: string
|
||||
label?: string
|
||||
title: string
|
||||
description: string
|
||||
action?: CardAction
|
||||
}
|
||||
|
||||
type ColumnCount = 2 | 3 | 4
|
||||
|
||||
const {
|
||||
cards,
|
||||
columns = 3,
|
||||
copiedLabel,
|
||||
copyLabel,
|
||||
eyebrow,
|
||||
heading,
|
||||
subtitle
|
||||
} = defineProps<{
|
||||
cards: readonly FeatureCard[]
|
||||
columns?: ColumnCount
|
||||
copiedLabel?: string
|
||||
copyLabel?: string
|
||||
eyebrow?: string
|
||||
heading: string
|
||||
subtitle?: string
|
||||
}>()
|
||||
|
||||
const columnClass: Record<ColumnCount, string> = {
|
||||
2: 'lg:grid-cols-2',
|
||||
3: 'lg:grid-cols-3',
|
||||
4: 'lg:grid-cols-4'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
|
||||
<SectionHeader :label="eyebrow" align="start">
|
||||
{{ heading }}
|
||||
<template v-if="subtitle" #subtitle>
|
||||
<p class="mt-4 max-w-xl text-sm text-smoke-700 lg:text-base">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</template>
|
||||
</SectionHeader>
|
||||
|
||||
<div :class="cn('mt-16 grid grid-cols-1 gap-6', columnClass[columns])">
|
||||
<div
|
||||
v-for="card in cards"
|
||||
:key="card.id"
|
||||
class="bg-transparency-white-t4 flex flex-col rounded-3xl p-6 lg:p-8"
|
||||
>
|
||||
<p
|
||||
v-if="card.label"
|
||||
class="text-primary-comfy-yellow text-xs font-bold tracking-widest uppercase"
|
||||
>
|
||||
{{ card.label }}
|
||||
</p>
|
||||
<h3
|
||||
:class="
|
||||
cn(
|
||||
'text-xl font-light text-primary-comfy-canvas lg:text-2xl',
|
||||
card.label && 'mt-3'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ card.title }}
|
||||
</h3>
|
||||
<p class="mt-3 text-sm text-smoke-700">
|
||||
{{ card.description }}
|
||||
</p>
|
||||
|
||||
<div v-if="card.action" class="mt-6">
|
||||
<Button
|
||||
v-if="card.action.type === 'link'"
|
||||
as="a"
|
||||
:href="card.action.href"
|
||||
:target="card.action.target"
|
||||
:rel="
|
||||
card.action.target === '_blank'
|
||||
? 'noopener noreferrer'
|
||||
: undefined
|
||||
"
|
||||
:variant="card.action.variant ?? 'outline'"
|
||||
:append-icon="card.action.icon"
|
||||
>
|
||||
{{ card.action.label }}
|
||||
</Button>
|
||||
<CopyableField
|
||||
v-else
|
||||
:value="card.action.value"
|
||||
:copy-label="copyLabel"
|
||||
:copied-label="copiedLabel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
100
apps/website/src/components/blocks/FeatureGrid02.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
import SectionHeader from '../common/SectionHeader.vue'
|
||||
import NodeUnionIcon from '../icons/NodeUnionIcon.vue'
|
||||
|
||||
type Cta = { label: string; href: string; target?: '_blank' }
|
||||
|
||||
export interface FeatureStep {
|
||||
id: string
|
||||
number: string
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
heading: string
|
||||
steps: readonly FeatureStep[]
|
||||
primaryCta?: Cta
|
||||
secondaryCta?: Cta
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
|
||||
<SectionHeader>{{ heading }}</SectionHeader>
|
||||
|
||||
<!-- Step cards in a row, joined by node-union connectors on desktop -->
|
||||
<div
|
||||
class="mt-12 flex flex-col gap-4 lg:flex-row lg:items-stretch lg:gap-0"
|
||||
>
|
||||
<template v-for="(step, i) in steps" :key="step.id">
|
||||
<div
|
||||
v-if="i > 0"
|
||||
class="relative z-10 -mx-px hidden shrink-0 items-center justify-center self-stretch lg:flex"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<NodeUnionIcon
|
||||
class="text-primary-comfy-yellow size-4 scale-x-150 rotate-90"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="border-primary-comfy-yellow flex flex-1 flex-col rounded-[40px] border-2 bg-primary-comfy-ink p-2"
|
||||
>
|
||||
<div class="flex flex-1 flex-col gap-4 p-8">
|
||||
<div>
|
||||
<p
|
||||
class="text-primary-comfy-yellow text-xs font-bold tracking-widest uppercase"
|
||||
>
|
||||
{{ step.number }}
|
||||
</p>
|
||||
<h3
|
||||
class="mt-1 text-2xl font-medium tracking-widest text-primary-comfy-canvas uppercase"
|
||||
>
|
||||
{{ step.title }}
|
||||
</h3>
|
||||
</div>
|
||||
<p class="text-primary-comfy-canvas">
|
||||
{{ step.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="primaryCta || secondaryCta"
|
||||
class="mt-12 flex flex-col items-center gap-4 lg:flex-row lg:justify-center"
|
||||
>
|
||||
<Button
|
||||
v-if="primaryCta"
|
||||
as="a"
|
||||
:href="primaryCta.href"
|
||||
:target="primaryCta.target"
|
||||
:rel="
|
||||
primaryCta.target === '_blank' ? 'noopener noreferrer' : undefined
|
||||
"
|
||||
size="lg"
|
||||
class="w-full lg:w-auto lg:min-w-48"
|
||||
>
|
||||
{{ primaryCta.label }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="secondaryCta"
|
||||
as="a"
|
||||
:href="secondaryCta.href"
|
||||
:target="secondaryCta.target"
|
||||
:rel="
|
||||
secondaryCta.target === '_blank' ? 'noopener noreferrer' : undefined
|
||||
"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
class="w-full lg:w-auto lg:min-w-48"
|
||||
>
|
||||
{{ secondaryCta.label }}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
108
apps/website/src/components/blocks/FeatureRows01.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import GlassCard from '../common/GlassCard.vue'
|
||||
import SectionHeader from '../common/SectionHeader.vue'
|
||||
import VideoPlayer from '../common/VideoPlayer.vue'
|
||||
import type { VideoTrack } from '../common/VideoPlayer.vue'
|
||||
|
||||
type RowMedia =
|
||||
| { type: 'image'; src: string; alt?: string }
|
||||
| {
|
||||
type: 'video'
|
||||
src: string
|
||||
// <video> has no native alt; used as the player's accessible label.
|
||||
alt?: string
|
||||
poster?: string
|
||||
tracks?: readonly VideoTrack[]
|
||||
autoplay?: boolean
|
||||
loop?: boolean
|
||||
minimal?: boolean
|
||||
hideControls?: boolean
|
||||
}
|
||||
|
||||
export interface FeatureRow {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
media: RowMedia
|
||||
}
|
||||
|
||||
const {
|
||||
heading,
|
||||
eyebrow,
|
||||
locale = 'en',
|
||||
rows
|
||||
} = defineProps<{
|
||||
heading: string
|
||||
eyebrow?: string
|
||||
locale?: Locale
|
||||
rows: readonly FeatureRow[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
|
||||
<SectionHeader :label="eyebrow" max-width="xl">
|
||||
{{ heading }}
|
||||
</SectionHeader>
|
||||
|
||||
<div class="mt-16 flex flex-col gap-4 lg:gap-6">
|
||||
<GlassCard
|
||||
v-for="(row, i) in rows"
|
||||
:key="row.id"
|
||||
class="flex flex-col gap-8 lg:flex-row lg:items-stretch lg:gap-0"
|
||||
>
|
||||
<!-- Text -->
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'order-2 flex flex-col justify-center gap-4 p-6 lg:w-1/2 lg:p-12',
|
||||
i % 2 === 0 ? 'lg:order-1' : 'lg:order-2'
|
||||
)
|
||||
"
|
||||
>
|
||||
<h3 class="text-2xl font-light text-primary-comfy-canvas lg:text-3xl">
|
||||
{{ row.title }}
|
||||
</h3>
|
||||
<p class="text-sm text-smoke-700 lg:text-base">
|
||||
{{ row.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Media: image or video -->
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'order-1 flex lg:w-1/2',
|
||||
i % 2 === 0 ? 'lg:order-2' : 'lg:order-1'
|
||||
)
|
||||
"
|
||||
>
|
||||
<img
|
||||
v-if="row.media.type === 'image'"
|
||||
:src="row.media.src"
|
||||
:alt="row.media.alt ?? row.title"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="aspect-4/3 w-full rounded-4xl object-cover"
|
||||
/>
|
||||
<VideoPlayer
|
||||
v-else
|
||||
:locale="locale"
|
||||
:aria-label="row.media.alt ?? row.title"
|
||||
:src="row.media.src"
|
||||
:poster="row.media.poster"
|
||||
:tracks="row.media.tracks"
|
||||
:autoplay="row.media.autoplay"
|
||||
:loop="row.media.loop"
|
||||
:minimal="row.media.minimal"
|
||||
:hide-controls="row.media.hideControls"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
109
apps/website/src/components/blocks/HeroBackdrop01.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import ProductHeroBadge from '../common/ProductHeroBadge.vue'
|
||||
|
||||
type Backdrop =
|
||||
| { type: 'image'; src: string; alt?: string }
|
||||
| { type: 'video'; src: string; poster?: string; alt?: string }
|
||||
|
||||
const {
|
||||
backdrop,
|
||||
badgeText,
|
||||
badgeLogoSrc,
|
||||
badgeLogoAlt,
|
||||
title,
|
||||
subtitle,
|
||||
footnote,
|
||||
class: className
|
||||
} = defineProps<{
|
||||
backdrop?: Backdrop
|
||||
badgeText?: string
|
||||
badgeLogoSrc?: string
|
||||
badgeLogoAlt?: string
|
||||
title: string
|
||||
subtitle?: string
|
||||
footnote?: string
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const scrimShape = 'farthest-side at 50% 50%'
|
||||
const scrimStyle = {
|
||||
background: `radial-gradient(${scrimShape}, color-mix(in srgb, var(--color-primary-warm-white) 80%, transparent) 0%, transparent 80%)`,
|
||||
maskImage: `radial-gradient(${scrimShape}, #000 45%, transparent 90%)`,
|
||||
WebkitMaskImage: `radial-gradient(${scrimShape}, #000 45%, transparent 90%)`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
:class="cn('max-w-9xl mx-auto px-4 pt-4 lg:px-6 lg:pt-6', className)"
|
||||
>
|
||||
<div class="relative overflow-hidden rounded-3xl">
|
||||
<slot name="backdrop">
|
||||
<video
|
||||
v-if="backdrop?.type === 'video'"
|
||||
:src="backdrop.src"
|
||||
:poster="backdrop.poster"
|
||||
:aria-label="backdrop.alt"
|
||||
autoplay
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
preload="metadata"
|
||||
class="absolute inset-0 size-full object-cover"
|
||||
/>
|
||||
<img
|
||||
v-else-if="backdrop?.type === 'image'"
|
||||
:src="backdrop.src"
|
||||
:alt="backdrop.alt ?? ''"
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
class="absolute inset-0 size-full object-cover"
|
||||
/>
|
||||
</slot>
|
||||
|
||||
<div
|
||||
class="relative flex min-h-[70svh] flex-col justify-center px-8 py-16 lg:min-h-176 lg:px-16 lg:py-24"
|
||||
>
|
||||
<div class="relative w-full max-w-xl">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute -inset-12 backdrop-blur-md lg:-inset-16"
|
||||
:style="scrimStyle"
|
||||
/>
|
||||
|
||||
<div class="relative">
|
||||
<ProductHeroBadge
|
||||
v-if="badgeText"
|
||||
:text="badgeText"
|
||||
:logo-src="badgeLogoSrc"
|
||||
:logo-alt="badgeLogoAlt"
|
||||
/>
|
||||
|
||||
<h1
|
||||
class="mt-10 text-4xl/tight font-light tracking-tight whitespace-pre-line text-primary-comfy-ink lg:text-6xl/tight"
|
||||
>
|
||||
{{ title }}
|
||||
</h1>
|
||||
|
||||
<p
|
||||
v-if="subtitle"
|
||||
class="mt-8 max-w-md text-base text-primary-comfy-ink lg:text-lg"
|
||||
>
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
|
||||
<p v-if="footnote" class="mt-10 text-sm text-primary-comfy-ink">
|
||||
{{ footnote }}
|
||||
</p>
|
||||
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
166
apps/website/src/components/blocks/HeroLivestream01.vue
Normal file
@@ -0,0 +1,166 @@
|
||||
<script setup lang="ts">
|
||||
import type { AnchorHTMLAttributes } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useNow } from '@vueuse/core'
|
||||
|
||||
import Button from '../ui/button/Button.vue'
|
||||
import { resolveRel } from '../../utils/cta'
|
||||
|
||||
type Cta = {
|
||||
label: string
|
||||
href: string
|
||||
target?: AnchorHTMLAttributes['target']
|
||||
rel?: AnchorHTMLAttributes['rel']
|
||||
}
|
||||
|
||||
type Visual =
|
||||
| {
|
||||
type: 'image'
|
||||
src: string
|
||||
alt: string
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
| {
|
||||
type: 'video'
|
||||
src: string
|
||||
alt: string
|
||||
poster?: string
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
|
||||
const {
|
||||
visual,
|
||||
eyebrow,
|
||||
title,
|
||||
subtitle,
|
||||
primaryCta,
|
||||
secondaryCta,
|
||||
youtubeVideoId,
|
||||
startDateTime,
|
||||
endDateTime
|
||||
} = defineProps<{
|
||||
visual?: Visual
|
||||
eyebrow?: string
|
||||
title: string
|
||||
subtitle?: string
|
||||
primaryCta: Cta
|
||||
secondaryCta?: Cta
|
||||
youtubeVideoId: string
|
||||
startDateTime: string
|
||||
endDateTime: string
|
||||
}>()
|
||||
|
||||
const embedUrl = computed(
|
||||
() =>
|
||||
`https://www.youtube-nocookie.com/embed/${youtubeVideoId}?autoplay=1&mute=1&rel=0`
|
||||
)
|
||||
|
||||
// Keep SSR/initial paint deterministic on the logo and only flip to the embed
|
||||
// after client hydration — avoids a build-time `now` leaking into the markup.
|
||||
const mounted = ref(false)
|
||||
onMounted(() => {
|
||||
mounted.value = true
|
||||
})
|
||||
|
||||
const now = useNow({ interval: 30_000 })
|
||||
const startMs = computed(() => new Date(startDateTime).getTime())
|
||||
const endMs = computed(() => new Date(endDateTime).getTime())
|
||||
|
||||
const isLive = computed(
|
||||
() =>
|
||||
mounted.value &&
|
||||
now.value.getTime() >= startMs.value &&
|
||||
now.value.getTime() < endMs.value
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="max-w-9xl mx-auto flex flex-col items-center px-6 py-16 text-center lg:py-24"
|
||||
>
|
||||
<div
|
||||
v-if="isLive"
|
||||
class="mb-10 aspect-video w-full overflow-hidden rounded-2xl lg:mb-12"
|
||||
>
|
||||
<iframe
|
||||
:src="embedUrl"
|
||||
:title="title"
|
||||
class="size-full"
|
||||
loading="lazy"
|
||||
allow="autoplay; encrypted-media; picture-in-picture"
|
||||
allowfullscreen
|
||||
/>
|
||||
</div>
|
||||
<slot v-else name="visual">
|
||||
<img
|
||||
v-if="visual?.type === 'image'"
|
||||
:src="visual.src"
|
||||
:alt="visual.alt"
|
||||
:width="visual.width"
|
||||
:height="visual.height"
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
class="mb-10 h-auto w-full max-w-md lg:mb-12 lg:max-w-lg"
|
||||
/>
|
||||
<video
|
||||
v-else-if="visual?.type === 'video'"
|
||||
:src="visual.src"
|
||||
:poster="visual.poster"
|
||||
:aria-label="visual.alt"
|
||||
:width="visual.width"
|
||||
:height="visual.height"
|
||||
autoplay
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
preload="metadata"
|
||||
class="mb-10 h-auto w-full max-w-md lg:mb-12 lg:max-w-2xl"
|
||||
/>
|
||||
</slot>
|
||||
|
||||
<p
|
||||
v-if="eyebrow"
|
||||
class="mb-4 text-sm font-medium tracking-wide text-primary-comfy-canvas/70 uppercase"
|
||||
>
|
||||
{{ eyebrow }}
|
||||
</p>
|
||||
|
||||
<h1
|
||||
class="max-w-3xl text-4xl/snug font-light tracking-tight text-pretty text-primary-comfy-canvas lg:text-6xl/snug"
|
||||
>
|
||||
{{ title }}
|
||||
</h1>
|
||||
|
||||
<p
|
||||
v-if="subtitle"
|
||||
class="mt-6 max-w-2xl text-base text-primary-comfy-canvas/70 lg:text-lg"
|
||||
>
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
|
||||
<div class="mt-10 flex flex-col gap-4 sm:flex-row lg:mt-12">
|
||||
<Button
|
||||
as="a"
|
||||
:href="primaryCta.href"
|
||||
:target="primaryCta.target"
|
||||
:rel="resolveRel(primaryCta)"
|
||||
size="lg"
|
||||
>
|
||||
{{ primaryCta.label }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="secondaryCta"
|
||||
as="a"
|
||||
:href="secondaryCta.href"
|
||||
:target="secondaryCta.target"
|
||||
:rel="resolveRel(secondaryCta)"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
>
|
||||
{{ secondaryCta.label }}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
import ProductHeroBadge from '../common/ProductHeroBadge.vue'
|
||||
@@ -27,6 +29,7 @@ const {
|
||||
badgeLogoAlt,
|
||||
title,
|
||||
titleHighlight,
|
||||
subtitle,
|
||||
features = [],
|
||||
primaryCta,
|
||||
secondaryCta,
|
||||
@@ -41,14 +44,17 @@ const {
|
||||
videoAutoplay = false,
|
||||
videoLoop = false,
|
||||
videoMinimal = false,
|
||||
videoHideControls = false
|
||||
videoHideControls = false,
|
||||
class: className
|
||||
} = defineProps<{
|
||||
locale?: Locale
|
||||
class?: HTMLAttributes['class']
|
||||
badgeText: string
|
||||
badgeLogoSrc?: string
|
||||
badgeLogoAlt?: string
|
||||
title: string
|
||||
titleHighlight?: string
|
||||
subtitle?: string
|
||||
features?: string[]
|
||||
primaryCta: Cta
|
||||
secondaryCta?: Cta
|
||||
@@ -72,7 +78,8 @@ const {
|
||||
:class="
|
||||
cn(
|
||||
'max-w-9xl relative mx-auto flex flex-col items-center gap-12 px-6 pt-20 pb-16 md:pt-28 md:pb-24 lg:items-center lg:gap-16 lg:px-16',
|
||||
imagePosition === 'right' ? 'lg:flex-row' : 'lg:flex-row-reverse'
|
||||
imagePosition === 'right' ? 'lg:flex-row' : 'lg:flex-row-reverse',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
@@ -84,7 +91,7 @@ const {
|
||||
/>
|
||||
|
||||
<h1
|
||||
class="mt-8 text-2xl leading-[125%] font-light tracking-[-1.44px] text-primary-comfy-canvas md:text-4xl lg:text-5xl"
|
||||
class="mt-8 text-2xl leading-[125%] font-light tracking-[-1.44px] whitespace-pre-line text-primary-comfy-canvas md:text-4xl lg:text-5xl"
|
||||
>
|
||||
<template v-if="titleHighlight">
|
||||
<span class="text-primary-warm-white">{{ titleHighlight }}</span>
|
||||
@@ -93,6 +100,13 @@ const {
|
||||
<template v-else>{{ title }}</template>
|
||||
</h1>
|
||||
|
||||
<p
|
||||
v-if="subtitle"
|
||||
class="mt-6 max-w-xl text-base text-primary-comfy-canvas/80"
|
||||
>
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
|
||||
<ul v-if="features.length" class="mt-8 space-y-3">
|
||||
<li
|
||||
v-for="feature in features"
|
||||
@@ -127,27 +141,29 @@ const {
|
||||
</div>
|
||||
|
||||
<div class="order-first w-full lg:order-last lg:flex-1">
|
||||
<VideoPlayer
|
||||
v-if="videoSrc"
|
||||
:locale
|
||||
:src="videoSrc"
|
||||
:poster="videoPoster"
|
||||
:tracks="videoTracks"
|
||||
:autoplay="videoAutoplay"
|
||||
:loop="videoLoop"
|
||||
:minimal="videoMinimal"
|
||||
:hide-controls="videoHideControls"
|
||||
/>
|
||||
<img
|
||||
v-else-if="imageSrc"
|
||||
:src="imageSrc"
|
||||
:alt="imageAlt"
|
||||
:width="imageWidth"
|
||||
:height="imageHeight"
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
class="aspect-4/3 w-full rounded-3xl object-cover"
|
||||
/>
|
||||
<slot name="media">
|
||||
<VideoPlayer
|
||||
v-if="videoSrc"
|
||||
:locale
|
||||
:src="videoSrc"
|
||||
:poster="videoPoster"
|
||||
:tracks="videoTracks"
|
||||
:autoplay="videoAutoplay"
|
||||
:loop="videoLoop"
|
||||
:minimal="videoMinimal"
|
||||
:hide-controls="videoHideControls"
|
||||
/>
|
||||
<img
|
||||
v-else-if="imageSrc"
|
||||
:src="imageSrc"
|
||||
:alt="imageAlt"
|
||||
:width="imageWidth"
|
||||
:height="imageHeight"
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
class="aspect-4/3 w-full rounded-3xl object-cover"
|
||||
/>
|
||||
</slot>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
59
apps/website/src/components/blocks/ReasonsSplit01.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
export interface Reason {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
const { highlightClass = 'text-white' } = defineProps<{
|
||||
heading: string
|
||||
headingHighlight?: string
|
||||
highlightClass?: string
|
||||
subtitle?: string
|
||||
reasons: readonly Reason[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="max-w-9xl mx-auto flex flex-col gap-4 px-6 py-16 lg:flex-row lg:gap-16 lg:py-24"
|
||||
>
|
||||
<!-- Left heading -->
|
||||
<div
|
||||
class="sticky top-20 z-10 w-full shrink-0 self-start bg-primary-comfy-ink py-4 lg:top-28 lg:w-115 lg:py-0"
|
||||
>
|
||||
<h2
|
||||
class="text-4xl/16 font-light whitespace-pre-line text-primary-comfy-canvas lg:text-5xl/16"
|
||||
>
|
||||
{{ heading
|
||||
}}<span v-if="headingHighlight" :class="highlightClass">{{
|
||||
headingHighlight
|
||||
}}</span>
|
||||
</h2>
|
||||
<p v-if="subtitle" class="mt-6 text-sm text-primary-comfy-canvas/70">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Right reasons list -->
|
||||
<div class="flex-1">
|
||||
<div
|
||||
v-for="reason in reasons"
|
||||
:key="reason.id"
|
||||
class="flex flex-col gap-4 border-b border-primary-comfy-canvas/20 py-10 first:pt-0 lg:gap-12 xl:flex-row"
|
||||
>
|
||||
<div class="shrink-0 xl:w-84">
|
||||
<h3
|
||||
class="text-2xl font-light whitespace-pre-line text-primary-comfy-canvas"
|
||||
>
|
||||
{{ reason.title }}
|
||||
</h3>
|
||||
<slot name="reason-extra" :reason="reason" />
|
||||
</div>
|
||||
<p class="flex-1 text-sm text-primary-comfy-canvas/70">
|
||||
{{ reason.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
91
apps/website/src/components/blocks/StepsSplit01.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import GlassCard from '../common/GlassCard.vue'
|
||||
import SectionHeader from '../common/SectionHeader.vue'
|
||||
|
||||
type Step = { id: string; title: string; description: string }
|
||||
|
||||
type Media =
|
||||
| { type: 'image'; src: string; alt?: string }
|
||||
| { type: 'video'; src: string; poster?: string; alt?: string }
|
||||
|
||||
const {
|
||||
steps,
|
||||
media,
|
||||
heading,
|
||||
mediaPosition = 'right',
|
||||
class: className
|
||||
} = defineProps<{
|
||||
steps: readonly Step[]
|
||||
media?: Media
|
||||
heading?: string
|
||||
mediaPosition?: 'left' | 'right'
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
function stepNumber(index: number) {
|
||||
return String(index + 1).padStart(2, '0')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section :class="cn('max-w-9xl mx-auto px-6 py-16 lg:py-24', className)">
|
||||
<SectionHeader v-if="heading" class="mb-12 lg:mb-16">
|
||||
{{ heading }}
|
||||
</SectionHeader>
|
||||
|
||||
<GlassCard>
|
||||
<div class="grid grid-cols-1 items-stretch gap-4 lg:grid-cols-2 lg:gap-8">
|
||||
<ol class="flex flex-col gap-6 px-6 py-8 lg:px-10 lg:py-14">
|
||||
<li v-for="(step, index) in steps" :key="step.id">
|
||||
<p
|
||||
class="font-formula-narrow text-primary-comfy-yellow text-sm font-bold tracking-wide uppercase lg:text-base"
|
||||
>
|
||||
{{ stepNumber(index) }} {{ step.title }}
|
||||
</p>
|
||||
<p
|
||||
class="mt-2 text-sm/relaxed text-primary-comfy-canvas lg:text-base"
|
||||
>
|
||||
{{ step.description }}
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div
|
||||
v-if="media || $slots.media"
|
||||
:class="
|
||||
cn(
|
||||
'relative aspect-video overflow-hidden rounded-4xl lg:aspect-auto',
|
||||
mediaPosition === 'left' && 'lg:order-first'
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot name="media">
|
||||
<video
|
||||
v-if="media?.type === 'video'"
|
||||
:src="media.src"
|
||||
:poster="media.poster"
|
||||
:aria-label="media.alt"
|
||||
autoplay
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
preload="metadata"
|
||||
class="absolute inset-0 size-full object-cover"
|
||||
/>
|
||||
<img
|
||||
v-else-if="media?.type === 'image'"
|
||||
:src="media.src"
|
||||
:alt="media.alt ?? ''"
|
||||
decoding="async"
|
||||
class="absolute inset-0 size-full object-cover"
|
||||
/>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</section>
|
||||
</template>
|
||||
@@ -6,6 +6,7 @@ import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import type { BrandButtonVariants } from './brandButton.variants'
|
||||
import { brandButtonVariants } from './brandButton.variants'
|
||||
import { resolveRel } from '../../utils/cta'
|
||||
|
||||
const props = defineProps<{
|
||||
href?: string
|
||||
@@ -16,9 +17,8 @@ const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const resolvedRel = computed(
|
||||
() =>
|
||||
props.rel ?? (props.target === '_blank' ? 'noopener noreferrer' : undefined)
|
||||
const resolvedRel = computed(() =>
|
||||
resolveRel({ rel: props.rel, target: props.target })
|
||||
)
|
||||
</script>
|
||||
|
||||
|
||||
@@ -10,12 +10,14 @@ const {
|
||||
locale = 'en',
|
||||
headingKey,
|
||||
faqPrefix,
|
||||
faqCount
|
||||
faqCount,
|
||||
footerKey
|
||||
} = defineProps<{
|
||||
locale?: Locale
|
||||
headingKey: TranslationKey
|
||||
faqPrefix: string
|
||||
faqCount: number
|
||||
footerKey?: TranslationKey
|
||||
}>()
|
||||
|
||||
const faqKeys: Array<{ q: TranslationKey; a: TranslationKey }> = Array.from(
|
||||
@@ -45,9 +47,9 @@ function toggle(index: number) {
|
||||
<div class="flex flex-col gap-6 md:flex-row md:gap-16">
|
||||
<!-- Left heading -->
|
||||
<div
|
||||
class="bg-primary-comfy-ink sticky top-20 z-10 w-full shrink-0 self-start py-4 md:top-28 md:w-80 md:py-0"
|
||||
class="sticky top-20 z-10 w-full shrink-0 self-start bg-primary-comfy-ink py-4 md:top-28 md:w-80 md:py-0"
|
||||
>
|
||||
<h2 class="text-primary-comfy-canvas text-4xl font-light md:text-5xl">
|
||||
<h2 class="text-4xl font-light text-primary-comfy-canvas md:text-5xl">
|
||||
{{ t(headingKey, locale) }}
|
||||
</h2>
|
||||
</div>
|
||||
@@ -57,7 +59,7 @@ function toggle(index: number) {
|
||||
<div
|
||||
v-for="(faq, index) in faqs"
|
||||
:key="index"
|
||||
class="border-primary-comfy-canvas/20 border-b"
|
||||
class="border-b border-primary-comfy-canvas/20"
|
||||
>
|
||||
<button
|
||||
:id="`faq-trigger-${index}`"
|
||||
@@ -98,11 +100,18 @@ function toggle(index: number) {
|
||||
:aria-labelledby="`faq-trigger-${index}`"
|
||||
class="pb-6"
|
||||
>
|
||||
<p class="text-primary-comfy-canvas/70 text-sm whitespace-pre-line">
|
||||
{{ faq.answer }}
|
||||
</p>
|
||||
<p
|
||||
class="[&_a]:text-primary-comfy-yellow text-sm whitespace-pre-line text-primary-comfy-canvas/70 [&_a]:underline"
|
||||
v-html="faq.answer"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="footerKey"
|
||||
class="[&_a]:text-primary-comfy-yellow mt-8 text-sm text-primary-comfy-canvas/70 [&_a]:underline"
|
||||
v-html="t(footerKey, locale)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
12
apps/website/src/components/common/JsonLdGraph.astro
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
import type { JsonLdGraph } from '../../utils/customerStoryJsonLd'
|
||||
import { escapeJsonLd } from '../../utils/escapeJsonLd'
|
||||
|
||||
interface Props {
|
||||
graph: JsonLdGraph
|
||||
}
|
||||
|
||||
const { graph } = Astro.props
|
||||
---
|
||||
|
||||
<script is:inline type="application/ld+json" set:html={escapeJsonLd(graph)} />
|
||||
@@ -7,12 +7,14 @@ const {
|
||||
label,
|
||||
headingTag = 'h2',
|
||||
maxWidth = 'lg',
|
||||
headingSize = 'section'
|
||||
headingSize = 'section',
|
||||
align = 'center'
|
||||
} = defineProps<{
|
||||
label?: string
|
||||
headingTag?: 'h1' | 'h2' | 'h3'
|
||||
maxWidth?: 'md' | 'lg' | 'xl'
|
||||
headingSize?: 'section' | 'hero'
|
||||
align?: 'center' | 'start'
|
||||
}>()
|
||||
|
||||
const maxWidthClass = {
|
||||
@@ -28,7 +30,14 @@ const headingSizeClass = {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('mx-auto text-center', maxWidthClass[maxWidth])">
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
maxWidthClass[maxWidth],
|
||||
align === 'center' ? 'mx-auto text-center' : 'text-left'
|
||||
)
|
||||
"
|
||||
>
|
||||
<SectionLabel v-if="label">{{ label }}</SectionLabel>
|
||||
<component
|
||||
:is="headingTag"
|
||||
|
||||
@@ -37,13 +37,15 @@ const topColumns: { title: string; links: FooterLink[] }[] = [
|
||||
{ label: t('nav.comfyLocal', locale), href: routes.download },
|
||||
{ label: t('nav.comfyCloud', locale), href: routes.cloud },
|
||||
{ label: t('nav.comfyApi', locale), href: routes.api },
|
||||
{ label: t('nav.comfyEnterprise', locale), href: routes.cloudEnterprise }
|
||||
{ label: t('nav.comfyEnterprise', locale), href: routes.cloudEnterprise },
|
||||
{ label: t('nav.mcpServer', locale), href: routes.mcp }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t('footer.resources', locale),
|
||||
links: [
|
||||
{ label: t('nav.learning', locale), href: routes.learning },
|
||||
{ label: t('nav.launches', locale), href: routes.launches },
|
||||
{
|
||||
label: t('footer.blog', locale),
|
||||
href: externalLinks.blog,
|
||||
|
||||
100
apps/website/src/components/customers/ArticleNav.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
import { useEventListener, useIntersectionObserver } from '@vueuse/core'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
|
||||
import { prefersReducedMotion } from '../../composables/useReducedMotion'
|
||||
import { scrollTo } from '../../scripts/smoothScroll'
|
||||
import CategoryNav from '../common/CategoryNav.vue'
|
||||
|
||||
type Category = ComponentProps<typeof CategoryNav>['categories'][number]
|
||||
|
||||
const { categories } = defineProps<{
|
||||
categories: Category[]
|
||||
}>()
|
||||
|
||||
const activeSection = ref(categories[0]?.value ?? '')
|
||||
|
||||
const HEADER_OFFSET_PX = -144
|
||||
const BOTTOM_THRESHOLD_PX = 4
|
||||
const SCROLL_SAFETY_MS = 1500
|
||||
|
||||
let isScrolling = false
|
||||
let scrollSafetyTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
function clearScrollLock() {
|
||||
isScrolling = false
|
||||
if (scrollSafetyTimer !== undefined) {
|
||||
clearTimeout(scrollSafetyTimer)
|
||||
scrollSafetyTimer = undefined
|
||||
}
|
||||
}
|
||||
|
||||
function isAtBottom(): boolean {
|
||||
const scrollBottom = window.scrollY + window.innerHeight
|
||||
return (
|
||||
scrollBottom >= document.documentElement.scrollHeight - BOTTOM_THRESHOLD_PX
|
||||
)
|
||||
}
|
||||
|
||||
function activateLastIfAtBottom() {
|
||||
if (isScrolling) return
|
||||
if (!isAtBottom()) return
|
||||
const lastId = categories[categories.length - 1]?.value
|
||||
if (lastId) activeSection.value = lastId
|
||||
}
|
||||
|
||||
function scrollToSection(id: string) {
|
||||
activeSection.value = id
|
||||
clearScrollLock()
|
||||
isScrolling = true
|
||||
scrollSafetyTimer = setTimeout(clearScrollLock, SCROLL_SAFETY_MS)
|
||||
const el = document.getElementById(id)
|
||||
if (el) {
|
||||
scrollTo(el, {
|
||||
offset: HEADER_OFFSET_PX,
|
||||
duration: 0.8,
|
||||
immediate: prefersReducedMotion(),
|
||||
onComplete: clearScrollLock
|
||||
})
|
||||
return
|
||||
}
|
||||
clearScrollLock()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// The section anchors live in the statically rendered article body, so the
|
||||
// observer targets are resolved from the DOM by id rather than template refs.
|
||||
const elements = categories
|
||||
.map((category) => document.getElementById(category.value))
|
||||
.filter((el): el is HTMLElement => el !== null)
|
||||
|
||||
useIntersectionObserver(
|
||||
elements,
|
||||
(entries) => {
|
||||
if (isScrolling) return
|
||||
if (isAtBottom()) return
|
||||
let best: IntersectionObserverEntry | null = null
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting) continue
|
||||
if (!best || entry.boundingClientRect.top < best.boundingClientRect.top)
|
||||
best = entry
|
||||
}
|
||||
if (best) activeSection.value = best.target.id
|
||||
},
|
||||
{ rootMargin: '-20% 0px -60% 0px' }
|
||||
)
|
||||
|
||||
activateLastIfAtBottom()
|
||||
})
|
||||
|
||||
useEventListener('scroll', activateLastIfAtBottom, { passive: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CategoryNav
|
||||
:categories
|
||||
:model-value="activeSection"
|
||||
@update:model-value="scrollToSection"
|
||||
/>
|
||||
</template>
|
||||
84
apps/website/src/components/customers/CustomerArticle.astro
Normal file
@@ -0,0 +1,84 @@
|
||||
---
|
||||
import { render } from 'astro:content'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import type { CustomerStoryEntry } from '../../utils/customers'
|
||||
import ArticleNav from './ArticleNav.vue'
|
||||
import AtAGlance from './content/AtAGlance.astro'
|
||||
import AuthorBio from './content/AuthorBio.astro'
|
||||
import BulletList from './content/BulletList.astro'
|
||||
import Contributors from './content/Contributors.astro'
|
||||
import Download from './content/Download.astro'
|
||||
import EducationCta from './content/EducationCta.astro'
|
||||
import Embed from './content/Embed.astro'
|
||||
import Figure from './content/Figure.astro'
|
||||
import Heading from './content/Heading.astro'
|
||||
import Heading4 from './content/Heading4.astro'
|
||||
import Link from './content/Link.astro'
|
||||
import ListItem from './content/ListItem.astro'
|
||||
import Paragraph from './content/Paragraph.astro'
|
||||
import Quote from './content/Quote.astro'
|
||||
import ReadMore from './content/ReadMore.vue'
|
||||
import Section from './content/Section.astro'
|
||||
import Steps from './content/Steps.astro'
|
||||
import Video from './content/Video.astro'
|
||||
|
||||
interface Props {
|
||||
entry: CustomerStoryEntry
|
||||
locale?: Locale
|
||||
}
|
||||
|
||||
const { entry, locale = 'en' } = Astro.props
|
||||
const { Content } = await render(entry)
|
||||
|
||||
// The sidebar nav mirrors the section outline declared in frontmatter so it is
|
||||
// server-rendered, exactly like the previous ContentSection sidebar.
|
||||
const categories = entry.data.sections.map((section) => ({
|
||||
label: section.label,
|
||||
value: section.id
|
||||
}))
|
||||
|
||||
// Markdown elements are mapped to the ported block styles; the named
|
||||
// components (Section, Figure, ...) are used directly inside the MDX body.
|
||||
const contentComponents = {
|
||||
p: Paragraph,
|
||||
a: Link,
|
||||
h3: Heading,
|
||||
h4: Heading4,
|
||||
ul: BulletList,
|
||||
li: ListItem,
|
||||
Section,
|
||||
Figure,
|
||||
Quote,
|
||||
Contributors,
|
||||
Steps,
|
||||
AtAGlance,
|
||||
AuthorBio,
|
||||
Download,
|
||||
EducationCta,
|
||||
Embed,
|
||||
Video
|
||||
}
|
||||
---
|
||||
|
||||
<section class="max-w-9xl mx-auto px-4 pt-8 pb-24 lg:px-20 lg:pt-24 lg:pb-40">
|
||||
<div class="lg:flex lg:gap-16">
|
||||
<aside class="hidden scrollbar-none lg:block lg:w-48 lg:shrink-0">
|
||||
<div class="sticky top-32">
|
||||
<ArticleNav
|
||||
categories={categories}
|
||||
client:media="(min-width: 1024px)"
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="flex-1">
|
||||
<Content components={contentComponents} />
|
||||
{
|
||||
entry.data.readMore && (
|
||||
<ReadMore href={entry.data.readMore} locale={locale} />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,9 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { customerStories } from '../../config/customerStories'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
import type { StoryCard } from '../../utils/customers'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
const { stories, locale = 'en' } = defineProps<{
|
||||
stories: StoryCard[]
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const prefix = locale === 'zh-CN' ? '/zh-CN' : ''
|
||||
</script>
|
||||
@@ -13,7 +16,7 @@ const prefix = locale === 'zh-CN' ? '/zh-CN' : ''
|
||||
class="max-w-9xl mx-auto grid grid-cols-1 gap-6 px-6 py-16 lg:grid-cols-2 lg:px-16 lg:py-24"
|
||||
>
|
||||
<a
|
||||
v-for="story in customerStories"
|
||||
v-for="story in stories"
|
||||
:key="story.slug"
|
||||
:href="`${prefix}/customers/${story.slug}`"
|
||||
class="bg-transparency-white-t4 group flex flex-col overflow-hidden rounded-3xl transition-colors hover:bg-white/8"
|
||||
@@ -22,7 +25,7 @@ const prefix = locale === 'zh-CN' ? '/zh-CN' : ''
|
||||
<div class="m-2 aspect-video overflow-hidden rounded-2xl">
|
||||
<div
|
||||
class="size-full rounded-2xl bg-white/5 bg-cover bg-center"
|
||||
:style="{ backgroundImage: `url(${story.image})` }"
|
||||
:style="{ backgroundImage: `url(${story.cover})` }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -32,12 +35,12 @@ const prefix = locale === 'zh-CN' ? '/zh-CN' : ''
|
||||
<span
|
||||
class="text-primary-comfy-yellow text-[10px] font-semibold tracking-widest uppercase"
|
||||
>
|
||||
{{ t(story.category, locale) }}
|
||||
{{ story.category }}
|
||||
</span>
|
||||
<h3
|
||||
class="mt-2 text-lg/snug font-light text-primary-comfy-canvas lg:text-xl/snug"
|
||||
>
|
||||
{{ t(story.title, locale) }}
|
||||
{{ story.title }}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
interface Row {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
rows: Row[]
|
||||
}
|
||||
|
||||
const { rows } = Astro.props
|
||||
---
|
||||
|
||||
<div
|
||||
class="my-8 overflow-hidden rounded-2xl border border-white/10 bg-site-bg-soft"
|
||||
>
|
||||
<dl class="divide-y divide-white/10">
|
||||
{
|
||||
rows.map((row) => (
|
||||
<div class="flex flex-col gap-1 p-5 sm:flex-row sm:gap-6">
|
||||
<dt class="text-primary-comfy-yellow shrink-0 text-xs font-bold tracking-widest uppercase sm:w-44">
|
||||
{row.label}
|
||||
</dt>
|
||||
<dd class="text-sm/relaxed text-primary-comfy-canvas">{row.value}</dd>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</dl>
|
||||
</div>
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
interface Author {
|
||||
name?: string
|
||||
role?: string
|
||||
photo?: string
|
||||
bio?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
label?: string
|
||||
people: Author[]
|
||||
}
|
||||
|
||||
const { label, people } = Astro.props
|
||||
const hasBioSlot = Astro.slots.has('default')
|
||||
---
|
||||
|
||||
<div class="mt-12 border-t border-white/10 pt-8">
|
||||
{
|
||||
label && (
|
||||
<span class="text-primary-comfy-yellow text-xs font-bold tracking-widest uppercase">
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
<div class="mt-4 space-y-8">
|
||||
{
|
||||
people.map((person) => (
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:gap-6">
|
||||
{person.photo && (
|
||||
<img
|
||||
src={person.photo}
|
||||
alt={person.name ?? ''}
|
||||
class="size-20 shrink-0 rounded-full object-cover"
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
{person.name && (
|
||||
<p class="text-sm font-semibold text-primary-comfy-canvas">
|
||||
{person.name}
|
||||
{person.role && (
|
||||
<span class="text-primary-warm-gray"> · {person.role}</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{person.bio ? (
|
||||
<p class="mt-2 text-sm/relaxed text-primary-comfy-canvas italic">
|
||||
{person.bio}
|
||||
</p>
|
||||
) : hasBioSlot && people.length === 1 ? (
|
||||
<p class="mt-2 text-sm/relaxed text-primary-comfy-canvas italic">
|
||||
<slot />
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1 @@
|
||||
<ul class="mt-4 space-y-1 pl-5 text-sm"><slot /></ul>
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
interface Person {
|
||||
name: string
|
||||
role: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
label: string
|
||||
people: Person[]
|
||||
}
|
||||
|
||||
const { label, people } = Astro.props
|
||||
---
|
||||
|
||||
<div class="mt-8 rounded-2xl bg-site-bg-soft p-6">
|
||||
<span
|
||||
class="text-primary-comfy-yellow text-xs font-bold tracking-widest uppercase"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{
|
||||
people.map((person, i) => (
|
||||
<>
|
||||
<p
|
||||
class={cn(
|
||||
'text-sm font-semibold text-primary-comfy-canvas',
|
||||
i === 0 ? 'mt-2' : 'mt-4'
|
||||
)}
|
||||
>
|
||||
{person.name}
|
||||
</p>
|
||||
<p class="text-xs text-primary-comfy-canvas">{person.role}</p>
|
||||
</>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
19
apps/website/src/components/customers/content/Download.astro
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
interface Props {
|
||||
href: string
|
||||
label: string
|
||||
newTab?: boolean
|
||||
}
|
||||
|
||||
const { href, label, newTab = false } = Astro.props
|
||||
---
|
||||
|
||||
<a
|
||||
href={href}
|
||||
download={newTab ? undefined : true}
|
||||
target={newTab ? '_blank' : undefined}
|
||||
rel={newTab ? 'noopener noreferrer' : undefined}
|
||||
class="text-primary-comfy-yellow my-4 inline-block text-sm font-semibold underline underline-offset-2 transition-opacity hover:opacity-80"
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
import { t } from '../../../i18n/translations'
|
||||
import type { Locale } from '../../../i18n/translations'
|
||||
import Link from './Link.astro'
|
||||
|
||||
const rawLocale = Astro.currentLocale ?? 'en'
|
||||
const locale: Locale = rawLocale === 'zh-CN' ? 'zh-CN' : 'en'
|
||||
---
|
||||
|
||||
<div
|
||||
class="border-primary-comfy-yellow mt-12 rounded-2xl border-l-4 bg-site-bg-soft p-8"
|
||||
>
|
||||
<p class="text-base/relaxed text-primary-comfy-canvas">
|
||||
<strong class="font-semibold">{t('educationCta.heading', locale)}</strong>{' '}
|
||||
{t('educationCta.body', locale)}{' '}
|
||||
<Link href="https://comfy.org/education">{t('educationCta.exploreLink', locale)}</Link>{' '}
|
||||
{t('educationCta.or', locale)}{' '}
|
||||
<Link href="https://tally.so/r/Xx97lL">{t('educationCta.applyLink', locale)}</Link>{' '}
|
||||
{t('educationCta.tail', locale)}
|
||||
</p>
|
||||
</div>
|
||||
22
apps/website/src/components/customers/content/Embed.astro
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
interface Props {
|
||||
src: string
|
||||
title: string
|
||||
}
|
||||
|
||||
const { src, title } = Astro.props
|
||||
---
|
||||
|
||||
<div
|
||||
class="my-8 aspect-video overflow-hidden rounded-2xl border border-white/10 bg-black"
|
||||
>
|
||||
<iframe
|
||||
src={src}
|
||||
title={title}
|
||||
class="size-full"
|
||||
loading="lazy"
|
||||
allow="autoplay; fullscreen; picture-in-picture; clipboard-write"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
sandbox="allow-scripts allow-same-origin allow-presentation allow-popups"
|
||||
></iframe>
|
||||
</div>
|
||||
21
apps/website/src/components/customers/content/Figure.astro
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
interface Props {
|
||||
src: string
|
||||
alt: string
|
||||
caption?: string
|
||||
}
|
||||
|
||||
const { src, alt, caption } = Astro.props
|
||||
const hasCaptionSlot = Astro.slots.has('default')
|
||||
---
|
||||
|
||||
<figure class="my-8">
|
||||
<img src={src} alt={alt} class="w-full rounded-2xl object-cover" />
|
||||
{
|
||||
(hasCaptionSlot || caption) && (
|
||||
<figcaption class="mt-3 text-xs text-primary-comfy-canvas">
|
||||
{hasCaptionSlot ? <slot /> : caption}
|
||||
</figcaption>
|
||||
)
|
||||
}
|
||||
</figure>
|
||||
@@ -0,0 +1,3 @@
|
||||
<h3 class="text-primary-comfy-yellow mt-6 mb-2 text-lg font-semibold italic">
|
||||
<slot />
|
||||
</h3>
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
---
|
||||
|
||||
<h4 class="mt-6 mb-2 text-base font-semibold text-primary-comfy-canvas">
|
||||
<slot />
|
||||
</h4>
|
||||
15
apps/website/src/components/customers/content/Link.astro
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
interface Props {
|
||||
href: string
|
||||
}
|
||||
|
||||
const { href } = Astro.props
|
||||
const isExternal = /^https?:\/\//.test(href)
|
||||
---
|
||||
|
||||
<a
|
||||
href={href}
|
||||
target={isExternal ? '_blank' : undefined}
|
||||
rel={isExternal ? 'noopener noreferrer' : undefined}
|
||||
class="text-primary-comfy-yellow underline underline-offset-2 transition-opacity hover:opacity-80"
|
||||
><slot /></a>
|
||||
@@ -0,0 +1,5 @@
|
||||
<li
|
||||
class="flex items-start gap-2 text-primary-comfy-canvas before:mt-1.5 before:size-1.5 before:shrink-0 before:rounded-full before:bg-primary-comfy-yellow"
|
||||
>
|
||||
<slot />
|
||||
</li>
|
||||
@@ -0,0 +1 @@
|
||||
<p class="mt-4 text-sm/relaxed text-primary-comfy-canvas"><slot /></p>
|
||||
20
apps/website/src/components/customers/content/Quote.astro
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
interface Props {
|
||||
name?: string
|
||||
}
|
||||
|
||||
const { name } = Astro.props
|
||||
---
|
||||
|
||||
<blockquote
|
||||
class="border-primary-comfy-yellow my-8 rounded-2xl border-l-4 bg-site-bg-soft p-8"
|
||||
>
|
||||
<p class="text-lg/relaxed font-light text-primary-comfy-canvas italic">
|
||||
"<slot />"
|
||||
</p>
|
||||
{
|
||||
name && (
|
||||
<p class="text-primary-comfy-yellow mt-4 text-sm font-semibold">{name}</p>
|
||||
)
|
||||
}
|
||||
</blockquote>
|
||||
21
apps/website/src/components/customers/content/ReadMore.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../../i18n/translations'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import Button from '../../ui/button/Button.vue'
|
||||
|
||||
const { href, locale = 'en' } = defineProps<{
|
||||
href: string
|
||||
locale?: Locale
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-8 flex justify-center">
|
||||
<Button as="a" :href variant="default" size="lg">
|
||||
{{ t('customers.story.readMore', locale) }}
|
||||
<template #append>
|
||||
<span class="text-base" aria-hidden="true">↗</span>
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
17
apps/website/src/components/customers/content/Section.astro
Normal file
@@ -0,0 +1,17 @@
|
||||
---
|
||||
interface Props {
|
||||
id: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
const { id, title } = Astro.props
|
||||
---
|
||||
|
||||
<div id={id} class="mb-16 scroll-mt-24 lg:scroll-mt-36">
|
||||
{
|
||||
title && (
|
||||
<h2 class="mb-6 text-2xl font-light text-primary-comfy-canvas">{title}</h2>
|
||||
)
|
||||
}
|
||||
<slot />
|
||||
</div>
|
||||
17
apps/website/src/components/customers/content/Steps.astro
Normal file
@@ -0,0 +1,17 @@
|
||||
---
|
||||
interface Props {
|
||||
items: string[]
|
||||
}
|
||||
|
||||
const { items } = Astro.props
|
||||
---
|
||||
|
||||
<ol class="mt-4 space-y-1 pl-1 text-sm [counter-reset:step]">
|
||||
{
|
||||
items.map((item) => (
|
||||
<li class="flex items-start gap-3 text-primary-comfy-canvas [counter-increment:step] before:shrink-0 before:font-semibold before:tabular-nums before:text-primary-comfy-yellow before:content-[counter(step,_decimal-leading-zero)]">
|
||||
{item}
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ol>
|
||||
22
apps/website/src/components/customers/content/Video.astro
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
import VideoPlayer from '../../common/VideoPlayer.vue'
|
||||
|
||||
interface Props {
|
||||
src: string
|
||||
poster?: string
|
||||
caption?: string
|
||||
}
|
||||
|
||||
const { src, poster, caption } = Astro.props
|
||||
---
|
||||
|
||||
<figure class="my-8">
|
||||
<VideoPlayer src={src} poster={poster} client:visible />
|
||||
{
|
||||
caption && (
|
||||
<figcaption class="mt-3 text-xs text-primary-comfy-canvas">
|
||||
{caption}
|
||||
</figcaption>
|
||||
)
|
||||
}
|
||||
</figure>
|
||||
23
apps/website/src/components/pricing/FAQSection.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<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
|
||||
id="faq"
|
||||
:heading="t('pricing.faq.heading', locale)"
|
||||
:faqs="faqs"
|
||||
/>
|
||||
</template>
|
||||
@@ -1,393 +0,0 @@
|
||||
<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>
|
||||
15
apps/website/src/components/pricing/PricingCard.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<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>
|
||||
43
apps/website/src/components/pricing/PricingCredits.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<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 text-xs">
|
||||
{{ estimate }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,42 @@
|
||||
<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', education = false } = defineProps<{
|
||||
locale?: Locale
|
||||
education?: boolean
|
||||
}>()
|
||||
|
||||
const ctaHref = computed(() => getRoutes(locale).contact)
|
||||
|
||||
const labelKey = education
|
||||
? 'pricing.creativeCampus.label'
|
||||
: 'pricing.enterprise.label'
|
||||
const descriptionKey = education
|
||||
? 'pricing.creativeCampus.description'
|
||||
: 'pricing.enterprise.description'
|
||||
</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(labelKey, locale)" />
|
||||
<p class="text-primary-warm-white text-sm">
|
||||
{{ t(descriptionKey, locale) }}
|
||||
</p>
|
||||
</div>
|
||||
<Button :href="ctaHref" variant="outline">
|
||||
{{ t('pricing.enterprise.cta', locale) }}
|
||||
</Button>
|
||||
</div>
|
||||
</PricingCard>
|
||||
</template>
|
||||
@@ -1,60 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import type {
|
||||
PlanFeatureGroup,
|
||||
PlanFeatureStatus
|
||||
} from '../../data/pricingPlans'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { Check, Clock, X } from '@lucide/vue'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
interface PlanFeature {
|
||||
text: TranslationKey
|
||||
export type { PlanFeatureGroup }
|
||||
|
||||
const statusIcon = {
|
||||
included: Check,
|
||||
excluded: X,
|
||||
coming: Clock
|
||||
} as const
|
||||
|
||||
const statusIconClass: Record<PlanFeatureStatus, string> = {
|
||||
included: 'text-primary-comfy-yellow',
|
||||
excluded: 'text-primary-comfy-canvas/40',
|
||||
coming: 'text-primary-warm-gray'
|
||||
}
|
||||
|
||||
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[]
|
||||
featureIntroKey?: TranslationKey
|
||||
nextUpKey?: TranslationKey
|
||||
andMoreKey?: TranslationKey
|
||||
nextUpClass?: string
|
||||
andMoreClass?: string
|
||||
listGap?: string
|
||||
introMargin?: string
|
||||
const statusTextClass: Record<PlanFeatureStatus, string> = {
|
||||
included: 'text-primary-warm-white',
|
||||
excluded: 'text-primary-warm-gray',
|
||||
coming: 'text-primary-warm-gray'
|
||||
}
|
||||
|
||||
const { locale = 'en' } = defineProps<{
|
||||
features: PlanFeatureGroup[]
|
||||
locale?: Locale
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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"
|
||||
<div class="flex flex-col gap-5">
|
||||
<div
|
||||
v-for="(group, groupIndex) in features"
|
||||
:key="group.titleKey ?? groupIndex"
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<span class="text-primary-comfy-yellow mt-0.5 text-sm">✓</span>
|
||||
<span class="text-primary-comfy-canvas text-sm">
|
||||
{{ t(feature.text, locale) }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-if="nextUpKey" :class="nextUpClass">
|
||||
{{ t(nextUpKey, locale) }}
|
||||
</p>
|
||||
<p v-if="andMoreKey" :class="andMoreClass">
|
||||
{{ t(andMoreKey, locale) }}
|
||||
</p>
|
||||
<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"
|
||||
>
|
||||
<component
|
||||
:is="statusIcon[feature.status ?? 'included']"
|
||||
class="mt-0.5 size-4 shrink-0"
|
||||
:class="statusIconClass[feature.status ?? 'included']"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="sr-only">
|
||||
{{
|
||||
t(
|
||||
`pricing.plan.feature.status.${feature.status ?? 'included'}`,
|
||||
locale
|
||||
)
|
||||
}}:
|
||||
</span>
|
||||
<span
|
||||
class="ppformula-text-center text-sm"
|
||||
:class="statusTextClass[feature.status ?? 'included']"
|
||||
>
|
||||
{{ t(feature.text, locale) }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
23
apps/website/src/components/pricing/PricingPlanLabel.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<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>
|
||||
67
apps/website/src/components/pricing/PricingPrice.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<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>
|
||||
191
apps/website/src/components/pricing/PricingSection.vue
Normal file
@@ -0,0 +1,191 @@
|
||||
<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 PricingStudentAmbassadorBand from './PricingStudentAmbassadorBand.vue'
|
||||
import PricingTeamCard from './PricingTeamCard.vue'
|
||||
|
||||
const { locale = 'en', education = false } = defineProps<{
|
||||
locale?: Locale
|
||||
education?: boolean
|
||||
}>()
|
||||
|
||||
const billingPeriod = ref<BillingCycle>('yearly')
|
||||
|
||||
// Education discount off the monthly list price: 10% monthly, 25% yearly.
|
||||
const EDU_MONTHLY_PCT = 10
|
||||
const EDU_YEARLY_PCT = 25
|
||||
|
||||
function displayPriceKey(plan: PricingPlan): TranslationKey | undefined {
|
||||
if (education) {
|
||||
if (billingPeriod.value === 'yearly') {
|
||||
return plan.eduYearlyPriceKey ?? plan.eduPriceKey
|
||||
}
|
||||
return plan.eduPriceKey
|
||||
}
|
||||
if (billingPeriod.value === 'yearly' && plan.yearlyPriceKey) {
|
||||
return plan.yearlyPriceKey
|
||||
}
|
||||
return plan.priceKey
|
||||
}
|
||||
|
||||
// In education mode the monthly list price is struck through in both cycles;
|
||||
// otherwise only the yearly view strikes the (monthly) list price.
|
||||
function originalPriceFor(plan: PricingPlan): string | undefined {
|
||||
if (education) {
|
||||
return plan.eduPriceKey && plan.priceKey
|
||||
? t(plan.priceKey, locale)
|
||||
: undefined
|
||||
}
|
||||
return billingPeriod.value === 'yearly' &&
|
||||
plan.yearlyPriceKey &&
|
||||
plan.priceKey
|
||||
? t(plan.priceKey, locale)
|
||||
: undefined
|
||||
}
|
||||
|
||||
function yearlyTotalFor(plan: PricingPlan): string | undefined {
|
||||
if (education) {
|
||||
return plan.eduYearlyTotalKey
|
||||
? t(plan.eduYearlyTotalKey, locale)
|
||||
: undefined
|
||||
}
|
||||
return plan.yearlyTotalKey ? t(plan.yearlyTotalKey, locale) : undefined
|
||||
}
|
||||
|
||||
// Only individual education cards show the savings label, via PricingPrice's
|
||||
// discount slot; the percentage flips with the billing toggle.
|
||||
function eduSavingsFor(plan: PricingPlan): string | undefined {
|
||||
if (!education || !plan.eduPriceKey) return undefined
|
||||
const pct =
|
||||
billingPeriod.value === 'yearly' ? EDU_YEARLY_PCT : EDU_MONTHLY_PCT
|
||||
return t('pricing.educationalSavings', locale).replace('{pct}', String(pct))
|
||||
}
|
||||
</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(
|
||||
education ? 'pricing.period.yearly.edu' : '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="originalPriceFor(plan)"
|
||||
:discount="eduSavingsFor(plan)"
|
||||
:billing-period="billingPeriod"
|
||||
:yearly-total="yearlyTotalFor(plan)"
|
||||
: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)"
|
||||
variant="outline"
|
||||
class="w-full text-center"
|
||||
>
|
||||
{{ t(plan.ctaKey, locale) }}
|
||||
</Button>
|
||||
</div>
|
||||
</PricingCard>
|
||||
|
||||
<PricingTeamCard :billing-period="billingPeriod" :education :locale />
|
||||
|
||||
<PricingEnterpriseBand :education :locale />
|
||||
|
||||
<PricingStudentAmbassadorBand v-if="education" :locale />
|
||||
</div>
|
||||
|
||||
<!-- Footnote -->
|
||||
<p class="mt-12 text-xs text-primary-comfy-canvas/70">
|
||||
{{ t('pricing.footnote', locale) }}
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { Clock } from '@lucide/vue'
|
||||
|
||||
import { externalLinks } from '../../config/routes'
|
||||
import { t } from '../../i18n/translations'
|
||||
import PricingCard from './PricingCard.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{
|
||||
locale?: Locale
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PricingCard class="col-span-full">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:gap-8">
|
||||
<span
|
||||
class="text-primary-warm-gray inline-flex shrink-0 items-center gap-2 text-sm tracking-wider uppercase"
|
||||
>
|
||||
<Clock class="size-4" />
|
||||
{{ t('pricing.studentAmbassador.comingSoon', locale) }}
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="text-primary-warm-white shrink-0 text-base font-bold tracking-wider uppercase"
|
||||
>
|
||||
{{ t('pricing.studentAmbassador.label', locale) }}
|
||||
</span>
|
||||
|
||||
<p class="text-primary-warm-white flex-1 text-sm">
|
||||
{{ t('pricing.studentAmbassador.description', locale) }}
|
||||
</p>
|
||||
|
||||
<a
|
||||
:href="externalLinks.studentAmbassadorForm"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-comfy-yellow inline-flex shrink-0 items-center gap-3 text-sm font-bold tracking-wider uppercase"
|
||||
>
|
||||
<span class="underline underline-offset-4">
|
||||
{{ t('pricing.studentAmbassador.cta', locale) }}
|
||||
</span>
|
||||
<span
|
||||
class="border-primary-comfy-yellow/50 hidden w-12 border-t border-dashed lg:block"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</PricingCard>
|
||||
</template>
|
||||
171
apps/website/src/components/pricing/PricingTeamCard.vue
Normal file
@@ -0,0 +1,171 @@
|
||||
<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 {
|
||||
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,
|
||||
education = false
|
||||
} = defineProps<{
|
||||
billingPeriod: 'monthly' | 'yearly'
|
||||
locale?: Locale
|
||||
education?: boolean
|
||||
}>()
|
||||
|
||||
const teamCreditTierIndex = ref<number[]>([2])
|
||||
|
||||
const selectedTeamTier = computed(
|
||||
() => teamCreditTiers[teamCreditTierIndex.value[0] ?? 0]
|
||||
)
|
||||
const selectedTeamPrice = computed(() => {
|
||||
const tier = selectedTeamTier.value
|
||||
if (education) {
|
||||
return billingPeriod === 'yearly'
|
||||
? tier.eduYearlyPrice
|
||||
: tier.eduMonthlyPrice
|
||||
}
|
||||
return billingPeriod === 'yearly' ? tier.yearlyPrice : tier.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
|
||||
// Round to 1 decimal so future tiers can't render repeating decimals
|
||||
// (e.g. 8.333333%), while preserving exact values like 2.5% / 7.5%.
|
||||
const pct = Math.round(((base - discounted) / base) * 1000) / 10
|
||||
return t(
|
||||
education ? 'pricing.team.educationalSaving' : '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', status: 'coming' },
|
||||
{ text: 'pricing.plan.team.projects', status: '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"
|
||||
>
|
||||
<template #tick="{ index, active }">
|
||||
<ComponentIcon
|
||||
class="hidden size-4 shrink-0 lg:block"
|
||||
:class="
|
||||
active
|
||||
? 'text-primary-comfy-orange'
|
||||
: 'text-primary-warm-gray'
|
||||
"
|
||||
/>
|
||||
<span
|
||||
class="text-sm max-sm:text-[10px]"
|
||||
:class="
|
||||
active ? 'text-primary-warm-white' : 'text-primary-warm-gray'
|
||||
"
|
||||
>
|
||||
{{ formatTeamCreditsShort(teamCreditTiers[index].credits) }}
|
||||
</span>
|
||||
</template>
|
||||
</Slider>
|
||||
</div>
|
||||
|
||||
<PricingCredits
|
||||
:credits="selectedTeamTier.credits.toLocaleString('en-US')"
|
||||
: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>
|
||||
@@ -1,7 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="bg-primary-comfy-ink rounded-4.5xl row-span-7 grid grid-rows-subgrid pb-3"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,5 +1,7 @@
|
||||
<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'
|
||||
|
||||
@@ -54,11 +56,7 @@ const features: IncludedFeature[] = [
|
||||
},
|
||||
{
|
||||
titleKey: 'pricing.included.feature11.title',
|
||||
descriptionKey: 'pricing.included.feature11.description'
|
||||
},
|
||||
{
|
||||
titleKey: 'pricing.included.feature12.title',
|
||||
descriptionKey: 'pricing.included.feature12.description',
|
||||
descriptionKey: 'pricing.included.feature11.description',
|
||||
isComingSoon: true
|
||||
}
|
||||
]
|
||||
@@ -92,11 +90,9 @@ const features: IncludedFeature[] = [
|
||||
>
|
||||
<!-- Title -->
|
||||
<div class="flex items-start gap-3">
|
||||
<img
|
||||
<Clock
|
||||
v-if="feature.isComingSoon"
|
||||
src="/icons/clock.svg"
|
||||
alt=""
|
||||
class="mt-0.5 size-4 shrink-0"
|
||||
class="mt-0.5 size-4 shrink-0 text-primary-comfy-canvas/55"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<CheckIcon
|
||||
@@ -105,6 +101,12 @@ 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>
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
:locale="locale"
|
||||
heading-key="cloud.faq.heading"
|
||||
faq-prefix="cloud.faq"
|
||||
:faq-count="15"
|
||||
:faq-count="12"
|
||||
footer-key="cloud.faq.footer"
|
||||
/>
|
||||
</template>
|
||||
|
||||
16
apps/website/src/components/ui/accordion/Accordion.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<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>
|
||||
@@ -0,0 +1,26 @@
|
||||
<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>
|
||||
29
apps/website/src/components/ui/accordion/AccordionItem.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<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>
|
||||
@@ -0,0 +1,43 @@
|
||||
<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>
|
||||
@@ -25,7 +25,7 @@ const {
|
||||
data-slot="badge"
|
||||
:data-variant="variant"
|
||||
:data-size="size"
|
||||
:class="cn(badgeVariants({ variant, size }), className)"
|
||||
:class="cn(badgeVariants({ size, variant }), className)"
|
||||
>
|
||||
<slot name="prepend">
|
||||
<component :is="prependIcon" v-if="prependIcon" />
|
||||
|
||||
@@ -4,15 +4,18 @@ import { cva } from 'cva'
|
||||
export const badgeVariants = cva({
|
||||
base: 'text-primary-warm-gray font-formula leading-none focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3',
|
||||
variants: {
|
||||
variant: {
|
||||
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'
|
||||
},
|
||||
size: {
|
||||
md: 'px-4 py-1 text-xs',
|
||||
xs: 'px-2 py-0.5 text-[9px]'
|
||||
},
|
||||
variant: {
|
||||
default: 'bg-transparency-ink-t80',
|
||||
subtle: 'bg-transparency-white-t4 text-primary-comfy-canvas',
|
||||
category: 'text-primary-comfy-yellow px-0 font-semibold uppercase',
|
||||
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'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { PrimitiveProps } from 'reka-ui'
|
||||
import type { Component, HTMLAttributes } from 'vue'
|
||||
import type { AnchorHTMLAttributes, Component, HTMLAttributes } from 'vue'
|
||||
import type { ButtonVariants } from '.'
|
||||
import { Primitive } from 'reka-ui'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
@@ -13,17 +13,19 @@ interface Props extends PrimitiveProps {
|
||||
disabled?: boolean
|
||||
prependIcon?: Component
|
||||
appendIcon?: Component
|
||||
href?: AnchorHTMLAttributes['href']
|
||||
}
|
||||
|
||||
const {
|
||||
as = 'button',
|
||||
as,
|
||||
asChild,
|
||||
variant,
|
||||
size,
|
||||
class: className,
|
||||
disabled,
|
||||
prependIcon,
|
||||
appendIcon
|
||||
appendIcon,
|
||||
href
|
||||
} = defineProps<Props>()
|
||||
</script>
|
||||
|
||||
@@ -32,9 +34,10 @@ const {
|
||||
data-slot="button"
|
||||
:data-variant="variant"
|
||||
:data-size="size"
|
||||
:as
|
||||
:as="as ?? (href != null && !disabled ? 'a' : 'button')"
|
||||
:as-child
|
||||
:disabled
|
||||
:href="disabled ? undefined : href"
|
||||
:class="cn(buttonVariants({ variant, size }), className)"
|
||||
>
|
||||
<slot name="prepend">
|
||||
|
||||
@@ -8,7 +8,8 @@ export const buttonVariants = cva(
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
default: 'h-10 px-6 py-2.5',
|
||||
sm: 'h-8 px-4 py-2 text-xs md:text-sm',
|
||||
default: 'h-10 px-6 py-2.5 text-xs md:text-sm',
|
||||
lg: 'h-14 px-8 py-4 text-base'
|
||||
},
|
||||
variant: {
|
||||
@@ -17,6 +18,8 @@ export const buttonVariants = cva(
|
||||
outline:
|
||||
'text-primary-comfy-yellow hover:bg-primary-comfy-yellow border uppercase hover:text-primary-comfy-ink',
|
||||
link: "text-primary-comfy-yellow h-auto justify-start px-0 py-1 text-base uppercase hover:opacity-90 [&_svg:not([class*='size-'])]:size-6",
|
||||
underlineLink:
|
||||
"text-primary-comfy-yellow relative h-auto justify-start px-0 py-1 uppercase after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-full after:origin-left after:scale-x-0 after:bg-current after:transition-transform after:duration-200 hover:opacity-90 hover:after:scale-x-100 [&_svg:not([class*='size-'])]:size-6",
|
||||
nav: 'text-primary-warm-white hover:text-primary-comfy-yellow h-auto justify-between px-0 py-1 text-start text-2xl font-medium',
|
||||
navMuted:
|
||||
'hover:text-primary-comfy-yellow h-auto w-full justify-between px-0 py-1 text-start text-2xl font-medium text-primary-comfy-canvas uppercase'
|
||||
|
||||
22
apps/website/src/components/ui/card/Card.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<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
|
||||
data-slot="card"
|
||||
:class="
|
||||
cn(
|
||||
'bg-transparency-white-t4 text-primary-warm-white rounded-4.5xl flex flex-col gap-6 shadow-sm',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
14
apps/website/src/components/ui/card/CardContent.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<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 data-slot="card-content" :class="cn('px-6', className)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
19
apps/website/src/components/ui/card/CardDescription.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<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
|
||||
data-slot="card-description"
|
||||
:class="
|
||||
cn('line-clamp-3 text-base text-primary-comfy-canvas/70', className)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
14
apps/website/src/components/ui/card/CardFooter.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<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 data-slot="card-footer" :class="cn('flex items-center', className)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
14
apps/website/src/components/ui/card/CardHeader.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<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 data-slot="card-header" :class="cn('flex flex-col gap-1.5', className)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
22
apps/website/src/components/ui/card/CardTitle.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<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
|
||||
data-slot="card-title"
|
||||
:class="
|
||||
cn(
|
||||
'text-xl leading-none font-medium text-primary-comfy-canvas md:text-2xl',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { Check, Copy } from '@lucide/vue'
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
|
||||
// Interactive: the copy button is inert until its host island is hydrated.
|
||||
// Render under a `client:*` directive (e.g. `client:visible`) when the page
|
||||
// needs it to work.
|
||||
const {
|
||||
value,
|
||||
copyLabel = 'Copy',
|
||||
copiedLabel = 'Copied'
|
||||
} = defineProps<{ value: string; copyLabel?: string; copiedLabel?: string }>()
|
||||
|
||||
const { copy, copied } = useClipboard({ copiedDuring: 2000 })
|
||||
|
||||
function handleCopy() {
|
||||
void copy(value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-transparency-white-t4 border-primary-warm-gray flex items-center gap-2 rounded-xl border px-4 py-3"
|
||||
>
|
||||
<span class="flex-1 truncate font-mono text-xs text-primary-comfy-canvas">
|
||||
{{ value }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="copied ? copiedLabel : copyLabel"
|
||||
class="text-primary-warm-gray shrink-0 cursor-pointer transition-colors hover:text-primary-comfy-canvas"
|
||||
@click="handleCopy"
|
||||
>
|
||||
<component :is="copied ? Check : Copy" class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
112
apps/website/src/components/ui/slider/Slider.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<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,
|
||||
modelValue,
|
||||
...restProps
|
||||
} = defineProps<
|
||||
SliderRootProps & { class?: HTMLAttributes['class']; ticks?: number }
|
||||
>()
|
||||
const emits = defineEmits<SliderRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(
|
||||
computed(() => ({ ...restProps, modelValue, min, max })),
|
||||
emits
|
||||
)
|
||||
|
||||
// Single source of truth for tick geometry, shared by the on-track dots and the
|
||||
// optional #tick label slot so the two can never drift apart.
|
||||
function tickLeft(i: number): string {
|
||||
return `calc(8px + ${(i - 1) / (ticks! - 1)} * (100% - 16px))`
|
||||
}
|
||||
|
||||
function tickValue(i: number): number {
|
||||
return min + ((i - 1) / (ticks! - 1)) * (max - min)
|
||||
}
|
||||
|
||||
function isTickActive(i: number): boolean {
|
||||
return modelValue != null && tickValue(i) === modelValue[0]
|
||||
}
|
||||
|
||||
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',
|
||||
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: tickLeft(i) }"
|
||||
/>
|
||||
</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"
|
||||
>
|
||||
<SliderRange
|
||||
data-slot="slider-range"
|
||||
class="bg-primary-warm-white absolute data-[orientation=horizontal]:h-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>
|
||||
|
||||
<div v-if="$slots.tick && ticks && ticks > 1" class="relative mt-3 h-6">
|
||||
<div
|
||||
v-for="i in ticks"
|
||||
:key="i"
|
||||
class="absolute top-0 inline-flex -translate-x-1/2 items-center gap-1.5"
|
||||
:style="{ left: tickLeft(i) }"
|
||||
>
|
||||
<slot name="tick" :index="i - 1" :active="isTickActive(i)" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
63
apps/website/src/components/ui/toggle-group/ToggleGroup.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<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>
|
||||
@@ -0,0 +1,56 @@
|
||||
<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>
|
||||
20
apps/website/src/components/ui/toggle/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
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 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'
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -22,6 +22,12 @@ interface HeroLogoConfig {
|
||||
cursorTiltStrength: number
|
||||
bgScale: number
|
||||
slideDuration: number
|
||||
svgMarkup: string
|
||||
fitAxis: 'width' | 'height'
|
||||
targetSize: number
|
||||
respectReducedMotion: boolean
|
||||
baseUrl: string
|
||||
fadeInDurationMs: number
|
||||
}
|
||||
|
||||
const DEFAULTS: HeroLogoConfig = {
|
||||
@@ -34,19 +40,25 @@ const DEFAULTS: HeroLogoConfig = {
|
||||
extrudeDepth: 200,
|
||||
cursorTiltStrength: 0.5,
|
||||
bgScale: 0.8,
|
||||
slideDuration: 0.4
|
||||
slideDuration: 0.4,
|
||||
svgMarkup: SVG_MARKUP,
|
||||
fitAxis: 'height',
|
||||
targetSize: 3,
|
||||
respectReducedMotion: true,
|
||||
baseUrl: BASE_URL,
|
||||
fadeInDurationMs: 0
|
||||
}
|
||||
|
||||
function buildImageUrls(): string[] {
|
||||
function buildImageUrls(baseUrl: string): string[] {
|
||||
return Array.from({ length: IMAGE_COUNT }, (_, i) => {
|
||||
const index = String(i).padStart(5, '0')
|
||||
return `${BASE_URL}/image_sequence_${index}.webp`
|
||||
return `${baseUrl}/image_sequence_${index}.webp`
|
||||
})
|
||||
}
|
||||
|
||||
function parseShapes(): THREE.Shape[] {
|
||||
function parseShapes(markup: string): THREE.Shape[] {
|
||||
const loader = new SVGLoader()
|
||||
const svgData = loader.parse(SVG_MARKUP)
|
||||
const svgData = loader.parse(markup)
|
||||
const shapes: THREE.Shape[] = []
|
||||
svgData.paths.forEach((path) => {
|
||||
shapes.push(...SVGLoader.createShapes(path))
|
||||
@@ -85,7 +97,8 @@ export function useHeroLogo(
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const container = containerRef.value
|
||||
if (!container || prefersReducedMotion()) return
|
||||
if (!container || (cfg.respectReducedMotion && prefersReducedMotion()))
|
||||
return
|
||||
|
||||
const { width, height } = container.getBoundingClientRect()
|
||||
|
||||
@@ -102,6 +115,9 @@ export function useHeroLogo(
|
||||
renderer.domElement.style.width = '100%'
|
||||
renderer.domElement.style.height = '100%'
|
||||
renderer.domElement.style.opacity = '0'
|
||||
if (cfg.fadeInDurationMs > 0) {
|
||||
renderer.domElement.style.transition = `opacity ${cfg.fadeInDurationMs}ms ease`
|
||||
}
|
||||
renderer.domElement.setAttribute('aria-hidden', 'true')
|
||||
container.appendChild(renderer.domElement)
|
||||
|
||||
@@ -126,24 +142,36 @@ export function useHeroLogo(
|
||||
camera.position.z = cfg.zoom
|
||||
|
||||
// SVG shape
|
||||
const shapes = parseShapes()
|
||||
const shapes = parseShapes(cfg.svgMarkup)
|
||||
const tempGeo = new THREE.ShapeGeometry(shapes)
|
||||
tempGeo.computeBoundingBox()
|
||||
const bb = tempGeo.boundingBox!
|
||||
const bb = tempGeo.boundingBox
|
||||
if (!bb) {
|
||||
tempGeo.dispose()
|
||||
cleanup?.()
|
||||
return
|
||||
}
|
||||
const cx = (bb.max.x + bb.min.x) / 2
|
||||
const cy = (bb.max.y + bb.min.y) / 2
|
||||
const scaleFactor = 3 / (bb.max.y - bb.min.y)
|
||||
const fitExtent =
|
||||
cfg.fitAxis === 'width' ? bb.max.x - bb.min.x : bb.max.y - bb.min.y
|
||||
if (fitExtent <= 0) {
|
||||
tempGeo.dispose()
|
||||
cleanup?.()
|
||||
return
|
||||
}
|
||||
const scaleFactor = cfg.targetSize / fitExtent
|
||||
tempGeo.dispose()
|
||||
|
||||
// Image sequence textures — load first frame eagerly, rest lazily
|
||||
const urls = buildImageUrls()
|
||||
const urls = buildImageUrls(cfg.baseUrl)
|
||||
const textures = await loadTextures(urls.slice(0, 1))
|
||||
if (disposed) return
|
||||
|
||||
renderer.domElement.style.opacity = '1'
|
||||
loaded.value = true
|
||||
|
||||
loadTextures(urls.slice(1)).then((rest) => {
|
||||
void loadTextures(urls.slice(1)).then((rest) => {
|
||||
if (!disposed) textures.push(...rest)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import type { TranslationKey } from '../i18n/translations'
|
||||
|
||||
interface CustomerStory {
|
||||
slug: string
|
||||
image: string
|
||||
category: TranslationKey
|
||||
title: TranslationKey
|
||||
body: TranslationKey
|
||||
detailPrefix: string
|
||||
readMoreHref?: string
|
||||
}
|
||||
|
||||
export const customerStories: CustomerStory[] = [
|
||||
{
|
||||
slug: 'series-entertainment',
|
||||
image:
|
||||
'https://media.comfy.org/website/customers/series-entertainment/cover.webp',
|
||||
category: 'customers.story.series-entertainment.category',
|
||||
title: 'customers.story.series-entertainment.title',
|
||||
body: 'customers.story.series-entertainment.body',
|
||||
detailPrefix: 'customers.detail.series-entertainment',
|
||||
readMoreHref:
|
||||
'https://comfy.org/cloud/enterprise-case-studies/how-series-entertainment-rebuilt-game-and-video-production-with-comfyui'
|
||||
},
|
||||
{
|
||||
slug: 'open-story-movement',
|
||||
image:
|
||||
'https://media.comfy.org/website/customers/open-story-movement/cover.webp',
|
||||
category: 'customers.story.open-story-movement.category',
|
||||
title: 'customers.story.open-story-movement.title',
|
||||
body: 'customers.story.open-story-movement.body',
|
||||
detailPrefix: 'customers.detail.open-story-movement',
|
||||
readMoreHref: 'https://blog.comfy.org/p/how-open-source-is-fueling-the-open'
|
||||
},
|
||||
{
|
||||
slug: 'moment-factory',
|
||||
image:
|
||||
'https://media.comfy.org/website/customers/moment-factory/cover.webp',
|
||||
category: 'customers.story.moment-factory.category',
|
||||
title: 'customers.story.moment-factory.title',
|
||||
body: 'customers.story.moment-factory.body',
|
||||
detailPrefix: 'customers.detail.moment-factory',
|
||||
readMoreHref:
|
||||
'https://comfy.org/cloud/enterprise-case-studies/comfyui-at-architectural-scale-how-moment-factory-reimagined-3d-projection-mapping'
|
||||
},
|
||||
{
|
||||
slug: 'ubisoft-chord',
|
||||
image: 'https://media.comfy.org/website/customers/ubisoft/cover.webp',
|
||||
category: 'customers.story.ubisoft-chord.category',
|
||||
title: 'customers.story.ubisoft-chord.title',
|
||||
body: 'customers.story.ubisoft-chord.body',
|
||||
detailPrefix: 'customers.detail.ubisoft-chord',
|
||||
readMoreHref:
|
||||
'https://blog.comfy.org/p/ubisoft-open-sources-the-chord-model'
|
||||
},
|
||||
{
|
||||
slug: 'groove-jones',
|
||||
image:
|
||||
'https://media.comfy.org/website/customers/groove-jones/crocs-nfl-dicks-sporting-goods-fooh.webp',
|
||||
category: 'customers.story.groove-jones.category',
|
||||
title: 'customers.story.groove-jones.title',
|
||||
body: 'customers.story.groove-jones.body',
|
||||
detailPrefix: 'customers.detail.groove-jones'
|
||||
}
|
||||
]
|
||||
|
||||
export function getStoryBySlug(slug: string): CustomerStory | undefined {
|
||||
return customerStories.find((s) => s.slug === slug)
|
||||
}
|
||||
|
||||
export function getNextStory(slug: string): CustomerStory {
|
||||
const index = customerStories.findIndex((s) => s.slug === slug)
|
||||
return customerStories[(index + 1) % customerStories.length]
|
||||
}
|
||||
@@ -8,17 +8,20 @@ const baseRoutes = {
|
||||
cloudEnterprise: '/cloud/enterprise',
|
||||
api: '/api',
|
||||
gallery: '/gallery',
|
||||
launches: '/launches',
|
||||
about: '/about',
|
||||
careers: '/careers',
|
||||
customers: '/customers',
|
||||
demos: '/demos',
|
||||
learning: '/learning',
|
||||
education: '/edu',
|
||||
termsOfService: '/terms-of-service',
|
||||
privacyPolicy: '/privacy-policy',
|
||||
affiliates: '/affiliates',
|
||||
affiliateTerms: '/affiliates/terms',
|
||||
contact: '/contact',
|
||||
models: '/p/supported-models'
|
||||
models: '/p/supported-models',
|
||||
mcp: '/mcp'
|
||||
} as const
|
||||
|
||||
type Routes = typeof baseRoutes
|
||||
@@ -59,13 +62,19 @@ export const externalLinks = {
|
||||
discord: 'https://discord.com/invite/comfyorg',
|
||||
docs: 'https://docs.comfy.org/',
|
||||
docsApi: 'https://docs.comfy.org/api-reference/cloud',
|
||||
docsMcp: 'https://docs.comfy.org/agent-tools/cloud',
|
||||
docsSubscription: 'https://docs.comfy.org/support/subscription/subscribing',
|
||||
github: 'https://github.com/Comfy-Org/ComfyUI',
|
||||
githubInstall: 'https://github.com/Comfy-Org/ComfyUI#installing',
|
||||
instagram: 'https://www.instagram.com/comfyui/',
|
||||
linkedin: 'https://www.linkedin.com/company/comfyui/',
|
||||
mcpServer: 'https://cloud.comfy.org/mcp',
|
||||
mcpSkills: 'https://github.com/Comfy-Org/comfy-skills',
|
||||
platform: 'https://platform.comfy.org',
|
||||
platformUsage: 'https://platform.comfy.org/profile/usage',
|
||||
reddit: 'https://www.reddit.com/r/comfyui/',
|
||||
studentAmbassadorForm:
|
||||
'https://docs.google.com/forms/d/e/1FAIpQLScY3Ui44Mgho-4OQ1UAH3JLc3LCkY5kHDkHY7EZI6etTkVpZQ/viewform',
|
||||
support: 'https://support.comfy.org/hc/en-us',
|
||||
workflows: 'https://comfy.org/workflows',
|
||||
x: 'https://x.com/ComfyUI',
|
||||
|
||||
17
apps/website/src/content.config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { defineCollection } from 'astro:content'
|
||||
import { glob } from 'astro/loaders'
|
||||
|
||||
import { customerStorySchema } from './content/customers.schema'
|
||||
|
||||
const customers = defineCollection({
|
||||
// Preserve the exact path as the id (default slugification lowercases the
|
||||
// `zh-CN` locale folder, which would break locale filtering).
|
||||
loader: glob({
|
||||
base: './src/content/customers',
|
||||
pattern: '**/*.mdx',
|
||||
generateId: ({ entry }) => entry.replace(/\.mdx$/, '')
|
||||
}),
|
||||
schema: customerStorySchema
|
||||
})
|
||||
|
||||
export const collections = { customers }
|
||||