Compare commits
2 Commits
feat/websi
...
v2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0dd68bec34 | ||
|
|
4626cb80a8 |
86
.claude/settings.json
Normal file
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(tsc *)",
|
||||
"command": "echo 'Use `pnpm typecheck` instead of running tsc directly.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(vue-tsc *)",
|
||||
"command": "echo 'Use `pnpm typecheck` instead of running vue-tsc directly.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx tsc *)",
|
||||
"command": "echo 'Use `pnpm typecheck` instead of running tsc via npx.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(pnpx tsc *)",
|
||||
"command": "echo 'Use `pnpm typecheck` instead of running tsc via pnpx.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(pnpm exec tsc *)",
|
||||
"command": "echo 'Use `pnpm typecheck` instead of `pnpm exec tsc`.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx vitest *)",
|
||||
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit <path>`) instead of npx vitest.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(pnpx vitest *)",
|
||||
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit <path>`) instead of pnpx vitest.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx eslint *)",
|
||||
"command": "echo 'Use `pnpm lint` or `pnpm lint:fix` instead of npx eslint.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(pnpx eslint *)",
|
||||
"command": "echo 'Use `pnpm lint` or `pnpm lint:fix` instead of pnpx eslint.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx prettier *)",
|
||||
"command": "echo 'This project uses oxfmt, not prettier. Use `pnpm format` or `pnpm format:check`.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(pnpx prettier *)",
|
||||
"command": "echo 'This project uses oxfmt, not prettier. Use `pnpm format` or `pnpm format:check`.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx oxlint *)",
|
||||
"command": "echo 'Use `pnpm oxlint` instead of npx oxlint.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx stylelint *)",
|
||||
"command": "echo 'Use `pnpm stylelint` instead of npx stylelint.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(npx knip *)",
|
||||
"command": "echo 'Use `pnpm knip` instead of npx knip.' >&2 && exit 2"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(pnpx knip *)",
|
||||
"command": "echo 'Use `pnpm knip` instead of pnpx knip.' >&2 && exit 2"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -63,14 +63,3 @@ reviews:
|
||||
Pass if none of these patterns are found in the diff.
|
||||
|
||||
When warning, reference the specific ADR by number and link to `docs/adr/` for context. Frame findings as directional guidance since ADR 0003 and 0008 are in Proposed status.
|
||||
|
||||
path_instructions:
|
||||
- path: '**/*.test.ts'
|
||||
instructions: |
|
||||
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`, and `docs/guidance/vitest.md` as required review context for every changed Vitest test file.
|
||||
- path: 'src/lib/litegraph/**/*.test.ts'
|
||||
instructions: |
|
||||
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`, `docs/guidance/vitest.md`, and `docs/testing/litegraph-testing.md` as required review context for every changed litegraph Vitest test file.
|
||||
- path: '{browser_tests,apps/website/e2e}/**/*.spec.ts'
|
||||
instructions: |
|
||||
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`, and `docs/guidance/playwright.md` as required review context for every changed Playwright test file.
|
||||
|
||||
197
.github/workflows/backport-auto-merge.yaml
vendored
@@ -1,197 +0,0 @@
|
||||
---
|
||||
name: Backport Auto-Merge
|
||||
|
||||
# Completes the merge of backport PRs once they are approved and their required
|
||||
# checks pass.
|
||||
#
|
||||
# Background: pr-backport.yaml opens each backport PR (labelled `backport`) and
|
||||
# calls `gh pr merge --auto`, which relies on the repo-level "Allow auto-merge"
|
||||
# setting. That setting is off, so `--auto` is a silent no-op and backport PRs
|
||||
# sit unmerged until a human clicks merge. This workflow performs the merge
|
||||
# directly (a plain `gh pr merge --squash`, which does not depend on that
|
||||
# setting) once GitHub itself reports the PR as ready to merge.
|
||||
#
|
||||
# Safety: branch protection on core/** and cloud/** is the hard gate — it
|
||||
# unconditionally requires an approval + the required status checks and cannot
|
||||
# be bypassed, and GitHub's merge API re-enforces it at merge time. This
|
||||
# workflow can only ever complete a merge that already satisfies those rules;
|
||||
# the eligibility check below only avoids pointless merge attempts.
|
||||
#
|
||||
# The merge uses PR_GH_TOKEN (not the default GITHUB_TOKEN) on purpose: a merge
|
||||
# performed by the default token does not emit events that trigger other
|
||||
# workflows, which would silently starve cloud-backport-tag.yaml (it runs on the
|
||||
# backport PR's `pull_request: closed` event to create the release tag).
|
||||
|
||||
on:
|
||||
# Fires when someone approves — if the required checks are already green, the
|
||||
# PR merges immediately.
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
# Primary catch for the "approved first, checks went green later" case, plus a
|
||||
# general backstop. A `check_suite`/`workflow_run` trigger would react faster to
|
||||
# checks completing, but GitHub suppresses `check_suite` events for its own
|
||||
# Actions suites (so it wouldn't fire for this repo's CI), and `workflow_run` is
|
||||
# a secrets-bearing "dangerous" trigger we don't want on a public repo for a
|
||||
# non-latency-critical task. Backports wait hours today, so a short sweep is a
|
||||
# large improvement and needs neither.
|
||||
schedule:
|
||||
- cron: '*/15 * * * *'
|
||||
|
||||
# Only constrains the default github.token (used for read-only PR lookups below).
|
||||
# It does NOT constrain PR_GH_TOKEN, whose authority is fixed by its own scopes.
|
||||
permissions:
|
||||
contents: read # read-only; required for gh api / gh pr list to resolve candidates
|
||||
pull-requests: read # read-only; required for gh pr view eligibility checks
|
||||
|
||||
# Serialize runs that act on the same PR (review events keyed by PR number; all
|
||||
# scheduled sweeps share one key). Cross-key overlaps are still possible but
|
||||
# harmless: the merge loop treats an already-merged PR as success (idempotent).
|
||||
concurrency:
|
||||
group: backport-auto-merge-${{ github.event.pull_request.number || 'sweep' }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
merge:
|
||||
name: Merge eligible backport PRs
|
||||
# Skip review events that can't possibly make a PR mergeable — non-approval
|
||||
# reviews, or reviews on non-backport PRs (most reviews in the repo) — before
|
||||
# spending any API call. Schedule sweeps always proceed. The per-PR
|
||||
# eligibility checks in the job still re-verify the label and decision from
|
||||
# live state.
|
||||
if: github.event_name != 'pull_request_review' || (github.event.review.state == 'approved' && contains(github.event.pull_request.labels.*.name, 'backport'))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # read-only PR/commit lookups via the default token
|
||||
pull-requests: read # read-only PR metadata via the default token
|
||||
steps:
|
||||
- name: Collect candidate backport PRs
|
||||
id: candidates
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
PR_FROM_REVIEW: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
numbers=""
|
||||
case "$EVENT_NAME" in
|
||||
pull_request_review)
|
||||
numbers="$PR_FROM_REVIEW"
|
||||
;;
|
||||
schedule)
|
||||
# Sweep every open backport PR.
|
||||
numbers=$(gh pr list --repo "$GH_REPO" --state open --label backport \
|
||||
--limit 100 --json number --jq '.[].number')
|
||||
;;
|
||||
esac
|
||||
# De-duplicate and emit space-separated, digit-only tokens.
|
||||
numbers=$(echo "$numbers" | tr ' ' '\n' | grep -E '^[0-9]+$' | sort -u | tr '\n' ' ' || true)
|
||||
echo "numbers=${numbers}" >> "$GITHUB_OUTPUT"
|
||||
echo "Candidate PRs: '${numbers:-<none>}'"
|
||||
|
||||
- name: Merge eligible backport PRs
|
||||
if: steps.candidates.outputs.numbers != ''
|
||||
env:
|
||||
GH_REPO: ${{ github.repository }}
|
||||
# Read with the default token; merge with PR_GH_TOKEN so the merge emits
|
||||
# the events that downstream workflows (cloud-backport-tag.yaml) rely on.
|
||||
READ_TOKEN: ${{ github.token }}
|
||||
MERGE_TOKEN: ${{ secrets.PR_GH_TOKEN }}
|
||||
CANDIDATES: ${{ steps.candidates.outputs.numbers }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
is_merged() {
|
||||
[ "$(GH_TOKEN="$READ_TOKEN" gh pr view "$1" --repo "$GH_REPO" --json merged --jq '.merged' 2>/dev/null || echo false)" = "true" ]
|
||||
}
|
||||
|
||||
for pr in $CANDIDATES; do
|
||||
echo "::group::PR #${pr}"
|
||||
|
||||
info=$(GH_TOKEN="$READ_TOKEN" gh pr view "$pr" --repo "$GH_REPO" \
|
||||
--json number,state,isDraft,labels,baseRefName,reviewDecision,mergeStateStatus 2>/dev/null || echo '')
|
||||
if [ -z "$info" ]; then
|
||||
echo "Could not read PR #${pr} — skipping."; echo "::endgroup::"; continue
|
||||
fi
|
||||
|
||||
state=$(echo "$info" | jq -r '.state')
|
||||
is_draft=$(echo "$info" | jq -r '.isDraft')
|
||||
is_backport=$(echo "$info" | jq -r '[.labels[].name] | any(. == "backport")')
|
||||
base=$(echo "$info" | jq -r '.baseRefName')
|
||||
review=$(echo "$info" | jq -r '.reviewDecision')
|
||||
merge_state=$(echo "$info" | jq -r '.mergeStateStatus')
|
||||
|
||||
# Only ever act on open, non-draft, backport-labelled PRs targeting a
|
||||
# protected release branch.
|
||||
if [ "$state" != "OPEN" ] || [ "$is_draft" != "false" ] || [ "$is_backport" != "true" ]; then
|
||||
echo "Not an actionable backport PR (state=$state draft=$is_draft backport=$is_backport) — skipping."
|
||||
echo "::endgroup::"; continue
|
||||
fi
|
||||
case "$base" in
|
||||
cloud/*|core/*) : ;;
|
||||
*) echo "Base '$base' is not a release branch — skipping."; echo "::endgroup::"; continue ;;
|
||||
esac
|
||||
|
||||
# Ready = approved AND GitHub says it's mergeable with required checks green.
|
||||
# CLEAN = approved, all required checks green, mergeable, no conflict.
|
||||
# UNSTABLE = same, but a NON-required check is pending/failing — GitHub
|
||||
# still allows the merge, so we do too (matches what a human
|
||||
# clicking "Squash and merge" can do; required checks are the
|
||||
# only merge gate per the ruleset). Requiring CLEAN alone would
|
||||
# stick forever behind flaky/slow non-required checks.
|
||||
# Any other state (BLOCKED/DIRTY/BEHIND/UNKNOWN/...) => not ready; re-checked
|
||||
# by a later event or the next sweep.
|
||||
if [ "$review" != "APPROVED" ] || { [ "$merge_state" != "CLEAN" ] && [ "$merge_state" != "UNSTABLE" ]; }; then
|
||||
echo "Not yet ready (reviewDecision=$review mergeStateStatus=$merge_state) — will re-check later."
|
||||
echo "::endgroup::"; continue
|
||||
fi
|
||||
|
||||
echo "PR #${pr} is ready — attempting squash merge."
|
||||
attempt=0
|
||||
max=3
|
||||
merged=false
|
||||
while [ "$attempt" -lt "$max" ]; do
|
||||
attempt=$((attempt + 1))
|
||||
# A concurrent run (or a human) may have merged it already.
|
||||
if is_merged "$pr"; then merged=true; break; fi
|
||||
if out=$(GH_TOKEN="$MERGE_TOKEN" gh pr merge "$pr" --repo "$GH_REPO" --squash 2>&1); then
|
||||
merged=true; break
|
||||
fi
|
||||
echo "Merge attempt ${attempt}/${max} failed: ${out}"
|
||||
# No sleep after the final attempt.
|
||||
[ "$attempt" -lt "$max" ] && sleep $((attempt * 15))
|
||||
done
|
||||
|
||||
# Final reconciliation: a failed merge command may just mean a concurrent
|
||||
# run won the race — don't post a false failure if the PR is in fact merged.
|
||||
if [ "$merged" != "true" ] && is_merged "$pr"; then merged=true; fi
|
||||
|
||||
if [ "$merged" = "true" ]; then
|
||||
echo "PR #${pr} merged."
|
||||
else
|
||||
echo "::warning::PR #${pr} looked ready but did not merge after ${max} attempts."
|
||||
# Avoid spamming a persistently-stuck PR: only re-warn if the last
|
||||
# warning (identified by its marker) is more than an hour old.
|
||||
marker='<!-- backport-auto-merge:merge-failed -->'
|
||||
# `gh api --paginate` emits one JSON array per page; `--jq` would run
|
||||
# per page (missing the true latest across pages), so slurp all pages
|
||||
# into one array first and filter with a separate jq pass.
|
||||
last_warned=$(GH_TOKEN="$READ_TOKEN" gh api "repos/${GH_REPO}/issues/${pr}/comments" --paginate 2>/dev/null \
|
||||
| jq -s "[.[][] | select(.body | contains(\"${marker}\"))] | sort_by(.created_at) | last | .created_at // empty") || last_warned=''
|
||||
stale=true
|
||||
if [ -n "$last_warned" ]; then
|
||||
last_epoch=$(date -d "$last_warned" +%s 2>/dev/null || echo 0)
|
||||
now_epoch=$(date -u +%s)
|
||||
[ $((now_epoch - last_epoch)) -lt 3600 ] && stale=false
|
||||
fi
|
||||
if [ "$stale" = "true" ]; then
|
||||
body=$(printf '%s\n\n%s' \
|
||||
"This backport PR is approved and its required checks are green, but automatic merge failed after ${max} attempts. Please merge manually or investigate (possible branch-protection mismatch)." \
|
||||
"$marker")
|
||||
GH_TOKEN="$MERGE_TOKEN" gh pr comment "$pr" --repo "$GH_REPO" --body "$body" || true
|
||||
else
|
||||
echo "Already warned within the last hour — skipping duplicate comment."
|
||||
fi
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
done
|
||||
21
.github/workflows/ci-dist-telemetry-scan.yaml
vendored
@@ -134,27 +134,6 @@ jobs:
|
||||
fi
|
||||
echo '✅ No Customer.io references found'
|
||||
|
||||
- name: Scan dist for Syft telemetry references
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo '🔍 Scanning for Syft references...'
|
||||
if rg --no-ignore -n \
|
||||
-g '*.html' \
|
||||
-g '*.js' \
|
||||
-e '(?i)syft' \
|
||||
-e '(?i)sy-d\.io' \
|
||||
dist; then
|
||||
echo '❌ ERROR: Syft references found in dist assets!'
|
||||
echo 'Syft must be properly tree-shaken from OSS builds.'
|
||||
echo ''
|
||||
echo 'To fix this:'
|
||||
echo '1. Use the TelemetryProvider pattern (see src/platform/telemetry/)'
|
||||
echo '2. Call telemetry via useTelemetry() hook'
|
||||
echo '3. Use conditional dynamic imports behind isCloud checks'
|
||||
exit 1
|
||||
fi
|
||||
echo '✅ No Syft references found'
|
||||
|
||||
- name: Scan dist for Cloudflare Turnstile sitekey references
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
2
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
@@ -121,7 +121,7 @@ jobs:
|
||||
--title "ComfyUI E2E Coverage" \
|
||||
--no-function-coverage \
|
||||
--precision 1 \
|
||||
--ignore-errors source,unmapped \
|
||||
--ignore-errors source,unmapped,range \
|
||||
--synthesize-missing
|
||||
|
||||
- name: Upload HTML report artifact
|
||||
|
||||
1
.github/workflows/ci-tests-storybook.yaml
vendored
@@ -95,7 +95,6 @@ jobs:
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch'
|
||||
|| (github.event_name == 'pull_request'
|
||||
&& github.event.pull_request.head.repo.fork == false
|
||||
&& startsWith(github.head_ref, 'version-bump-')
|
||||
&& (needs.changes.outputs.storybook-changes == 'true'
|
||||
|| needs.changes.outputs.app-frontend-changes == '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
|
||||
|
||||
@@ -30,7 +30,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
deploy-preview:
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
3
.github/workflows/ci-website-build.yaml
vendored
@@ -40,6 +40,3 @@ jobs:
|
||||
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
|
||||
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
|
||||
run: pnpm --filter @comfyorg/website build
|
||||
|
||||
- name: Validate JSON-LD structured data
|
||||
run: pnpm --filter @comfyorg/website validate:jsonld
|
||||
|
||||
10
.github/workflows/ci-website-e2e.yaml
vendored
@@ -67,15 +67,7 @@ jobs:
|
||||
|
||||
- name: Deploy report to Cloudflare
|
||||
id: deploy
|
||||
if: >-
|
||||
${{
|
||||
always() &&
|
||||
!cancelled() &&
|
||||
(
|
||||
github.event_name != 'pull_request' ||
|
||||
github.event.pull_request.head.repo.fork == false
|
||||
)
|
||||
}}
|
||||
if: always() && !cancelled()
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
|
||||
44
.github/workflows/cla.yml
vendored
@@ -6,7 +6,6 @@ on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, closed]
|
||||
merge_group:
|
||||
types: [checks_requested]
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
@@ -18,47 +17,13 @@ jobs:
|
||||
cla-assistant:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: CLA already verified before merge queue
|
||||
if: github.event_name == 'merge_group'
|
||||
run: echo "CLA is checked on the pull request before it enters merge queue."
|
||||
|
||||
# The CLA action normally requires every commit author in a PR to sign.
|
||||
# We only want the PR author to sign, so we allowlist all other committers
|
||||
# by computing them from the PR's commits and excluding the PR author.
|
||||
- name: Build author-only allowlist
|
||||
id: allowlist
|
||||
if: >
|
||||
github.event_name == 'pull_request_target' ||
|
||||
(github.event_name == 'issue_comment' && github.event.issue.pull_request && (
|
||||
github.event.comment.body == 'recheck' ||
|
||||
github.event.comment.body == 'I have read and agree to the Contributor License Agreement'
|
||||
))
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
|
||||
PR_AUTHOR: ${{ github.event.pull_request.user.login || github.event.issue.user.login }}
|
||||
BASE_ALLOWLIST: action@github.com,actions-user,ampagent,claude,comfy-pr-bot,GitHub Action,github-actions,github-actions[bot],Glary Bot,Glary-Bot,*[bot]
|
||||
# For each commit emit the GitHub login when the author/committer email resolves to a GitHub account
|
||||
# otherwise fall back to the raw git name.
|
||||
run: |
|
||||
others=$(gh api "repos/${{ github.repository }}/pulls/${PR_NUMBER}/commits" --paginate \
|
||||
--jq '.[] | (.author.login // .commit.author.name // empty), (.committer.login // .commit.committer.name // empty)' \
|
||||
| sort -u | grep -vix "${PR_AUTHOR}" | paste -sd, -)
|
||||
if [ -n "$others" ]; then
|
||||
echo "allowlist=${BASE_ALLOWLIST},${others}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "allowlist=${BASE_ALLOWLIST}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: CLA Assistant
|
||||
# Run on PR events, on "recheck" comment, or when someone posts the exact signing phrase.
|
||||
# IMPORTANT: this phrase must match `custom-pr-sign-comment` below.
|
||||
if: >
|
||||
github.event_name == 'pull_request_target' ||
|
||||
(github.event_name == 'issue_comment' && github.event.issue.pull_request && (
|
||||
github.event.comment.body == 'recheck' ||
|
||||
github.event.comment.body == 'I have read and agree to the Contributor License Agreement'
|
||||
))
|
||||
github.event.comment.body == 'recheck' ||
|
||||
github.event.comment.body == 'I have read and agree to the Contributor License Agreement'
|
||||
uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -74,10 +39,9 @@ jobs:
|
||||
path-to-signatures: signatures/cla.json
|
||||
branch: main
|
||||
|
||||
# Only the PR author must sign: bots plus every non-author committer
|
||||
# are allowlisted via the "Build author-only allowlist" step above.
|
||||
# Allowlist bots so they don't need to sign (optional, comma-separated).
|
||||
# *[bot] is a catch-all for any GitHub App bot account.
|
||||
allowlist: ${{ steps.allowlist.outputs.allowlist }}
|
||||
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: |
|
||||
|
||||
13
.github/workflows/cloud-dispatch-build.yaml
vendored
@@ -32,13 +32,12 @@ jobs:
|
||||
if: >
|
||||
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
|
||||
(github.event_name != 'pull_request' ||
|
||||
(github.event.pull_request.head.repo.fork == false &&
|
||||
((github.event.action == 'labeled' &&
|
||||
contains(fromJSON('["preview","preview-cpu","preview-gpu"]'), github.event.label.name)) ||
|
||||
(github.event.action == 'synchronize' &&
|
||||
(contains(github.event.pull_request.labels.*.name, 'preview') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'preview-gpu'))))))
|
||||
(github.event.action == 'labeled' &&
|
||||
contains(fromJSON('["preview","preview-cpu","preview-gpu"]'), github.event.label.name)) ||
|
||||
(github.event.action == 'synchronize' &&
|
||||
(contains(github.event.pull_request.labels.*.name, 'preview') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'preview-gpu'))))
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Build client payload
|
||||
|
||||
@@ -21,7 +21,6 @@ jobs:
|
||||
# - Preview label specifically removed
|
||||
if: >
|
||||
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
|
||||
github.event.pull_request.head.repo.fork == false &&
|
||||
((github.event.action == 'closed' &&
|
||||
(contains(github.event.pull_request.labels.*.name, 'preview') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
|
||||
|
||||
75
.github/workflows/pr-backport.yaml
vendored
@@ -278,49 +278,32 @@ jobs:
|
||||
continue
|
||||
fi
|
||||
|
||||
# Create backport branch. A failure here (e.g. dirty state left
|
||||
# by a prior target) must not abort the loop and skip remaining
|
||||
# targets, so fall back to a clean checkout and record the error.
|
||||
if ! git checkout -B "${BACKPORT_BRANCH}" "origin/${TARGET_BRANCH}"; then
|
||||
echo "::error::Failed to create branch ${BACKPORT_BRANCH} for ${TARGET_BRANCH}"
|
||||
FAILED="${FAILED}${TARGET_BRANCH}:branch-create-failed "
|
||||
git checkout main || git checkout -f main
|
||||
echo "::endgroup::"
|
||||
continue
|
||||
fi
|
||||
# Create backport branch
|
||||
git checkout -b "${BACKPORT_BRANCH}" "origin/${TARGET_BRANCH}"
|
||||
|
||||
# Try cherry-pick
|
||||
if git cherry-pick "${MERGE_COMMIT}"; then
|
||||
if [ "$REMOTE_BACKPORT_EXISTS" = true ]; then
|
||||
PUSH_CMD=(git push --force-with-lease origin "${BACKPORT_BRANCH}")
|
||||
git push --force-with-lease origin "${BACKPORT_BRANCH}"
|
||||
else
|
||||
PUSH_CMD=(git push origin "${BACKPORT_BRANCH}")
|
||||
git push origin "${BACKPORT_BRANCH}"
|
||||
fi
|
||||
|
||||
# A push failure for one target must not abort the loop and
|
||||
# prevent remaining targets from being attempted.
|
||||
if "${PUSH_CMD[@]}"; then
|
||||
echo "${BACKPORT_BRANCH}" >> "$CREATED_BRANCHES_FILE"
|
||||
SUCCESS="${SUCCESS}${TARGET_BRANCH}:${BACKPORT_BRANCH} "
|
||||
echo "Successfully created backport branch: ${BACKPORT_BRANCH}"
|
||||
else
|
||||
echo "::error::Failed to push ${BACKPORT_BRANCH} for ${TARGET_BRANCH}"
|
||||
FAILED="${FAILED}${TARGET_BRANCH}:push-failed "
|
||||
fi
|
||||
|
||||
echo "${BACKPORT_BRANCH}" >> "$CREATED_BRANCHES_FILE"
|
||||
SUCCESS="${SUCCESS}${TARGET_BRANCH}:${BACKPORT_BRANCH} "
|
||||
echo "Successfully created backport branch: ${BACKPORT_BRANCH}"
|
||||
# Return to main (keep the branch, we need it for PR)
|
||||
git checkout main || git checkout -f main
|
||||
git checkout main
|
||||
else
|
||||
# Get conflict info
|
||||
CONFLICTS=$(git diff --name-only --diff-filter=U | tr '\n' ',')
|
||||
git cherry-pick --abort || true
|
||||
git cherry-pick --abort
|
||||
|
||||
echo "::error::Cherry-pick failed due to conflicts"
|
||||
FAILED="${FAILED}${TARGET_BRANCH}:conflicts:${CONFLICTS} "
|
||||
|
||||
# Clean up the failed branch
|
||||
git checkout main || git checkout -f main
|
||||
git branch -D "${BACKPORT_BRANCH}" || true
|
||||
git checkout main
|
||||
git branch -D "${BACKPORT_BRANCH}"
|
||||
fi
|
||||
|
||||
echo "::endgroup::"
|
||||
@@ -401,10 +384,6 @@ jobs:
|
||||
|
||||
**Reason:** Merge conflicts detected during cherry-pick of `${MERGE_COMMIT_SHORT}`
|
||||
|
||||
The auto-backport could not be completed automatically. Please backport
|
||||
manually onto branch `${BACKPORT_BRANCH}` (from `origin/${target}`) and
|
||||
open a PR to `${target}`.
|
||||
|
||||
<details>
|
||||
<summary>📄 Conflicting files</summary>
|
||||
|
||||
@@ -437,37 +416,19 @@ jobs:
|
||||
MERGE_COMMIT=$(jq -r '.pull_request.merge_commit_sha' "$GITHUB_EVENT_PATH")
|
||||
fi
|
||||
|
||||
# Post a comment without letting a single failed `gh pr comment` (e.g.
|
||||
# a locked issue, as happened for PR #13359, or a transient API error)
|
||||
# abort the step under `set -e` and swallow the remaining failures.
|
||||
post_comment() {
|
||||
local body="$1"
|
||||
local context="$2"
|
||||
if ! gh pr comment "${PR_NUMBER}" --body "${body}"; then
|
||||
echo "::warning::Could not comment on PR #${PR_NUMBER} about ${context}. Manual backport required."
|
||||
fi
|
||||
}
|
||||
|
||||
for failure in ${{ steps.backport.outputs.failed }}; do
|
||||
IFS=':' read -r target reason conflicts <<< "${failure}"
|
||||
|
||||
SAFE_TARGET=$(echo "$target" | tr '/' '-')
|
||||
BACKPORT_BRANCH="backport-${PR_NUMBER}-to-${SAFE_TARGET}"
|
||||
|
||||
if [ "${reason}" = "branch-missing" ]; then
|
||||
post_comment "@${PR_AUTHOR} Backport failed: Branch \`${target}\` does not exist" "missing branch ${target}"
|
||||
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport failed: Branch \`${target}\` does not exist"
|
||||
|
||||
elif [ "${reason}" = "already-exists" ]; then
|
||||
post_comment "@${PR_AUTHOR} Commit \`${MERGE_COMMIT}\` already exists on branch \`${target}\`. No backport needed." "already-backported ${target}"
|
||||
|
||||
elif [ "${reason}" = "branch-create-failed" ]; then
|
||||
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport to \`${target}\` failed: could not create the backport branch. Please retry or backport manually."
|
||||
|
||||
elif [ "${reason}" = "push-failed" ]; then
|
||||
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport to \`${target}\` cherry-picked cleanly but the push failed. Please retry or push the backport branch manually."
|
||||
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Commit \`${MERGE_COMMIT}\` already exists on branch \`${target}\`. No backport needed."
|
||||
|
||||
elif [ "${reason}" = "conflicts" ]; then
|
||||
CONFLICTS_INLINE=$(echo "${conflicts}" | tr ',' ' ')
|
||||
SAFE_TARGET=$(echo "$target" | tr '/' '-')
|
||||
BACKPORT_BRANCH="backport-${PR_NUMBER}-to-${SAFE_TARGET}"
|
||||
PR_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/pull/${PR_NUMBER}"
|
||||
|
||||
export PR_NUMBER PR_URL MERGE_COMMIT target BACKPORT_BRANCH CONFLICTS_INLINE
|
||||
@@ -483,10 +444,10 @@ jobs:
|
||||
CONFLICTS_BLOCK=$(echo "${conflicts}" | tr ',' '\n')
|
||||
MERGE_COMMIT_SHORT="${MERGE_COMMIT:0:7}"
|
||||
|
||||
export target MERGE_COMMIT_SHORT BACKPORT_BRANCH CONFLICTS_BLOCK AGENT_PROMPT PR_AUTHOR
|
||||
COMMENT_BODY=$(envsubst '${target} ${MERGE_COMMIT_SHORT} ${BACKPORT_BRANCH} ${CONFLICTS_BLOCK} ${AGENT_PROMPT} ${PR_AUTHOR}' <<<"$COMMENT_BODY_TEMPLATE")
|
||||
export target MERGE_COMMIT_SHORT CONFLICTS_BLOCK AGENT_PROMPT PR_AUTHOR
|
||||
COMMENT_BODY=$(envsubst '${target} ${MERGE_COMMIT_SHORT} ${CONFLICTS_BLOCK} ${AGENT_PROMPT} ${PR_AUTHOR}' <<<"$COMMENT_BODY_TEMPLATE")
|
||||
|
||||
post_comment "${COMMENT_BODY}" "cherry-pick conflict on ${target} (backport manually onto ${BACKPORT_BRANCH})"
|
||||
gh pr comment "${PR_NUMBER}" --body "${COMMENT_BODY}"
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
4
.github/workflows/pr-cursor-review.yaml
vendored
@@ -29,7 +29,7 @@ jobs:
|
||||
# SHA-pinned per zizmor `unpinned-uses: hash-pin`. Bump this SHA to pick up
|
||||
# upstream changes; keep `workflows_ref` matching so prompts/scripts load
|
||||
# from the same commit as the workflow definition.
|
||||
uses: Comfy-Org/github-workflows/.github/workflows/cursor-review.yml@df507e6bae179c567ad3849370f99dae588985dc # github-workflows main (df507e6)
|
||||
uses: Comfy-Org/github-workflows/.github/workflows/cursor-review.yml@047ca48febe3a6647608ed2e0c4331b491cb9d6a # github-workflows#9
|
||||
with:
|
||||
# Overriding diff_excludes replaces the reusable default wholesale, so
|
||||
# this restates the generated/vendored defaults and adds this repo's heavy
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
:!**/*-snapshots/**
|
||||
:!src/workbench/extensions/manager/types/generatedManagerTypes.ts
|
||||
# Load the prompts/scripts from the same ref as `uses:`.
|
||||
workflows_ref: df507e6bae179c567ad3849370f99dae588985dc
|
||||
workflows_ref: 047ca48febe3a6647608ed2e0c4331b491cb9d6a
|
||||
secrets:
|
||||
CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}
|
||||
# Optional — enables start/complete Slack DMs to the triggerer.
|
||||
|
||||
@@ -76,14 +76,10 @@ test.describe('Affiliates landing — desktop interactions', () => {
|
||||
return match?.textContent ?? null
|
||||
})
|
||||
expect(faqJsonLd, 'FAQ JSON-LD script').not.toBeNull()
|
||||
const graph = JSON.parse(faqJsonLd!)['@graph'] as {
|
||||
'@type': string
|
||||
mainEntity?: unknown[]
|
||||
}[]
|
||||
const faqPage = graph.find((node) => node['@type'] === 'FAQPage')
|
||||
expect(faqPage, 'FAQPage node in @graph').toBeDefined()
|
||||
expect(Array.isArray(faqPage!.mainEntity)).toBe(true)
|
||||
expect(faqPage!.mainEntity!.length).toBe(FAQ_COUNT)
|
||||
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('Apply Now CTA opens the application form in a new tab', async ({
|
||||
@@ -115,14 +111,8 @@ test.describe('Affiliates landing — desktop interactions', () => {
|
||||
await firstQuestion.scrollIntoViewIfNeeded()
|
||||
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'false')
|
||||
|
||||
// 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 firstQuestion.click()
|
||||
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'true')
|
||||
await expect(page.getByText(FIRST_FAQ.answer.en)).toBeVisible()
|
||||
|
||||
await firstQuestion.click()
|
||||
|
||||
@@ -8,7 +8,7 @@ test.describe('Careers page @smoke', () => {
|
||||
})
|
||||
|
||||
test('has correct title', async ({ page }) => {
|
||||
await expect(page).toHaveTitle('Careers - Comfy')
|
||||
await expect(page).toHaveTitle('Careers — Comfy')
|
||||
})
|
||||
|
||||
test('Roles section heading is visible', async ({ page }) => {
|
||||
@@ -72,7 +72,7 @@ test.describe('Careers page role links', () => {
|
||||
test.describe('Careers page (zh-CN) @smoke', () => {
|
||||
test('renders localized heading and roles', async ({ page }) => {
|
||||
await page.goto('/zh-CN/careers')
|
||||
await expect(page).toHaveTitle('招聘 - Comfy')
|
||||
await expect(page).toHaveTitle('招聘 — Comfy')
|
||||
await expect(
|
||||
page.getByRole('heading', { name: '职位', level: 2 })
|
||||
).toBeVisible()
|
||||
|
||||
@@ -9,7 +9,7 @@ test.describe('Cloud nodes page @smoke', () => {
|
||||
|
||||
test('has correct title', async ({ page }) => {
|
||||
await expect(page).toHaveTitle(
|
||||
'Custom-node packs on Comfy Cloud - supported by default'
|
||||
'Custom-node packs on Comfy Cloud — supported by default'
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ test.describe('Cloud page @smoke', () => {
|
||||
})
|
||||
|
||||
test('has correct title', async ({ page }) => {
|
||||
await expect(page).toHaveTitle('Comfy Cloud - AI in the Cloud')
|
||||
await expect(page).toHaveTitle('Comfy Cloud — AI in the Cloud')
|
||||
})
|
||||
|
||||
test('HeroSection heading and subtitle are visible', async ({ page }) => {
|
||||
@@ -40,7 +40,7 @@ test.describe('Cloud page @smoke', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('AIModelsSection heading and 6 model cards are visible', async ({
|
||||
test('AIModelsSection heading and 5 model cards are visible', async ({
|
||||
page
|
||||
}) => {
|
||||
const heading = page.getByRole('heading', { name: /leading AI models/i })
|
||||
@@ -49,7 +49,7 @@ test.describe('Cloud page @smoke', () => {
|
||||
const section = heading.locator('xpath=ancestor::section')
|
||||
const grid = section.locator('.grid')
|
||||
const modelCards = grid.locator('a[href="https://comfy.org/workflows"]')
|
||||
await expect(modelCards).toHaveCount(6)
|
||||
await expect(modelCards).toHaveCount(5)
|
||||
})
|
||||
|
||||
test('AIModelsSection CTA links to workflows', async ({ page }) => {
|
||||
@@ -87,11 +87,11 @@ test.describe('Cloud page @smoke', () => {
|
||||
await expect(cards).toHaveCount(3)
|
||||
})
|
||||
|
||||
test('FAQSection heading is visible with 12 items', async ({ page }) => {
|
||||
test('FAQSection heading is visible with 15 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(12)
|
||||
await expect(faqButtons).toHaveCount(15)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -70,58 +70,4 @@ test.describe('Customer story detail @smoke', () => {
|
||||
'/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 an Article JSON-LD graph for the story', async ({ page }) => {
|
||||
await page.goto('/customers/series-entertainment')
|
||||
|
||||
const blocks = await page
|
||||
.locator('script[type="application/ld+json"]')
|
||||
.allTextContents()
|
||||
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('WebPage')
|
||||
expect(types).toContain('BreadcrumbList')
|
||||
|
||||
// The Article headline tracks the visible story title, not a fixed string.
|
||||
const article = graph.find((node) => node['@type'] === 'Article')
|
||||
expect(article?.headline).toMatch(/Series Entertainment/i)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -30,37 +30,4 @@ 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()
|
||||
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')
|
||||
expect(types).toContain('BreadcrumbList')
|
||||
|
||||
// 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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,7 +16,7 @@ test.describe('Download page @smoke', () => {
|
||||
|
||||
test('has correct title', async ({ page }) => {
|
||||
await expect(page).toHaveTitle(
|
||||
'Download Comfy Desktop - Run AI on Your Hardware'
|
||||
'Download Comfy Desktop — Run AI on Your Hardware'
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,388 +0,0 @@
|
||||
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 = '/education'
|
||||
const LEARNING_PATH = '/learning'
|
||||
const PRICING_PATH = '/cloud/pricing'
|
||||
const STUDENT_AMBASSADOR_FORM = externalLinks.studentAmbassadorForm
|
||||
|
||||
const MONTHLY_LABEL = t('pricing.period.monthly.edu', 'en')
|
||||
const EDU_YEARLY_TOGGLE = t('pricing.period.yearly.edu', 'en')
|
||||
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')
|
||||
// The href is only useful if it resolves to a real target on the page.
|
||||
await expect(page.locator('#plans')).toBeAttached()
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
type JsonLdNode = Record<string, unknown>
|
||||
|
||||
// The page emits one connected @graph, so every node (FAQPage, Product,
|
||||
// breadcrumbs) is read from that single ld+json block.
|
||||
const readJsonLdGraph = async (page: Page): Promise<JsonLdNode[]> => {
|
||||
const raw = await page.evaluate(() => {
|
||||
const scripts = Array.from(
|
||||
document.querySelectorAll<HTMLScriptElement>(
|
||||
'script[type="application/ld+json"]'
|
||||
)
|
||||
)
|
||||
const graph = scripts.find((s) =>
|
||||
(s.textContent ?? '').includes('"@graph"')
|
||||
)
|
||||
return graph?.textContent ?? null
|
||||
})
|
||||
expect(raw, 'JSON-LD @graph script').not.toBeNull()
|
||||
return (JSON.parse(raw!) as { '@graph': JsonLdNode[] })['@graph']
|
||||
}
|
||||
|
||||
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 graph = await readJsonLdGraph(page)
|
||||
const faqPage = graph.find((node) => node['@type'] === 'FAQPage')
|
||||
expect(faqPage, 'FAQPage node').toBeTruthy()
|
||||
expect(Array.isArray(faqPage!.mainEntity)).toBe(true)
|
||||
expect((faqPage!.mainEntity as unknown[]).length).toBe(FAQ_COUNT)
|
||||
})
|
||||
|
||||
test('describes the discounted plans as a priced Product', async ({
|
||||
page
|
||||
}) => {
|
||||
const graph = await readJsonLdGraph(page)
|
||||
const product = graph.find((node) => node['@type'] === 'Product')
|
||||
const webPage = graph.find((node) => node['@type'] === 'WebPage')
|
||||
expect(product, 'Product node').toBeTruthy()
|
||||
expect(webPage?.mainEntity).toEqual({ '@id': product!['@id'] })
|
||||
|
||||
const offers = product!.offers as JsonLdNode[]
|
||||
expect(offers.length).toBeGreaterThan(0)
|
||||
for (const offer of offers) {
|
||||
expect(offer.priceCurrency).toBe('USD')
|
||||
expect(Number(offer.price)).toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
const breadcrumb = graph.find((node) => node['@type'] === 'BreadcrumbList')
|
||||
const names = (breadcrumb!.itemListElement as JsonLdNode[]).map(
|
||||
(item) => item.name
|
||||
)
|
||||
expect(names).toEqual([
|
||||
t('breadcrumb.home', 'en'),
|
||||
t('nav.education', 'en')
|
||||
])
|
||||
})
|
||||
|
||||
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')
|
||||
|
||||
// The trigger renders aria-expanded="false" server-side, so a click can
|
||||
// land before the island hydrates. Re-click until it actually toggles.
|
||||
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()
|
||||
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()
|
||||
|
||||
// The highlighted savings row tracks the yearly discount.
|
||||
await expect(
|
||||
section.getByText('Educational savings – 25% off').first()
|
||||
).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 prices flip with the billing toggle', async ({ page }) => {
|
||||
const section = pricingSection(page)
|
||||
await section.scrollIntoViewIfNeeded()
|
||||
|
||||
// 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()
|
||||
|
||||
// The highlighted savings row now reflects the monthly discount.
|
||||
await expect(
|
||||
section.getByText('Educational savings – 10% off').first()
|
||||
).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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
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 opens the Creative Campus application form', 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',
|
||||
externalLinks.creativeCampusApplicationForm
|
||||
)
|
||||
await expect(contact).toHaveAttribute('target', '_blank')
|
||||
})
|
||||
})
|
||||
|
||||
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 — zh-CN @smoke', () => {
|
||||
test('CTA plan anchor resolves to the pricing section', async ({ page }) => {
|
||||
await page.goto('/zh-CN/education')
|
||||
|
||||
const choosePlan = page.getByRole('link', {
|
||||
name: t('education.cta.choosePlan', 'zh-CN')
|
||||
})
|
||||
await expect(choosePlan).toHaveAttribute('href', '#plans')
|
||||
await expect(page.locator('#plans')).toBeAttached()
|
||||
})
|
||||
})
|
||||
|
||||
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
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -17,7 +17,7 @@ test.describe('Homepage @smoke', () => {
|
||||
})
|
||||
|
||||
test('has correct title', async ({ page }) => {
|
||||
await expect(page).toHaveTitle('Comfy - Professional Control of Visual AI')
|
||||
await expect(page).toHaveTitle('Comfy — Professional Control of Visual AI')
|
||||
})
|
||||
|
||||
test('HeroSection heading is visible', async ({ page }) => {
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { learningTutorials } from '../src/data/learningTutorials'
|
||||
import { t } from '../src/i18n/translations'
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
const tutorialButtonName = (title: string, locale: 'en' | 'zh-CN') =>
|
||||
`${t('learning.tutorials.titlePrefix', locale)} ${title}`
|
||||
|
||||
test.describe('Learning page @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/learning')
|
||||
})
|
||||
|
||||
test('has correct title', async ({ page }) => {
|
||||
await expect(page).toHaveTitle('Learning - Comfy')
|
||||
})
|
||||
|
||||
test('hero headline references ComfyUI', async ({ page }) => {
|
||||
const heading = page.getByRole('heading', { level: 1 })
|
||||
await expect(heading).toBeVisible()
|
||||
await expect(heading).toContainText(t('learning.heroTitle.before', 'en'))
|
||||
await expect(heading).toContainText('ComfyUI')
|
||||
await expect(heading).toContainText(t('learning.heroTitle.line2', 'en'))
|
||||
})
|
||||
|
||||
test('featured workflow section shows title and author', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name: t('learning.featured.title', 'en'),
|
||||
level: 2
|
||||
})
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByText(t('learning.featured.author', 'en'))
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('renders every tutorial from the data source', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name: t('learning.tutorials.heading', 'en'),
|
||||
level: 2
|
||||
})
|
||||
).toBeVisible()
|
||||
|
||||
for (const tutorial of learningTutorials) {
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: tutorialButtonName(tutorial.title.en, 'en')
|
||||
})
|
||||
).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('tutorials with a workflow link expose an external Try Workflow link', async ({
|
||||
page
|
||||
}) => {
|
||||
const linkedTutorials = learningTutorials.filter(
|
||||
(tutorial) => tutorial.href
|
||||
)
|
||||
const workflowLinks = page.getByRole('link', {
|
||||
name: t('cta.tryWorkflow', 'en')
|
||||
})
|
||||
const hrefs = await workflowLinks.evaluateAll((links) =>
|
||||
links.map((link) => link.getAttribute('href'))
|
||||
)
|
||||
for (const tutorial of linkedTutorials) {
|
||||
expect(hrefs).toContain(tutorial.href)
|
||||
}
|
||||
})
|
||||
|
||||
test('call to action links to contact sales', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name: t('learning.cta.heading', 'en'),
|
||||
level: 2
|
||||
})
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('link', { name: t('learning.cta.contactSales', 'en') })
|
||||
).toHaveAttribute('href', '/contact')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Learning tutorial dialog', () => {
|
||||
test('opens a tutorial video and dismisses via the close button', async ({
|
||||
page
|
||||
}) => {
|
||||
const [firstTutorial] = learningTutorials
|
||||
await page.goto('/learning')
|
||||
|
||||
const openButton = page.getByRole('button', {
|
||||
name: tutorialButtonName(firstTutorial.title.en, 'en')
|
||||
})
|
||||
await openButton.scrollIntoViewIfNeeded()
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: firstTutorial.title.en })
|
||||
// TutorialsSection is hydrated via `client:visible`; retry the click until
|
||||
// Vue responds by opening the dialog.
|
||||
await expect(async () => {
|
||||
await openButton.click()
|
||||
await expect(dialog).toBeVisible({ timeout: 1_000 })
|
||||
}).toPass({ timeout: 10_000 })
|
||||
|
||||
await expect(
|
||||
dialog.getByRole('heading', { level: 2, name: firstTutorial.title.en })
|
||||
).toBeVisible()
|
||||
|
||||
await dialog
|
||||
.getByRole('button', { name: t('gallery.detail.close', 'en') })
|
||||
.click()
|
||||
await expect(dialog).toBeHidden()
|
||||
})
|
||||
|
||||
test('dismisses the dialog with the Escape key', async ({ page }) => {
|
||||
const [firstTutorial] = learningTutorials
|
||||
await page.goto('/learning')
|
||||
|
||||
const openButton = page.getByRole('button', {
|
||||
name: tutorialButtonName(firstTutorial.title.en, 'en')
|
||||
})
|
||||
await openButton.scrollIntoViewIfNeeded()
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: firstTutorial.title.en })
|
||||
await expect(async () => {
|
||||
await openButton.click()
|
||||
await expect(dialog).toBeVisible({ timeout: 1_000 })
|
||||
}).toPass({ timeout: 10_000 })
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(dialog).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Learning page (zh-CN) @smoke', () => {
|
||||
test('renders localized title, headings, and tutorials', async ({ page }) => {
|
||||
await page.goto('/zh-CN/learning')
|
||||
|
||||
await expect(page).toHaveTitle('学习 - Comfy')
|
||||
await expect(page.getByRole('heading', { level: 1 })).toContainText(
|
||||
/[一-鿿]/
|
||||
)
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name: t('learning.tutorials.heading', 'zh-CN'),
|
||||
level: 2
|
||||
})
|
||||
).toBeVisible()
|
||||
|
||||
const [firstTutorial] = learningTutorials
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: tutorialButtonName(firstTutorial.title['zh-CN'], 'zh-CN')
|
||||
})
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -31,26 +31,6 @@ test.describe('Desktop navigation @smoke', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('NEW badge shows on Products and Community only', async ({ page }) => {
|
||||
const nav = page.getByRole('navigation', { name: 'Main navigation' })
|
||||
const desktopLinks = nav.getByTestId('desktop-nav-links')
|
||||
|
||||
for (const label of ['Products', 'Community']) {
|
||||
await expect(
|
||||
desktopLinks
|
||||
.getByRole('button', { name: label })
|
||||
.getByText('NEW', { exact: true })
|
||||
).toBeVisible()
|
||||
}
|
||||
|
||||
await expect(
|
||||
desktopLinks.getByRole('button', { name: 'Company' }).getByText('NEW')
|
||||
).toHaveCount(0)
|
||||
await expect(
|
||||
desktopLinks.getByRole('link', { name: 'Pricing' }).getByText('NEW')
|
||||
).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('CTA buttons are visible', async ({ page }) => {
|
||||
const nav = page.getByRole('navigation', { name: 'Main navigation' })
|
||||
const desktopCTA = nav.getByTestId('desktop-nav-cta')
|
||||
@@ -137,27 +117,6 @@ test.describe('Mobile menu @mobile', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('NEW badge shows on Products and Community only', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Toggle menu' }).click()
|
||||
|
||||
const menu = page.getByRole('dialog')
|
||||
|
||||
for (const label of ['Products', 'Community']) {
|
||||
await expect(
|
||||
menu.getByRole('button', { name: label }).getByText('NEW', {
|
||||
exact: true
|
||||
})
|
||||
).toBeVisible()
|
||||
}
|
||||
|
||||
await expect(
|
||||
menu.getByRole('button', { name: 'Company' }).getByText('NEW')
|
||||
).toHaveCount(0)
|
||||
await expect(
|
||||
menu.getByRole('link', { name: 'Pricing' }).getByText('NEW')
|
||||
).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('clicking section with subitems drills down and back works', async ({
|
||||
page
|
||||
}) => {
|
||||
|
||||
@@ -22,7 +22,7 @@ test.describe('Payment success page @smoke', () => {
|
||||
})
|
||||
|
||||
test('has correct title and is noindex', async ({ page }) => {
|
||||
await expect(page).toHaveTitle('Payment Successful - Comfy')
|
||||
await expect(page).toHaveTitle('Payment Successful — Comfy')
|
||||
await expectNoIndex(page)
|
||||
})
|
||||
|
||||
@@ -54,7 +54,7 @@ test.describe('Payment failed page @smoke', () => {
|
||||
})
|
||||
|
||||
test('has correct title and is noindex', async ({ page }) => {
|
||||
await expect(page).toHaveTitle('Payment Failed - Comfy')
|
||||
await expect(page).toHaveTitle('Payment Failed — Comfy')
|
||||
await expectNoIndex(page)
|
||||
})
|
||||
|
||||
@@ -84,7 +84,7 @@ test.describe('Payment failed page @smoke', () => {
|
||||
test.describe('Payment pages zh-CN @smoke', () => {
|
||||
test('zh-CN success page renders and links correctly', async ({ page }) => {
|
||||
await page.goto('/zh-CN/payment/success')
|
||||
await expect(page).toHaveTitle('支付成功 - Comfy')
|
||||
await expect(page).toHaveTitle('支付成功 — Comfy')
|
||||
await expectNoIndex(page)
|
||||
await expect(
|
||||
page.getByRole('heading', { name: '支付成功', level: 1 })
|
||||
@@ -99,7 +99,7 @@ test.describe('Payment pages zh-CN @smoke', () => {
|
||||
|
||||
test('zh-CN failed page renders and links correctly', async ({ page }) => {
|
||||
await page.goto('/zh-CN/payment/failed')
|
||||
await expect(page).toHaveTitle('支付失败 - Comfy')
|
||||
await expect(page).toHaveTitle('支付失败 — Comfy')
|
||||
await expectNoIndex(page)
|
||||
await expect(
|
||||
page.getByRole('heading', { name: '无法完成支付', level: 1 })
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
@@ -8,40 +7,41 @@ test.describe('Pricing page @smoke', () => {
|
||||
await page.goto('/cloud/pricing')
|
||||
})
|
||||
|
||||
const pricingSection = (page: Page) =>
|
||||
page.locator('section').filter({
|
||||
has: page.getByRole('heading', { name: /Choose a plan/i })
|
||||
})
|
||||
|
||||
test('shows the three paid tiers and Enterprise', async ({ page }) => {
|
||||
const section = pricingSection(page)
|
||||
const pricingGrid = page
|
||||
.locator('section', {
|
||||
has: page.getByRole('heading', { name: /Pricing/i })
|
||||
})
|
||||
.locator('.lg\\:grid')
|
||||
|
||||
for (const label of ['STANDARD', 'CREATOR', 'PRO', 'ENTERPRISE']) {
|
||||
for (const label of ['STANDARD', 'CREATOR', 'PRO']) {
|
||||
await expect(
|
||||
section.locator('span', { hasText: new RegExp(`^${label}$`) })
|
||||
pricingGrid.locator('span', { hasText: new RegExp(`^${label}$`) })
|
||||
).toBeVisible()
|
||||
}
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /Looking for Enterprise Solutions/i })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('does not show the Free tier when SHOW_FREE_TIER is disabled', async ({
|
||||
page
|
||||
}) => {
|
||||
const section = pricingSection(page)
|
||||
const pricingGrid = page
|
||||
.locator('section', {
|
||||
has: page.getByRole('heading', { name: /Pricing/i })
|
||||
})
|
||||
.locator('.lg\\:grid')
|
||||
|
||||
await expect(section.locator('span', { hasText: /^FREE$/ })).toHaveCount(0)
|
||||
await expect(
|
||||
pricingGrid.locator('span', { hasText: /^FREE$/ })
|
||||
).toHaveCount(0)
|
||||
await expect(page.getByRole('link', { name: /^START FREE$/ })).toHaveCount(
|
||||
0
|
||||
)
|
||||
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: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 95 KiB |
@@ -17,8 +17,7 @@
|
||||
"test:visual:update": "playwright test --project visual --update-snapshots",
|
||||
"ashby:refresh-snapshot": "tsx ./scripts/refresh-ashby-snapshot.ts",
|
||||
"cloud-nodes:refresh-snapshot": "tsx ./scripts/refresh-cloud-nodes-snapshot.ts",
|
||||
"generate:models": "tsx ./scripts/generate-models.ts",
|
||||
"validate:jsonld": "tsx ./scripts/validate-jsonld.ts"
|
||||
"generate:models": "tsx ./scripts/generate-models.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/sitemap": "catalog:",
|
||||
|
||||
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 938 B After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 56 KiB |
@@ -1,10 +0,0 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1483_15836)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M196.373 184.704V136.491C196.373 132.437 197.909 129.387 201.451 127.36L298.368 71.552C311.573 63.936 327.296 60.3947 343.531 60.3947C404.416 60.3947 442.987 107.584 442.987 157.803C442.987 161.365 442.987 165.419 442.475 169.472L341.995 110.613C339.266 108.876 336.099 107.954 332.864 107.954C329.629 107.954 326.462 108.876 323.733 110.613L196.373 184.704ZM422.699 372.437V257.28C422.699 250.176 419.648 245.12 413.547 241.557L286.187 167.467L327.787 143.616C329.294 142.624 331.059 142.095 332.864 142.095C334.669 142.095 336.434 142.624 337.941 143.616L434.859 199.445C462.784 215.659 481.557 250.176 481.557 283.669C481.557 322.24 458.731 357.76 422.677 372.48L422.699 372.437ZM166.443 270.997L124.843 246.635C121.28 244.608 119.744 241.557 119.744 237.504V125.845C119.744 71.552 161.344 30.4427 217.685 30.4427C239.019 30.4427 258.795 37.5467 275.541 50.24L175.573 108.096C169.493 111.637 166.443 116.715 166.443 123.819V270.976V270.997ZM256 322.731L196.373 289.237V218.197L256 184.704L315.627 218.197V289.237L256 322.731ZM294.315 476.971C272.981 476.971 253.205 469.888 236.459 457.195L336.427 399.339C342.507 395.797 345.557 390.72 345.557 383.616V236.459L387.669 260.821C391.232 262.848 392.747 265.899 392.747 269.952V381.589C392.747 435.883 350.635 476.971 294.315 476.971ZM174.059 363.84L77.12 308.011C49.216 291.776 30.4427 257.28 30.4427 223.787C30.3769 204.756 35.9917 186.138 46.5684 170.317C57.1451 154.495 72.2025 142.19 89.8133 134.976V250.667C89.8133 257.771 92.864 262.848 98.944 266.411L225.813 339.989L184.213 363.84C182.707 364.835 180.941 365.365 179.136 365.365C177.331 365.365 175.565 364.835 174.059 363.84ZM168.469 447.04C111.125 447.04 69.0133 403.925 69.0133 350.635C69.0133 346.581 69.5253 342.528 70.016 338.475L169.984 396.288C176.085 399.851 182.165 399.851 188.245 396.288L315.605 322.731V370.944C315.605 374.997 314.112 378.048 310.549 380.075L213.632 435.883C200.427 443.499 184.704 447.04 168.469 447.04ZM294.315 507.413C323.553 507.416 351.895 497.319 374.547 478.831C397.198 460.343 412.768 434.598 418.624 405.952C475.456 391.232 512 337.92 512 283.648C512 248.128 496.789 213.632 469.376 188.757C471.915 178.091 473.429 167.445 473.429 156.8C473.429 84.2453 414.571 29.9307 346.581 29.9307C332.885 29.9307 319.701 31.9573 306.475 36.544C282.795 13.2354 250.933 0.118797 217.707 1.37049e-07C188.465 -0.00135846 160.121 10.0985 137.469 28.5908C114.817 47.0831 99.2486 72.8325 93.3973 101.483C36.544 116.203 0 169.493 0 223.787C0 259.328 15.2107 293.824 42.624 318.677C40.0853 329.344 38.5707 340.011 38.5707 350.656C38.5707 423.211 97.4293 477.504 165.419 477.504C179.115 477.504 192.299 475.477 205.525 470.912C229.208 494.23 261.08 507.347 294.315 507.456V507.413Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1483_15836">
|
||||
<rect width="512" height="512" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.0 KiB |
@@ -1,3 +1,3 @@
|
||||
<svg width="20" height="32" viewBox="0 0 20 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20 32V0C20 5.39616 15.5172 9.78053 10 9.78053C4.48276 9.78053 0 5.416 0 0V32C0 26.6038 4.48276 22.2195 10 22.2195C15.5172 22.2195 20 26.6038 20 32Z" fill="#F2FF59"/>
|
||||
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 20 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path id="Vector" d="M20 32V0C20 5.39616 15.5172 9.78053 10 9.78053C4.48276 9.78053 0 5.416 0 0V32C0 26.6038 4.48276 22.2195 10 22.2195C15.5172 22.2195 20 26.6038 20 32Z" fill="var(--fill-0, #F2FF59)"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 279 B After Width: | Height: | Size: 380 B |
@@ -1,129 +0,0 @@
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { collectGraphIds } from '../src/utils/jsonLd'
|
||||
|
||||
const DIST_DIR = join(process.cwd(), 'dist')
|
||||
const JSON_LD_BLOCK =
|
||||
/<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi
|
||||
|
||||
interface Violation {
|
||||
file: string
|
||||
message: string
|
||||
}
|
||||
|
||||
function htmlFiles(dir: string): string[] {
|
||||
return readdirSync(dir, { recursive: true })
|
||||
.map(String)
|
||||
.filter((entry) => entry.endsWith('.html'))
|
||||
.map((entry) => join(dir, entry))
|
||||
}
|
||||
|
||||
function typesOf(node: Record<string, unknown>): string[] {
|
||||
const type = node['@type']
|
||||
if (typeof type === 'string') return [type]
|
||||
if (Array.isArray(type)) {
|
||||
return type.filter((t): t is string => typeof t === 'string')
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function hasValidPrice(node: Record<string, unknown>): boolean {
|
||||
const price = node.price
|
||||
const priceStr = price == null ? '' : String(price).trim()
|
||||
return priceStr !== '' && !Number.isNaN(Number(priceStr))
|
||||
}
|
||||
|
||||
function checkHonesty(
|
||||
value: unknown,
|
||||
file: string,
|
||||
violations: Violation[]
|
||||
): void {
|
||||
const walk = (node: unknown): void => {
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach(walk)
|
||||
return
|
||||
}
|
||||
if (!node || typeof node !== 'object') return
|
||||
const record = node as Record<string, unknown>
|
||||
const types = typesOf(record)
|
||||
if (types.includes('Review') || types.includes('AggregateRating')) {
|
||||
violations.push({
|
||||
file,
|
||||
message: `dishonest node type ${types.join('/')}`
|
||||
})
|
||||
}
|
||||
if ('aggregateRating' in record || 'review' in record) {
|
||||
violations.push({
|
||||
file,
|
||||
message: 'node carries a review/aggregateRating'
|
||||
})
|
||||
}
|
||||
if (
|
||||
types.includes('Offer') &&
|
||||
(!hasValidPrice(record) || !record.priceCurrency)
|
||||
) {
|
||||
violations.push({
|
||||
file,
|
||||
message: 'Offer missing priceCurrency or a concrete price'
|
||||
})
|
||||
}
|
||||
Object.values(record).forEach(walk)
|
||||
}
|
||||
walk(value)
|
||||
}
|
||||
|
||||
function validateFile(file: string): Violation[] {
|
||||
const html = readFileSync(file, 'utf8')
|
||||
const violations: Violation[] = []
|
||||
const definedIds = new Set<string>()
|
||||
const referencedIds: string[] = []
|
||||
|
||||
for (const match of html.matchAll(JSON_LD_BLOCK)) {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(match[1])
|
||||
} catch (error) {
|
||||
violations.push({ file, message: `invalid JSON-LD: ${String(error)}` })
|
||||
continue
|
||||
}
|
||||
checkHonesty(parsed, file, violations)
|
||||
const { defined, references } = collectGraphIds(parsed)
|
||||
defined.forEach((id) => definedIds.add(id))
|
||||
referencedIds.push(...references)
|
||||
}
|
||||
|
||||
for (const id of referencedIds) {
|
||||
if (!definedIds.has(id)) {
|
||||
violations.push({ file, message: `unresolved @id reference: ${id}` })
|
||||
}
|
||||
}
|
||||
|
||||
return violations
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const files = htmlFiles(DIST_DIR)
|
||||
|
||||
if (files.length === 0) {
|
||||
console.error(
|
||||
`JSON-LD validation found no HTML in ${DIST_DIR} — build first.`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const violations = files.flatMap(validateFile)
|
||||
if (violations.length > 0) {
|
||||
console.error(`JSON-LD validation failed (${violations.length} issue(s)):`)
|
||||
for (const { file, message } of violations) {
|
||||
console.error(` ${file.replace(DIST_DIR, 'dist')}: ${message}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
`JSON-LD validation passed across ${files.length} page(s).\n`
|
||||
)
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -1,55 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import type { StoryCard as StoryCardType } from '../../utils/customers'
|
||||
import SectionHeader from '../common/SectionHeader.vue'
|
||||
import StoryCard from '../customers/StoryCard.vue'
|
||||
import ScrollCarousel from '../ui/scroll-carousel/ScrollCarousel.vue'
|
||||
|
||||
const {
|
||||
stories,
|
||||
heading,
|
||||
subtitle,
|
||||
locale = 'en',
|
||||
class: className
|
||||
} = defineProps<{
|
||||
stories: StoryCardType[]
|
||||
heading?: string
|
||||
subtitle?: string
|
||||
locale?: Locale
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
:class="cn('max-w-9xl mx-auto px-6 py-16 lg:px-16 lg:py-24', className)"
|
||||
>
|
||||
<SectionHeader v-if="heading" class="mb-12 lg:mb-16">
|
||||
{{ heading }}
|
||||
<template v-if="subtitle" #subtitle>
|
||||
<p class="mt-4 text-sm text-smoke-700 lg:text-base">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</template>
|
||||
</SectionHeader>
|
||||
|
||||
<!-- Reuse the shared carousel shell; neutralise its outer chrome so the
|
||||
section wrapper above owns the padding and max-width. -->
|
||||
<ScrollCarousel
|
||||
:locale="locale"
|
||||
gap-class="gap-6"
|
||||
class="max-w-none p-0 lg:p-0"
|
||||
>
|
||||
<StoryCard
|
||||
v-for="story in stories"
|
||||
:key="story.slug"
|
||||
:story="story"
|
||||
:locale="locale"
|
||||
class="w-[80%] shrink-0 snap-start sm:w-[70%] lg:w-[calc(50%-0.75rem)]"
|
||||
/>
|
||||
</ScrollCarousel>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,20 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import Accordion from '../ui/accordion/Accordion.vue'
|
||||
import AccordionContent from '../ui/accordion/AccordionContent.vue'
|
||||
import AccordionItem from '../ui/accordion/AccordionItem.vue'
|
||||
import AccordionTrigger from '../ui/accordion/AccordionTrigger.vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { reactive, watch } from 'vue'
|
||||
|
||||
type Faq = { id: string; question: string; answer: string }
|
||||
|
||||
defineProps<{
|
||||
id?: string
|
||||
const { faqs } = defineProps<{
|
||||
heading: string
|
||||
faqs: readonly Faq[]
|
||||
}>()
|
||||
|
||||
const expanded = reactive<boolean[]>(faqs.map(() => false))
|
||||
|
||||
watch(
|
||||
() => faqs.length,
|
||||
(length) => {
|
||||
if (length === expanded.length) return
|
||||
expanded.length = 0
|
||||
for (let i = 0; i < length; i += 1) expanded.push(false)
|
||||
}
|
||||
)
|
||||
|
||||
function toggle(index: number) {
|
||||
expanded[index] = !expanded[index]
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section :id class="max-w-9xl mx-auto px-4 py-16 lg:px-20 lg:py-24">
|
||||
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
|
||||
<div class="flex flex-col gap-6 md:flex-row md:gap-16">
|
||||
<!-- Left heading -->
|
||||
<div
|
||||
@@ -26,23 +38,57 @@ defineProps<{
|
||||
</div>
|
||||
|
||||
<!-- Right FAQ list -->
|
||||
<Accordion type="multiple" class="flex-1">
|
||||
<AccordionItem
|
||||
<div class="flex-1">
|
||||
<div
|
||||
v-for="(faq, index) in faqs"
|
||||
:key="faq.id"
|
||||
:value="faq.id"
|
||||
class="border-b border-primary-comfy-canvas/20"
|
||||
>
|
||||
<AccordionTrigger :class="index === 0 ? 'pt-0' : ''">
|
||||
{{ faq.question }}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<p
|
||||
class="text-sm whitespace-pre-line text-primary-comfy-canvas/70"
|
||||
v-html="faq.answer"
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
<button
|
||||
:id="`faq-trigger-${faq.id}`"
|
||||
type="button"
|
||||
:aria-expanded="expanded[index]"
|
||||
:aria-controls="`faq-panel-${faq.id}`"
|
||||
:class="
|
||||
cn(
|
||||
'flex w-full cursor-pointer items-center justify-between text-left',
|
||||
index === 0 ? 'pb-6' : 'py-6'
|
||||
)
|
||||
"
|
||||
@click="toggle(index)"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'text-lg font-light md:text-xl',
|
||||
expanded[index]
|
||||
? 'text-primary-comfy-yellow'
|
||||
: 'text-primary-comfy-canvas'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ faq.question }}
|
||||
</span>
|
||||
<span
|
||||
class="text-primary-comfy-yellow ml-4 shrink-0 text-2xl"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{{ expanded[index] ? '−' : '+' }}
|
||||
</span>
|
||||
</button>
|
||||
<section
|
||||
v-show="expanded[index]"
|
||||
:id="`faq-panel-${faq.id}`"
|
||||
role="region"
|
||||
:aria-labelledby="`faq-trigger-${faq.id}`"
|
||||
class="pb-6"
|
||||
>
|
||||
<p class="text-sm whitespace-pre-line text-primary-comfy-canvas/70">
|
||||
{{ faq.answer }}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -56,7 +56,7 @@ const columnClass: Record<ColumnCount, string> = {
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
|
||||
<SectionHeader max-width="xl" :label="eyebrow" align="start">
|
||||
<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">
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import HeroBackdrop01 from './HeroBackdrop01.vue'
|
||||
|
||||
const sampleImage =
|
||||
'https://images.unsplash.com/photo-1451187580459-43490279c0fa?auto=format&fit=crop&w=1600&q=80'
|
||||
|
||||
const meta: Meta<typeof HeroBackdrop01> = {
|
||||
title: 'Website/Blocks/HeroBackdrop01',
|
||||
component: HeroBackdrop01,
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
backdrop: { type: 'image', src: sampleImage, alt: 'Abstract gradient' },
|
||||
title: 'Build anything\nwith ComfyUI',
|
||||
subtitle:
|
||||
'A powerful, modular visual interface for building and running AI workflows.'
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {}
|
||||
|
||||
export const WithBadge: Story = {
|
||||
args: {
|
||||
badgeText: 'New'
|
||||
}
|
||||
}
|
||||
|
||||
export const WithFootnote: Story = {
|
||||
args: {
|
||||
footnote: 'Available on Windows, macOS, and Linux.'
|
||||
}
|
||||
}
|
||||
|
||||
export const NoBackdrop: Story = {
|
||||
args: {
|
||||
backdrop: undefined
|
||||
}
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { prefersReducedMotion } from '../../composables/useReducedMotion'
|
||||
import ProductHeroBadge from '../common/ProductHeroBadge.vue'
|
||||
|
||||
type Backdrop =
|
||||
| { type: 'image'; src: string; alt?: string }
|
||||
| { type: 'video'; src: string; poster?: string; alt?: string }
|
||||
|
||||
const {
|
||||
backdrop,
|
||||
mobileBackdrop,
|
||||
badgeText,
|
||||
badgeLogoSrc,
|
||||
badgeLogoAlt,
|
||||
title,
|
||||
subtitle,
|
||||
footnote,
|
||||
class: className
|
||||
} = defineProps<{
|
||||
backdrop?: Backdrop
|
||||
mobileBackdrop?: Backdrop
|
||||
badgeText?: string
|
||||
badgeLogoSrc?: string
|
||||
badgeLogoAlt?: string
|
||||
title: string
|
||||
subtitle?: string
|
||||
footnote?: string
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
// Respect prefers-reduced-motion: don't autoplay the looping backdrop video
|
||||
// (WCAG 2.2.2). The paused video falls back to its poster/first frame.
|
||||
const reduceMotion = computed(() => prefersReducedMotion())
|
||||
|
||||
// Removing the reactive `autoplay` attribute only suppresses the *initial*
|
||||
// play; it can't pause a video the browser has already started. That is
|
||||
// exactly the SSR case: the server renders `autoplay` (it can't read the
|
||||
// client's motion preference), the browser begins playback on parse, and the
|
||||
// post-hydration attribute removal is too late. Pause on mount so
|
||||
// reduced-motion users get the poster frame instead of a looping video.
|
||||
const pauseIfReduced = (el: unknown) => {
|
||||
if (el instanceof HTMLVideoElement && reduceMotion.value) el.pause()
|
||||
}
|
||||
|
||||
// On mobile the backdrop is an in-flow rounded card above the content; on
|
||||
// desktop it is the full-bleed background behind it. A single element serves
|
||||
// both roles via responsive classes — mobileBackdrop only swaps the source.
|
||||
const sharedBackdropClass =
|
||||
'relative aspect-3/2 w-full rounded-3xl object-cover lg:absolute lg:inset-0 lg:aspect-auto lg:size-full lg:rounded-none'
|
||||
|
||||
// When both breakpoints use images, serve them from a single responsive <img>
|
||||
// so the browser fetches only the source matching the viewport. Two
|
||||
// `hidden`/`lg:hidden`-toggled <img> layers would each download (display:none
|
||||
// does not stop the fetch), doubling the high-priority load on an
|
||||
// LCP-critical hero. Videos or a mixed image/video pair can't collapse this
|
||||
// way and fall back to breakpoint-toggled layers below.
|
||||
const responsiveImage = computed(() => {
|
||||
if (backdrop?.type !== 'image') return null
|
||||
if (mobileBackdrop && mobileBackdrop.type !== 'image') return null
|
||||
const base = mobileBackdrop ?? backdrop
|
||||
return {
|
||||
src: base.src,
|
||||
alt: backdrop.alt ?? mobileBackdrop?.alt ?? '',
|
||||
// Larger-viewport source; omitted when one image serves both breakpoints.
|
||||
desktopSrc: mobileBackdrop ? backdrop.src : undefined
|
||||
}
|
||||
})
|
||||
|
||||
// Fallback for videos and mixed image/video pairs: toggle assets by breakpoint.
|
||||
const backdropLayers = computed(() => {
|
||||
if (!backdrop) return []
|
||||
if (mobileBackdrop) {
|
||||
return [
|
||||
{
|
||||
backdrop: mobileBackdrop,
|
||||
class: 'relative aspect-3/2 w-full rounded-3xl object-cover lg:hidden'
|
||||
},
|
||||
{
|
||||
backdrop,
|
||||
class: 'absolute inset-0 hidden size-full object-cover lg:block'
|
||||
}
|
||||
]
|
||||
}
|
||||
return [{ backdrop, class: sharedBackdropClass }]
|
||||
})
|
||||
|
||||
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">
|
||||
<picture v-if="responsiveImage" class="contents">
|
||||
<source
|
||||
v-if="responsiveImage.desktopSrc"
|
||||
:srcset="responsiveImage.desktopSrc"
|
||||
media="(min-width: 1024px)"
|
||||
/>
|
||||
<img
|
||||
:src="responsiveImage.src"
|
||||
:alt="responsiveImage.alt"
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
:class="sharedBackdropClass"
|
||||
/>
|
||||
</picture>
|
||||
|
||||
<template v-else>
|
||||
<template v-for="(layer, i) in backdropLayers" :key="i">
|
||||
<video
|
||||
v-if="layer.backdrop.type === 'video'"
|
||||
:ref="pauseIfReduced"
|
||||
:src="layer.backdrop.src"
|
||||
:poster="layer.backdrop.poster"
|
||||
:aria-label="layer.backdrop.alt"
|
||||
:aria-hidden="layer.backdrop.alt ? undefined : true"
|
||||
:autoplay="!reduceMotion"
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
preload="metadata"
|
||||
:class="layer.class"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
:src="layer.backdrop.src"
|
||||
:alt="layer.backdrop.alt ?? ''"
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
:class="layer.class"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</slot>
|
||||
|
||||
<div
|
||||
class="relative flex flex-col justify-center px-0 pt-6 pb-8 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 hidden backdrop-blur-md lg:-inset-16 lg:block"
|
||||
: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-canvas lg:text-6xl/tight lg:text-primary-comfy-ink"
|
||||
>
|
||||
{{ title }}
|
||||
</h1>
|
||||
|
||||
<p
|
||||
v-if="subtitle"
|
||||
class="mt-8 max-w-md text-base text-primary-comfy-canvas lg:text-lg lg:text-primary-comfy-ink"
|
||||
>
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="footnote"
|
||||
class="mt-10 text-sm text-primary-comfy-canvas lg:text-primary-comfy-ink"
|
||||
>
|
||||
{{ footnote }}
|
||||
</p>
|
||||
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,97 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { prefersReducedMotion } from '../../composables/useReducedMotion'
|
||||
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')
|
||||
}
|
||||
|
||||
// Respect prefers-reduced-motion: don't autoplay the looping media video
|
||||
// (WCAG 2.2.2). The paused video falls back to its poster/first frame.
|
||||
const reduceMotion = computed(() => prefersReducedMotion())
|
||||
</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="!reduceMotion"
|
||||
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>
|
||||
@@ -1,28 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { ChevronRight } from '@lucide/vue'
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
const { hover = 'self', class: className } = defineProps<{
|
||||
hover?: 'self' | 'group'
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex size-10 items-center justify-center rounded-2xl bg-white/20 text-white backdrop-blur-sm transition-colors',
|
||||
hover === 'group'
|
||||
? 'group-hover:bg-primary-comfy-yellow group-hover:text-primary-comfy-ink'
|
||||
: 'hover:bg-primary-comfy-yellow hover:text-primary-comfy-ink',
|
||||
className
|
||||
)
|
||||
"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<ChevronRight class="size-5" :stroke-width="2" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -10,14 +10,12 @@ const {
|
||||
locale = 'en',
|
||||
headingKey,
|
||||
faqPrefix,
|
||||
faqCount,
|
||||
footerKey
|
||||
faqCount
|
||||
} = defineProps<{
|
||||
locale?: Locale
|
||||
headingKey: TranslationKey
|
||||
faqPrefix: string
|
||||
faqCount: number
|
||||
footerKey?: TranslationKey
|
||||
}>()
|
||||
|
||||
const faqKeys: Array<{ q: TranslationKey; a: TranslationKey }> = Array.from(
|
||||
@@ -47,9 +45,9 @@ function toggle(index: number) {
|
||||
<div class="flex flex-col gap-6 md:flex-row md:gap-16">
|
||||
<!-- Left heading -->
|
||||
<div
|
||||
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"
|
||||
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"
|
||||
>
|
||||
<h2 class="text-4xl font-light text-primary-comfy-canvas md:text-5xl">
|
||||
<h2 class="text-primary-comfy-canvas text-4xl font-light md:text-5xl">
|
||||
{{ t(headingKey, locale) }}
|
||||
</h2>
|
||||
</div>
|
||||
@@ -59,7 +57,7 @@ function toggle(index: number) {
|
||||
<div
|
||||
v-for="(faq, index) in faqs"
|
||||
:key="index"
|
||||
class="border-b border-primary-comfy-canvas/20"
|
||||
class="border-primary-comfy-canvas/20 border-b"
|
||||
>
|
||||
<button
|
||||
:id="`faq-trigger-${index}`"
|
||||
@@ -100,18 +98,11 @@ function toggle(index: number) {
|
||||
:aria-labelledby="`faq-trigger-${index}`"
|
||||
class="pb-6"
|
||||
>
|
||||
<p
|
||||
class="[&_a]:text-primary-comfy-yellow text-sm whitespace-pre-line text-primary-comfy-canvas/70 [&_a]:underline"
|
||||
v-html="faq.answer"
|
||||
/>
|
||||
<p class="text-primary-comfy-canvas/70 text-sm whitespace-pre-line">
|
||||
{{ faq.answer }}
|
||||
</p>
|
||||
</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>
|
||||
|
||||
@@ -33,7 +33,7 @@ const ctaButtons = [
|
||||
|
||||
<template>
|
||||
<nav
|
||||
class="sticky top-0 z-50 flex items-center justify-between gap-4 bg-primary-comfy-ink px-6 py-5 lg:gap-4 lg:px-[clamp(0.25rem,4vw,5rem)] lg:py-8"
|
||||
class="fixed inset-x-0 top-0 z-50 flex items-center justify-between gap-4 bg-primary-comfy-ink px-6 py-5 lg:gap-4 lg:px-[clamp(0.25rem,4vw,5rem)] lg:py-8"
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
<a
|
||||
|
||||
@@ -16,7 +16,6 @@ import type { NavItem } from '../../../data/mainNavigation'
|
||||
import type { Locale } from '../../../i18n/translations'
|
||||
import NavColumn from './NavColumn.vue'
|
||||
import NavFeaturedCard from './NavFeaturedCard.vue'
|
||||
import NewBadge from './NewBadge.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
const mainNavigation = getMainNavigation(locale)
|
||||
@@ -43,10 +42,7 @@ function isNavItemActive(navItem: NavItem, path: string): boolean {
|
||||
<NavigationMenuTrigger
|
||||
:active="isNavItemActive(navItem, currentPath)"
|
||||
>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span class="ppformula-text-center">{{ navItem.label }}</span>
|
||||
<NewBadge v-if="navItem.badge" :locale="locale" size="xxs" />
|
||||
</span>
|
||||
{{ navItem.label }}
|
||||
</NavigationMenuTrigger>
|
||||
<NavigationMenuContent class="w-auto" data-testid="nav-dropdown">
|
||||
<ul class="flex w-max gap-16">
|
||||
|
||||
@@ -8,7 +8,6 @@ import { lockScroll, unlockScroll } from '../../../composables/scrollLock'
|
||||
import type { Locale } from '../../../i18n/translations.ts'
|
||||
import { t } from '../../../i18n/translations.ts'
|
||||
import NavLinkContent from './NavLinkContent.vue'
|
||||
import NewBadge from './NewBadge.vue'
|
||||
import Sheet from '@/components/ui/sheet/Sheet.vue'
|
||||
import SheetContent from '@/components/ui/sheet/SheetContent.vue'
|
||||
import SheetDescription from '@/components/ui/sheet/SheetDescription.vue'
|
||||
@@ -97,8 +96,7 @@ onUnmounted(() => {
|
||||
:href="item.columns ? undefined : item.href"
|
||||
@click="item.columns && (activeSection = item.label)"
|
||||
>
|
||||
<span class="ppformula-text-center">{{ item.label }}</span>
|
||||
<NewBadge v-if="item.badge" :locale="locale" size="xxs" />
|
||||
{{ item.label }}
|
||||
<template #append>
|
||||
<ChevronRight class="size-7" />
|
||||
</template>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import Badge from '@/components/ui/badge/Badge.vue'
|
||||
import { ArrowUpRight } from '@lucide/vue'
|
||||
|
||||
import type { NavColumnItem } from '../../../data/mainNavigation'
|
||||
import type { Locale } from '../../../i18n/translations'
|
||||
import NewBadge from './NewBadge.vue'
|
||||
import { t } from '../../../i18n/translations'
|
||||
|
||||
defineProps<{ item: NavColumnItem; locale: Locale }>()
|
||||
</script>
|
||||
@@ -11,7 +12,9 @@ defineProps<{ item: NavColumnItem; locale: Locale }>()
|
||||
<template>
|
||||
<span class="flex items-center gap-2">
|
||||
<span class="ppformula-text-center">{{ item.label }}</span>
|
||||
<NewBadge v-if="item.badge" :locale="locale" size="xs" />
|
||||
<Badge v-if="item.badge" size="xs" variant="accent">
|
||||
{{ t('nav.badgeNew', locale) }}
|
||||
</Badge>
|
||||
<ArrowUpRight
|
||||
v-if="item.external"
|
||||
class="text-primary-comfy-yellow size-4"
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import Badge from '@/components/ui/badge/Badge.vue'
|
||||
|
||||
import type { BadgeVariants } from '@/components/ui/badge'
|
||||
import type { Locale } from '../../../i18n/translations'
|
||||
import { t } from '../../../i18n/translations'
|
||||
|
||||
defineProps<{ locale: Locale; size?: BadgeVariants['size'] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Badge :size="size" variant="accent">
|
||||
{{ t('nav.badgeNew', locale) }}
|
||||
</Badge>
|
||||
</template>
|
||||
@@ -1,12 +0,0 @@
|
||||
---
|
||||
import type { JsonLdGraph } from '../../utils/jsonLd'
|
||||
import { escapeJsonLd } from '../../utils/escapeJsonLd'
|
||||
|
||||
interface Props {
|
||||
graph: JsonLdGraph
|
||||
}
|
||||
|
||||
const { graph } = Astro.props
|
||||
---
|
||||
|
||||
<script is:inline type="application/ld+json" set:html={escapeJsonLd(graph)} />
|
||||
@@ -1,8 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import Button from '../ui/button/Button.vue'
|
||||
|
||||
const { title, description, cta, href, bg } = defineProps<{
|
||||
title: string
|
||||
description: string
|
||||
@@ -30,14 +28,11 @@ const { title, description, cta, href, bg } = defineProps<{
|
||||
<p class="text-sm text-white/70">
|
||||
{{ description }}
|
||||
</p>
|
||||
<Button
|
||||
as="span"
|
||||
variant="default"
|
||||
size="sm"
|
||||
class="mt-4 h-auto whitespace-normal"
|
||||
<span
|
||||
class="bg-primary-comfy-yellow text-primary-comfy-ink mt-4 inline-block rounded-xl px-4 py-2 text-xs font-bold tracking-wide"
|
||||
>
|
||||
{{ cta }}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
@@ -43,7 +43,7 @@ const headingSizeClass = {
|
||||
:is="headingTag"
|
||||
:class="
|
||||
cn(
|
||||
'text-balance text-primary-comfy-canvas',
|
||||
'text-primary-comfy-canvas',
|
||||
label && 'mt-4',
|
||||
headingSizeClass[headingSize]
|
||||
)
|
||||
|
||||
@@ -38,8 +38,7 @@ const topColumns: { title: string; links: FooterLink[] }[] = [
|
||||
{ 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.mcpServer', locale), href: routes.mcp },
|
||||
{ label: t('nav.supportedModels', locale), href: routes.models }
|
||||
{ label: t('nav.mcpServer', locale), href: routes.mcp }
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -86,7 +85,6 @@ const companyColumn: { title: string; links: FooterLink[] } = {
|
||||
{ label: t('footer.about', locale), href: routes.about },
|
||||
{ label: t('nav.careers', locale), href: routes.careers },
|
||||
{ label: t('footer.termsOfService', locale), href: routes.termsOfService },
|
||||
{ label: t('footer.enterpriseMsa', locale), href: routes.enterpriseMsa },
|
||||
{ label: t('footer.privacyPolicy', locale), href: routes.privacyPolicy }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -33,41 +33,36 @@ useHeroAnimation({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
ref="sectionRef"
|
||||
class="px-4 py-20 lg:flex lg:gap-16 lg:px-20 lg:py-24"
|
||||
>
|
||||
<section ref="sectionRef" class="px-4 py-20 lg:flex lg:px-20 lg:py-24">
|
||||
<!-- Left column: intro + image -->
|
||||
<div class="lg:w-1/2">
|
||||
<div class="lg:max-w-xl">
|
||||
<SectionLabel ref="badgeRef">
|
||||
{{ t(tk('badge'), locale) }}
|
||||
</SectionLabel>
|
||||
<SectionLabel ref="badgeRef">
|
||||
{{ t(tk('badge'), locale) }}
|
||||
</SectionLabel>
|
||||
|
||||
<h1
|
||||
ref="headingRef"
|
||||
class="mt-4 text-3xl font-light whitespace-pre-line text-primary-comfy-canvas lg:text-5xl"
|
||||
>
|
||||
{{ t(tk('heading'), locale) }}
|
||||
</h1>
|
||||
<h1
|
||||
ref="headingRef"
|
||||
class="text-primary-comfy-canvas mt-4 text-3xl font-light whitespace-pre-line lg:text-5xl"
|
||||
>
|
||||
{{ t(tk('heading'), locale) }}
|
||||
</h1>
|
||||
|
||||
<div ref="descRef">
|
||||
<p class="mt-4 text-sm text-primary-comfy-canvas">
|
||||
{{ t(tk('description'), locale) }}
|
||||
</p>
|
||||
<div ref="descRef">
|
||||
<p class="text-primary-comfy-canvas mt-4 text-sm">
|
||||
{{ t(tk('description'), locale) }}
|
||||
</p>
|
||||
|
||||
<p class="mt-4 text-sm text-primary-comfy-canvas">
|
||||
{{ t(tk('supportLink'), locale) }}
|
||||
<a
|
||||
href="https://docs.comfy.org/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-comfy-yellow underline"
|
||||
>
|
||||
{{ t(tk('supportLinkCta'), locale) }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-primary-comfy-canvas mt-4 text-sm">
|
||||
{{ t(tk('supportLink'), locale) }}
|
||||
<a
|
||||
href="https://docs.comfy.org/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-comfy-yellow underline"
|
||||
>
|
||||
{{ t(tk('supportLinkCta'), locale) }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div ref="imageRef" class="mt-8 overflow-hidden rounded-2xl lg:-ml-20">
|
||||
|
||||
@@ -4,24 +4,16 @@ 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
|
||||
@@ -42,26 +34,18 @@ const categories = entry.data.sections.map((section) => ({
|
||||
// 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
|
||||
Steps
|
||||
}
|
||||
---
|
||||
|
||||
<section class="max-w-9xl mx-auto px-4 pt-8 pb-24 lg:px-20 lg:pt-24 lg:pb-40">
|
||||
<section class="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">
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { useScroll } from '@vueuse/core'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
import ScrollCarousel from '../ui/scroll-carousel/ScrollCarousel.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
@@ -22,26 +24,83 @@ const feedbacks = [
|
||||
role: 'customers.feedback.role3' as const
|
||||
}
|
||||
]
|
||||
|
||||
const trackRef = ref<HTMLElement>()
|
||||
const { x } = useScroll(trackRef)
|
||||
|
||||
const progress = computed(() => {
|
||||
const el = trackRef.value
|
||||
if (!el) return 0
|
||||
const max = el.scrollWidth - el.clientWidth
|
||||
return max > 0 ? x.value / max : 0
|
||||
})
|
||||
|
||||
function scroll(direction: -1 | 1) {
|
||||
const el = trackRef.value
|
||||
if (!el) return
|
||||
el.scrollBy({ left: direction * el.clientWidth, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
const progressPercent = computed(() => `${progress.value * 100}%`)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ScrollCarousel :locale="locale">
|
||||
<section class="max-w-9xl mx-auto px-6 py-16 lg:px-16 lg:py-24">
|
||||
<!-- Scrollable track -->
|
||||
<div
|
||||
v-for="(fb, i) in feedbacks"
|
||||
:key="i"
|
||||
class="bg-transparency-white-t4 flex w-full shrink-0 snap-start flex-col justify-between rounded-3xl p-8 lg:w-3/4 lg:p-12"
|
||||
ref="trackRef"
|
||||
class="flex snap-x snap-mandatory scrollbar-none gap-12 overflow-x-auto lg:gap-20"
|
||||
>
|
||||
<p class="text-2xl/relaxed font-light text-primary-comfy-canvas">
|
||||
"{{ t(fb.quote, locale) }}"
|
||||
</p>
|
||||
<div class="mt-12">
|
||||
<p class="text-primary-comfy-yellow text-base font-medium">
|
||||
{{ t(fb.name, locale) }},
|
||||
</p>
|
||||
<p class="text-primary-comfy-yellow text-base font-medium">
|
||||
{{ t(fb.role, locale) }}
|
||||
<div
|
||||
v-for="(fb, i) in feedbacks"
|
||||
:key="i"
|
||||
class="bg-transparency-white-t4 flex w-full shrink-0 snap-start flex-col justify-between rounded-3xl p-8 lg:w-3/4 lg:p-12"
|
||||
>
|
||||
<p class="text-primary-comfy-canvas text-2xl/relaxed font-light">
|
||||
"{{ t(fb.quote, locale) }}"
|
||||
</p>
|
||||
<div class="mt-12">
|
||||
<p class="text-primary-comfy-yellow text-base font-medium">
|
||||
{{ t(fb.name, locale) }},
|
||||
</p>
|
||||
<p class="text-primary-comfy-yellow text-base font-medium">
|
||||
{{ t(fb.role, locale) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollCarousel>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="mt-10 flex items-center gap-4">
|
||||
<!-- Progress bar -->
|
||||
<div class="h-1 flex-1 rounded-full bg-white/20">
|
||||
<div
|
||||
class="bg-primary-comfy-yellow h-full rounded-full"
|
||||
:style="{ width: progressPercent }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Prev -->
|
||||
<button
|
||||
class="flex size-10 items-center justify-center rounded-full border border-white/20 text-white/60 transition-colors hover:border-white/40"
|
||||
:aria-label="locale === 'zh-CN' ? '上一条' : 'Previous'"
|
||||
@click="scroll(-1)"
|
||||
>
|
||||
<img
|
||||
src="/icons/arrow-right.svg"
|
||||
alt=""
|
||||
class="size-3 rotate-180 opacity-60 invert"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- Next -->
|
||||
<button
|
||||
class="bg-primary-comfy-yellow flex size-10 items-center justify-center rounded-full transition-opacity hover:opacity-90"
|
||||
:aria-label="locale === 'zh-CN' ? '下一条' : 'Next'"
|
||||
@click="scroll(1)"
|
||||
>
|
||||
<img src="/icons/arrow-right.svg" alt="" class="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
import type { StoryCard } from '../../utils/customers'
|
||||
|
||||
const { story, locale = 'en' } = defineProps<{
|
||||
story: StoryCard
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const prefix = locale === 'zh-CN' ? '/zh-CN' : ''
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a
|
||||
:href="`${prefix}/customers/${story.slug}`"
|
||||
class="bg-transparency-white-t4 group flex flex-col overflow-hidden rounded-3xl transition-colors hover:bg-white/8"
|
||||
>
|
||||
<!-- Image -->
|
||||
<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.cover})` }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex flex-1 flex-col justify-between px-6 pt-4 pb-6">
|
||||
<div>
|
||||
<span
|
||||
class="text-primary-comfy-yellow text-[10px] font-semibold tracking-widest uppercase"
|
||||
>
|
||||
{{ story.category }}
|
||||
</span>
|
||||
<h3
|
||||
class="mt-2 text-lg/snug font-light text-primary-comfy-canvas lg:text-xl/snug"
|
||||
>
|
||||
{{ story.title }}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mt-8 flex items-center gap-3 text-xs font-semibold tracking-widest uppercase"
|
||||
>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow flex size-8 items-center justify-center rounded-full"
|
||||
>
|
||||
<img src="/icons/arrow-right.svg" alt="" class="ml-0.5 size-3" />
|
||||
</span>
|
||||
<span class="text-primary-comfy-canvas">
|
||||
{{ t('customers.story.viewArticle', locale) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
@@ -1,23 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import type { StoryCard as StoryCardType } from '../../utils/customers'
|
||||
import StoryCard from './StoryCard.vue'
|
||||
import { t } from '../../i18n/translations'
|
||||
import type { StoryCard } from '../../utils/customers'
|
||||
|
||||
const { stories, locale = 'en' } = defineProps<{
|
||||
stories: StoryCardType[]
|
||||
stories: StoryCard[]
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const prefix = locale === 'zh-CN' ? '/zh-CN' : ''
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
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"
|
||||
>
|
||||
<StoryCard
|
||||
<a
|
||||
v-for="story in stories"
|
||||
:key="story.slug"
|
||||
:story="story"
|
||||
:locale="locale"
|
||||
/>
|
||||
:href="`${prefix}/customers/${story.slug}`"
|
||||
class="bg-transparency-white-t4 group flex flex-col overflow-hidden rounded-3xl transition-colors hover:bg-white/8"
|
||||
>
|
||||
<!-- Image -->
|
||||
<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.cover})` }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex flex-1 flex-col justify-between px-6 pt-4 pb-6">
|
||||
<div>
|
||||
<span
|
||||
class="text-primary-comfy-yellow text-[10px] font-semibold tracking-widest uppercase"
|
||||
>
|
||||
{{ story.category }}
|
||||
</span>
|
||||
<h3
|
||||
class="mt-2 text-lg/snug font-light text-primary-comfy-canvas lg:text-xl/snug"
|
||||
>
|
||||
{{ story.title }}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mt-8 flex items-center gap-3 text-xs font-semibold tracking-widest uppercase"
|
||||
>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow flex size-8 items-center justify-center rounded-full"
|
||||
>
|
||||
<img src="/icons/arrow-right.svg" alt="" class="ml-0.5 size-3" />
|
||||
</span>
|
||||
<span class="text-primary-comfy-canvas">
|
||||
{{ t('customers.story.viewArticle', locale) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
---
|
||||
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>
|
||||
@@ -1,60 +0,0 @@
|
||||
---
|
||||
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>
|
||||
@@ -14,7 +14,7 @@ interface Props {
|
||||
const { label, people } = Astro.props
|
||||
---
|
||||
|
||||
<div class="mt-8 rounded-2xl bg-site-bg-soft p-6">
|
||||
<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"
|
||||
>
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
---
|
||||
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>
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
import { externalLinks, getRoutes } from '../../../config/routes'
|
||||
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'
|
||||
const routes = getRoutes(locale)
|
||||
---
|
||||
|
||||
<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={routes.education}>{t('educationCta.exploreLink', locale)}</Link>{' '}
|
||||
{t('educationCta.or', locale)}{' '}
|
||||
<Link href={externalLinks.creativeCampusApplicationForm}>{t('educationCta.applyLink', locale)}</Link>{' '}
|
||||
{t('educationCta.tail', locale)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -1,22 +0,0 @@
|
||||
---
|
||||
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>
|
||||
@@ -6,15 +6,14 @@ interface Props {
|
||||
}
|
||||
|
||||
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) && (
|
||||
caption && (
|
||||
<figcaption class="mt-3 text-xs text-primary-comfy-canvas">
|
||||
{hasCaptionSlot ? <slot /> : caption}
|
||||
{caption}
|
||||
</figcaption>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
---
|
||||
|
||||
<h4 class="mt-6 mb-2 text-base font-semibold text-primary-comfy-canvas">
|
||||
<slot />
|
||||
</h4>
|
||||
@@ -1,15 +0,0 @@
|
||||
---
|
||||
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>
|
||||
@@ -1,20 +1,16 @@
|
||||
---
|
||||
interface Props {
|
||||
name?: string
|
||||
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"
|
||||
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>
|
||||
)
|
||||
}
|
||||
<p class="text-primary-comfy-yellow mt-4 text-sm font-semibold">{name}</p>
|
||||
</blockquote>
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
---
|
||||
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>
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
|
||||
import { localizeHref } from '../../config/routes'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const {
|
||||
@@ -16,7 +15,8 @@ const {
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const nextHref = localizeHref(`/demos/${nextSlug}`, locale)
|
||||
const localePrefix = locale === 'en' ? '' : `/${locale}`
|
||||
const nextHref = `${localePrefix}/demos/${nextSlug}`
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { getRoutes } from '../../config/routes'
|
||||
import { hasKey, translationKeys } from '../../i18n/translations'
|
||||
|
||||
const PREFIX = 'enterprise-msa'
|
||||
|
||||
function deriveMsaSectionIds(): string[] {
|
||||
const labelRegex = new RegExp(`^${PREFIX}\\.([0-9]+-[a-z-]+)\\.label$`)
|
||||
const ids: string[] = []
|
||||
for (const key of translationKeys) {
|
||||
const match = key.match(labelRegex)
|
||||
if (match && !ids.includes(match[1])) ids.push(match[1])
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
describe('enterprise MSA i18n', () => {
|
||||
it('every derived section has a title and at least one block', () => {
|
||||
const sectionIds = deriveMsaSectionIds()
|
||||
expect(sectionIds.length).toBeGreaterThan(0)
|
||||
for (const id of sectionIds) {
|
||||
expect(hasKey(`${PREFIX}.${id}.title`)).toBe(true)
|
||||
expect(hasKey(`${PREFIX}.${id}.block.0`)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('exposes the page-chrome keys the .astro file references', () => {
|
||||
for (const suffix of [
|
||||
'effective-date',
|
||||
'page.title',
|
||||
'page.description',
|
||||
'page.heading',
|
||||
'page.tocLabel',
|
||||
'page.effectiveDateLabel',
|
||||
'page.parties'
|
||||
]) {
|
||||
expect(hasKey(`${PREFIX}.${suffix}`)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('serves the enterprise MSA at the canonical /enterprise-msa path regardless of locale', () => {
|
||||
expect(getRoutes('en').enterpriseMsa).toBe('/enterprise-msa')
|
||||
expect(getRoutes('zh-CN').enterpriseMsa).toBe('/enterprise-msa')
|
||||
})
|
||||
})
|
||||
@@ -1,23 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import FAQSplit01 from '../blocks/FAQSplit01.vue'
|
||||
import { pricingFaqs } from '../../data/pricingFaq'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const faqs = pricingFaqs.map((faq) => ({
|
||||
id: faq.id,
|
||||
question: faq.question[locale],
|
||||
answer: faq.answer[locale]
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FAQSplit01
|
||||
id="faq"
|
||||
:heading="t('pricing.faq.heading', locale)"
|
||||
:faqs="faqs"
|
||||
/>
|
||||
</template>
|
||||
393
apps/website/src/components/pricing/PriceSection.vue
Normal file
@@ -0,0 +1,393 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
import PricingPlanFeatureList from './PricingPlanFeatureList.vue'
|
||||
import PricingTierCard from './PricingTierCard.vue'
|
||||
import { SHOW_FREE_TIER } from '../../config/features'
|
||||
import { externalLinks, getRoutes } from '../../config/routes'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
function subscribeUrl(tier: string): string {
|
||||
return `${externalLinks.cloud}/cloud/subscribe?tier=${tier}&cycle=monthly`
|
||||
}
|
||||
|
||||
interface PlanFeature {
|
||||
text: TranslationKey
|
||||
}
|
||||
|
||||
interface PricingPlan {
|
||||
id: string
|
||||
labelKey: TranslationKey
|
||||
summaryKey: TranslationKey
|
||||
priceKey?: TranslationKey
|
||||
creditsKey?: TranslationKey
|
||||
estimateKey?: TranslationKey
|
||||
ctaKey: TranslationKey
|
||||
ctaHref: string
|
||||
featureIntroKey?: TranslationKey
|
||||
features: PlanFeature[]
|
||||
andMoreKey?: TranslationKey
|
||||
image?: string
|
||||
isPopular?: boolean
|
||||
isEnterprise?: boolean
|
||||
}
|
||||
|
||||
const freePlan: PricingPlan = {
|
||||
id: 'free',
|
||||
labelKey: 'pricing.plan.free.label',
|
||||
summaryKey: 'pricing.plan.free.summary',
|
||||
priceKey: 'pricing.plan.free.price',
|
||||
creditsKey: 'pricing.plan.free.credits',
|
||||
estimateKey: 'pricing.plan.free.estimate',
|
||||
ctaKey: 'pricing.plan.free.cta',
|
||||
ctaHref: externalLinks.cloud,
|
||||
features: [
|
||||
{ text: 'pricing.plan.free.feature1' },
|
||||
{ text: 'pricing.plan.free.feature2' }
|
||||
]
|
||||
}
|
||||
|
||||
const plans: PricingPlan[] = [
|
||||
...(SHOW_FREE_TIER ? [freePlan] : []),
|
||||
{
|
||||
id: 'standard',
|
||||
labelKey: 'pricing.plan.standard.label',
|
||||
summaryKey: 'pricing.plan.standard.summary',
|
||||
priceKey: 'pricing.plan.standard.price',
|
||||
creditsKey: 'pricing.plan.standard.credits',
|
||||
estimateKey: 'pricing.plan.standard.estimate',
|
||||
ctaKey: 'pricing.plan.standard.cta',
|
||||
ctaHref: subscribeUrl('standard'),
|
||||
featureIntroKey: SHOW_FREE_TIER
|
||||
? 'pricing.plan.standard.featureIntro'
|
||||
: undefined,
|
||||
features: [
|
||||
{ text: 'pricing.plan.standard.feature1' },
|
||||
{ text: 'pricing.plan.standard.feature2' },
|
||||
{ text: 'pricing.plan.standard.feature3' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'creator',
|
||||
labelKey: 'pricing.plan.creator.label',
|
||||
summaryKey: 'pricing.plan.creator.summary',
|
||||
priceKey: 'pricing.plan.creator.price',
|
||||
creditsKey: 'pricing.plan.creator.credits',
|
||||
estimateKey: 'pricing.plan.creator.estimate',
|
||||
ctaKey: 'pricing.plan.creator.cta',
|
||||
ctaHref: subscribeUrl('creator'),
|
||||
featureIntroKey: 'pricing.plan.creator.featureIntro',
|
||||
features: [
|
||||
{ text: 'pricing.plan.creator.feature1' },
|
||||
{ text: 'pricing.plan.creator.feature2' }
|
||||
],
|
||||
isPopular: true
|
||||
},
|
||||
{
|
||||
id: 'pro',
|
||||
labelKey: 'pricing.plan.pro.label',
|
||||
summaryKey: 'pricing.plan.pro.summary',
|
||||
priceKey: 'pricing.plan.pro.price',
|
||||
creditsKey: 'pricing.plan.pro.credits',
|
||||
estimateKey: 'pricing.plan.pro.estimate',
|
||||
ctaKey: 'pricing.plan.pro.cta',
|
||||
ctaHref: subscribeUrl('pro'),
|
||||
featureIntroKey: 'pricing.plan.pro.featureIntro',
|
||||
features: [
|
||||
{ text: 'pricing.plan.pro.feature1' },
|
||||
{ text: 'pricing.plan.pro.feature2' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'enterprise',
|
||||
labelKey: 'pricing.enterprise.label',
|
||||
summaryKey: 'pricing.enterprise.description',
|
||||
ctaKey: 'pricing.enterprise.cta',
|
||||
ctaHref: getRoutes(locale).cloudEnterprise,
|
||||
features: [],
|
||||
isEnterprise: true
|
||||
}
|
||||
]
|
||||
|
||||
const standardPlans = plans.filter((p) => !p.isEnterprise)
|
||||
const enterprisePlan = plans.find((p) => p.isEnterprise)!
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-4 py-16 lg:px-20 lg:py-14">
|
||||
<!-- Header -->
|
||||
<div class="mx-auto mb-8 max-w-3xl text-center lg:mb-10">
|
||||
<h1
|
||||
class="font-formula text-4xl font-light text-primary-comfy-canvas lg:text-5xl"
|
||||
>
|
||||
{{ t('pricing.title', locale) }}
|
||||
</h1>
|
||||
<p class="mt-3 text-base text-primary-comfy-canvas">
|
||||
{{ t('pricing.subtitle', locale) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Desktop: dynamic grid (3 or 4 columns) / Mobile: stacked cards -->
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'rounded-5xl bg-transparency-white-t4 hidden p-2 lg:grid lg:gap-2',
|
||||
standardPlans.length === 4 ? 'lg:grid-cols-4' : 'lg:grid-cols-3'
|
||||
)
|
||||
"
|
||||
>
|
||||
<PricingTierCard v-for="plan in standardPlans" :key="plan.id">
|
||||
<!-- Label + badge -->
|
||||
<div class="flex items-center gap-2 px-6 pt-6">
|
||||
<span
|
||||
class="text-primary-comfy-yellow translate-y-0.5 text-base font-bold tracking-wider"
|
||||
>
|
||||
{{ t(plan.labelKey, locale) }}
|
||||
</span>
|
||||
<span v-if="plan.isPopular" class="flex h-5 items-stretch">
|
||||
<img
|
||||
src="/icons/node-left.svg"
|
||||
alt=""
|
||||
class="-mx-px self-stretch"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow font-formula-narrow flex items-center px-2 text-sm font-bold tracking-wider text-primary-comfy-ink"
|
||||
>
|
||||
<span class="ppformula-text-center">
|
||||
{{ t('pricing.badge.popular', locale) }}
|
||||
</span>
|
||||
</span>
|
||||
<img
|
||||
src="/icons/node-right.svg"
|
||||
alt=""
|
||||
class="-mx-px self-stretch"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Summary -->
|
||||
<p class="px-6 text-sm text-primary-comfy-canvas">
|
||||
{{ t(plan.summaryKey, locale) }}
|
||||
</p>
|
||||
|
||||
<!-- Price -->
|
||||
<div v-if="plan.priceKey" class="flex items-baseline gap-1 px-6 pt-2">
|
||||
<span
|
||||
class="font-formula text-5xl font-light text-primary-comfy-canvas"
|
||||
>
|
||||
{{ t(plan.priceKey, locale) }}
|
||||
</span>
|
||||
<span class="text-sm text-primary-comfy-canvas">
|
||||
{{ t('pricing.plan.period', locale) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="px-6 pt-2" />
|
||||
|
||||
<!-- Credits -->
|
||||
<p
|
||||
v-if="plan.creditsKey"
|
||||
class="px-6 text-sm text-primary-comfy-canvas"
|
||||
>
|
||||
{{ t(plan.creditsKey, locale) }}
|
||||
</p>
|
||||
<div v-else class="px-6" />
|
||||
|
||||
<!-- Estimate -->
|
||||
<p
|
||||
v-if="plan.estimateKey"
|
||||
class="px-6 text-xs text-primary-comfy-canvas/80"
|
||||
>
|
||||
{{ t(plan.estimateKey, locale) }}
|
||||
</p>
|
||||
<div v-else class="px-6" />
|
||||
|
||||
<!-- Features -->
|
||||
<div v-if="plan.features.length" class="px-6 py-3">
|
||||
<p
|
||||
v-if="plan.featureIntroKey"
|
||||
class="mb-2 text-sm font-semibold text-primary-comfy-canvas"
|
||||
>
|
||||
{{ t(plan.featureIntroKey, locale) }}
|
||||
</p>
|
||||
<ul class="space-y-2">
|
||||
<li
|
||||
v-for="feature in plan.features"
|
||||
:key="feature.text"
|
||||
class="flex items-start gap-2"
|
||||
>
|
||||
<span class="text-primary-comfy-yellow mt-0.5 text-sm">✓</span>
|
||||
<span class="text-sm text-primary-comfy-canvas">
|
||||
{{ t(feature.text, locale) }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<!-- CTA -->
|
||||
<div class="flex self-end px-6">
|
||||
<BrandButton
|
||||
:href="plan.ctaHref"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="w-full text-center"
|
||||
>
|
||||
{{ t(plan.ctaKey, locale) }}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</PricingTierCard>
|
||||
</div>
|
||||
|
||||
<!-- Mobile: stacked plans -->
|
||||
<div class="flex flex-col gap-8 lg:hidden">
|
||||
<div v-for="plan in plans" :key="plan.id" class="flex flex-col">
|
||||
<!-- Main info card -->
|
||||
<div class="bg-transparency-white-t4 rounded-3xl p-6">
|
||||
<!-- Label + badge -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="text-primary-comfy-yellow text-xs font-bold tracking-wider"
|
||||
>
|
||||
{{ t(plan.labelKey, locale) }}
|
||||
</span>
|
||||
<span v-if="plan.isPopular" class="flex h-5 items-stretch">
|
||||
<img
|
||||
src="/icons/node-left.svg"
|
||||
alt=""
|
||||
class="-mx-px self-stretch"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow flex items-center px-2 text-[10px] font-bold tracking-wider text-primary-comfy-ink"
|
||||
>
|
||||
<span class="ppformula-text-center">
|
||||
{{ t('pricing.badge.popular', locale) }}
|
||||
</span>
|
||||
</span>
|
||||
<img
|
||||
src="/icons/node-right.svg"
|
||||
alt=""
|
||||
class="-mx-px self-stretch"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Enterprise heading -->
|
||||
<h2
|
||||
v-if="plan.isEnterprise"
|
||||
class="mt-3 text-2xl font-light text-primary-comfy-canvas"
|
||||
>
|
||||
{{ t('pricing.enterprise.heading', locale) }}
|
||||
</h2>
|
||||
|
||||
<!-- Summary -->
|
||||
<p class="mt-2 text-sm text-primary-comfy-canvas">
|
||||
{{ t(plan.summaryKey, locale) }}
|
||||
</p>
|
||||
|
||||
<!-- Price (standard plans only) -->
|
||||
<template v-if="plan.priceKey">
|
||||
<div class="mt-6 flex items-baseline gap-1">
|
||||
<span
|
||||
class="font-formula text-5xl font-light text-primary-comfy-canvas"
|
||||
>
|
||||
{{ t(plan.priceKey, locale) }}
|
||||
</span>
|
||||
<span class="text-sm text-primary-comfy-canvas/55">
|
||||
{{ t('pricing.plan.period', locale) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="plan.creditsKey"
|
||||
class="mt-4 text-xs font-medium text-primary-comfy-canvas"
|
||||
>
|
||||
{{ t(plan.creditsKey, locale) }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="plan.estimateKey"
|
||||
class="mt-2 text-xs text-primary-comfy-canvas"
|
||||
>
|
||||
{{ t(plan.estimateKey, locale) }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<!-- CTA -->
|
||||
<div class="mt-6">
|
||||
<BrandButton
|
||||
:href="plan.ctaHref"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
class="w-full text-center"
|
||||
>
|
||||
{{ t(plan.ctaKey, locale) }}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Features card -->
|
||||
<div
|
||||
v-if="plan.features.length"
|
||||
class="bg-transparency-white-t4 mt-2 rounded-3xl p-6"
|
||||
>
|
||||
<PricingPlanFeatureList
|
||||
:features="plan.features"
|
||||
:feature-intro-key="plan.featureIntroKey"
|
||||
:and-more-key="plan.andMoreKey"
|
||||
:locale
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Image (standard plans only) -->
|
||||
<div v-if="plan.image" class="mt-2">
|
||||
<img
|
||||
:src="plan.image"
|
||||
:alt="t(plan.labelKey, locale)"
|
||||
class="aspect-21/9 w-full rounded-3xl object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Enterprise section (desktop only, mobile handled in plan loop) -->
|
||||
<div
|
||||
class="bg-transparency-white-t4 rounded-5xl mt-8 hidden w-full flex-col p-2 lg:mt-8 lg:flex lg:flex-row"
|
||||
>
|
||||
<!-- Left side -->
|
||||
<div
|
||||
class="rounded-4.5xl flex w-full flex-col items-start justify-between gap-8 bg-primary-comfy-ink p-8"
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
class="text-primary-comfy-yellow text-xs font-bold tracking-wider"
|
||||
>
|
||||
{{ t(enterprisePlan.labelKey, locale) }}
|
||||
</span>
|
||||
<h2
|
||||
class="mt-3 text-2xl font-light text-primary-comfy-canvas lg:text-3xl"
|
||||
>
|
||||
{{ t('pricing.enterprise.heading', locale) }}
|
||||
</h2>
|
||||
<p class="mt-3 text-sm text-primary-comfy-canvas">
|
||||
{{ t(enterprisePlan.summaryKey, locale) }}
|
||||
</p>
|
||||
</div>
|
||||
<BrandButton :href="enterprisePlan.ctaHref" variant="outline" size="lg">
|
||||
{{ t(enterprisePlan.ctaKey, locale) }}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footnote -->
|
||||
<p class="mt-12 text-xs text-primary-comfy-canvas/70">
|
||||
{{ t('pricing.footnote', locale) }}
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,15 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('rounded-4.5xl bg-primary-comfy-ink p-8', className)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,42 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale, TranslationKey } 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', href } = defineProps<{
|
||||
labelKey: TranslationKey
|
||||
descriptionKey: TranslationKey
|
||||
locale?: Locale
|
||||
href?: string
|
||||
}>()
|
||||
|
||||
const ctaHref = computed(() => href ?? getRoutes(locale).contact)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PricingCard class="col-span-full">
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3 lg:gap-20">
|
||||
<div
|
||||
class="flex flex-col gap-6 lg:col-span-2 lg:flex-row lg:items-center"
|
||||
>
|
||||
<PricingPlanLabel :label="t(labelKey, locale)" />
|
||||
<p class="text-primary-warm-white">
|
||||
{{ t(descriptionKey, locale) }}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
:href="ctaHref"
|
||||
:target="href ? '_blank' : undefined"
|
||||
:rel="href ? 'noopener noreferrer' : undefined"
|
||||
variant="outline"
|
||||
>
|
||||
{{ t('pricing.enterprise.cta', locale) }}
|
||||
</Button>
|
||||
</div>
|
||||
</PricingCard>
|
||||
</template>
|
||||
@@ -1,43 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { Component as ComponentIcon } from '@lucide/vue'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const {
|
||||
locale = 'en',
|
||||
estimateKey,
|
||||
estimateCount
|
||||
} = defineProps<{
|
||||
credits: string
|
||||
label: string
|
||||
estimateKey?: TranslationKey
|
||||
estimateCount?: string
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const estimate = computed(() => {
|
||||
if (!estimateKey) return undefined
|
||||
const text = t(estimateKey, locale)
|
||||
return estimateCount ? text.replace('{count}', estimateCount) : text
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<ComponentIcon class="text-primary-comfy-orange size-4 shrink-0" />
|
||||
<span class="text-primary-warm-white ppformula-text-center text-sm">
|
||||
<span class="font-extrabold">
|
||||
{{ credits }}
|
||||
</span>
|
||||
{{ label }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="estimate" class="text-primary-warm-gray mt-1.5 text-xs">
|
||||
{{ estimate }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,90 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import type {
|
||||
PlanFeatureGroup,
|
||||
PlanFeatureStatus
|
||||
} from '../../data/pricingPlans'
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
|
||||
import { BookOpen, Check, Clock, X } from '@lucide/vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
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'
|
||||
interface PlanFeature {
|
||||
text: TranslationKey
|
||||
}
|
||||
|
||||
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[]
|
||||
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
|
||||
locale?: Locale
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-5">
|
||||
<div
|
||||
v-for="(group, groupIndex) in features"
|
||||
:key="group.titleKey ?? groupIndex"
|
||||
class="flex flex-col gap-2"
|
||||
<p
|
||||
v-if="featureIntroKey"
|
||||
:class="cn('text-primary-comfy-canvas text-sm font-semibold', introMargin)"
|
||||
>
|
||||
{{ t(featureIntroKey, locale) }}
|
||||
</p>
|
||||
<ul :class="listGap">
|
||||
<li
|
||||
v-for="feature in features"
|
||||
:key="feature.text"
|
||||
class="flex items-start gap-2"
|
||||
>
|
||||
<p v-if="group.titleKey" class="text-sm text-primary-comfy-canvas">
|
||||
{{ t(group.titleKey, locale) }}
|
||||
</p>
|
||||
<ul class="space-y-2">
|
||||
<li
|
||||
v-for="feature in group.features"
|
||||
:key="feature.text"
|
||||
class="flex items-start gap-2"
|
||||
>
|
||||
<component
|
||||
:is="
|
||||
feature.highlight
|
||||
? BookOpen
|
||||
: statusIcon[feature.status ?? 'included']
|
||||
"
|
||||
class="mt-0.5 size-4 shrink-0"
|
||||
:class="
|
||||
feature.highlight
|
||||
? 'text-primary-comfy-yellow'
|
||||
: 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="
|
||||
feature.highlight
|
||||
? 'text-primary-comfy-yellow'
|
||||
: statusTextClass[feature.status ?? 'included']
|
||||
"
|
||||
>
|
||||
{{ t(feature.text, locale) }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-primary-comfy-yellow mt-0.5 text-sm">✓</span>
|
||||
<span class="text-primary-comfy-canvas text-sm">
|
||||
{{ t(feature.text, locale) }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-if="nextUpKey" :class="nextUpClass">
|
||||
{{ t(nextUpKey, locale) }}
|
||||
</p>
|
||||
<p v-if="andMoreKey" :class="andMoreClass">
|
||||
{{ t(andMoreKey, locale) }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
label: string
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'text-primary-comfy-yellow text-base font-bold tracking-wider whitespace-nowrap uppercase',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
</template>
|
||||
@@ -1,66 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const {
|
||||
locale = 'en',
|
||||
billingPeriod,
|
||||
yearlyTotal
|
||||
} = defineProps<{
|
||||
price: string
|
||||
period: string
|
||||
originalPrice?: string
|
||||
discount?: string
|
||||
billingPeriod?: 'monthly' | 'yearly'
|
||||
yearlyTotal?: string
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const billingNote = computed(() => {
|
||||
if (billingPeriod === 'yearly' && yearlyTotal) {
|
||||
return t('pricing.period.billedYearly', locale).replace(
|
||||
'{total}',
|
||||
yearlyTotal
|
||||
)
|
||||
}
|
||||
if (billingPeriod === 'monthly') {
|
||||
return t('pricing.period.billedMonthly', locale)
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="mt-6 flex items-baseline gap-2">
|
||||
<span class="font-formula text-5xl font-light text-primary-comfy-canvas">
|
||||
{{ price }}
|
||||
</span>
|
||||
<div class="flex gap-2 max-sm:flex-col">
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span
|
||||
v-if="originalPrice"
|
||||
class="font-formula text-primary-warm-gray text-sm font-light line-through"
|
||||
>
|
||||
{{ originalPrice }}
|
||||
</span>
|
||||
<span class="text-primary-warm-white text-sm">
|
||||
{{ period }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span
|
||||
v-if="discount"
|
||||
class="text-primary-comfy-yellow text-sm max-sm:text-xs sm:ml-2"
|
||||
>
|
||||
{{ discount }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="billingNote" class="text-primary-warm-gray mt-2 text-sm">
|
||||
{{ billingNote }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,238 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { externalLinks } from '../../config/routes'
|
||||
import { planFeatures, 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 PricingContactBand from './PricingContactBand.vue'
|
||||
import PricingCredits from './PricingCredits.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,
|
||||
headingLevel = 'h1'
|
||||
} = defineProps<{
|
||||
locale?: Locale
|
||||
education?: boolean
|
||||
headingLevel?: 'h1' | 'h2'
|
||||
}>()
|
||||
|
||||
const selectedBillingPeriod = ref<BillingCycle>('yearly')
|
||||
|
||||
// reka-ui's single toggle group emits undefined when the active item is
|
||||
// clicked again; exactly one billing cycle must stay selected, so ignore it.
|
||||
const billingPeriod = computed({
|
||||
get: () => selectedBillingPeriod.value,
|
||||
set: (value: BillingCycle | undefined) => {
|
||||
if (value) {
|
||||
selectedBillingPeriod.value = value
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function displayPriceKey(plan: PricingPlan): TranslationKey | undefined {
|
||||
if (education) {
|
||||
// Plans without education pricing (e.g. free) fall back to the list price.
|
||||
if (billingPeriod.value === 'yearly') {
|
||||
return plan.eduYearlyPriceKey ?? plan.eduPriceKey ?? plan.priceKey
|
||||
}
|
||||
return plan.eduPriceKey ?? plan.priceKey
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// Derive each plan's display values once so the template doesn't call the
|
||||
// helpers twice (and avoids the non-null assertion on the price key).
|
||||
const planCards = computed(() =>
|
||||
pricingPlans.map((plan) => ({
|
||||
plan,
|
||||
priceKey: displayPriceKey(plan),
|
||||
originalPrice: originalPriceFor(plan),
|
||||
yearlyTotal: yearlyTotalFor(plan),
|
||||
features: planFeatures(plan, education, billingPeriod.value)
|
||||
}))
|
||||
)
|
||||
</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">
|
||||
<component
|
||||
:is="headingLevel"
|
||||
class="font-formula text-4xl font-light text-primary-comfy-canvas lg:text-5xl"
|
||||
>
|
||||
{{ t('pricing.title', locale) }}
|
||||
</component>
|
||||
<p
|
||||
class="mx-auto mt-3 max-w-xl text-base text-pretty text-primary-comfy-canvas"
|
||||
>
|
||||
{{ t(education ? 'pricing.subtitle.edu' : '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-40 text-2xs sm:min-w-48 sm:text-xs"
|
||||
>
|
||||
<span class="ppformula-text-center">{{
|
||||
t(
|
||||
education
|
||||
? 'pricing.period.monthly.edu'
|
||||
: 'pricing.period.monthly',
|
||||
locale
|
||||
)
|
||||
}}</span>
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value="yearly"
|
||||
class="min-w-40 text-2xs sm:min-w-48 sm:text-xs"
|
||||
>
|
||||
<span class="ppformula-text-center">{{
|
||||
t(
|
||||
education ? 'pricing.period.yearly.edu' : 'pricing.period.yearly',
|
||||
locale
|
||||
)
|
||||
}}</span>
|
||||
</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,
|
||||
priceKey,
|
||||
originalPrice,
|
||||
yearlyTotal,
|
||||
features
|
||||
} in planCards"
|
||||
: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" size="xs">
|
||||
{{ t('pricing.badge.popular', locale) }}</Badge
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Price -->
|
||||
<PricingPrice
|
||||
v-if="priceKey"
|
||||
:price="t(priceKey, locale)"
|
||||
:period="t('pricing.plan.period', locale)"
|
||||
:original-price="originalPrice"
|
||||
:billing-period="billingPeriod"
|
||||
:yearly-total="yearlyTotal"
|
||||
:locale
|
||||
/>
|
||||
|
||||
<!-- Features -->
|
||||
<div v-if="features.length" class="mt-8">
|
||||
<PricingPlanFeatureList :features="[{ 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 />
|
||||
|
||||
<PricingContactBand
|
||||
:label-key="
|
||||
education
|
||||
? 'pricing.creativeCampus.label'
|
||||
: 'pricing.enterprise.label'
|
||||
"
|
||||
:description-key="
|
||||
education
|
||||
? 'pricing.creativeCampus.description'
|
||||
: 'pricing.enterprise.description'
|
||||
"
|
||||
:href="
|
||||
education ? externalLinks.creativeCampusApplicationForm : undefined
|
||||
"
|
||||
:locale
|
||||
/>
|
||||
</div>
|
||||
<PricingStudentAmbassadorBand v-if="education" :locale />
|
||||
|
||||
<!-- Footnote -->
|
||||
<p class="mt-12 text-xs text-primary-comfy-canvas/70">
|
||||
{{ t('pricing.footnote', locale) }}
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,49 +0,0 @@
|
||||
<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 Button from '../ui/button/Button.vue'
|
||||
import PricingCard from './PricingCard.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{
|
||||
locale?: Locale
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PricingCard class="col-span-full max-lg:mx-auto max-lg:max-w-lg lg:py-3">
|
||||
<div class="flex flex-col gap-5 lg:flex-row lg:items-center lg:gap-8">
|
||||
<span
|
||||
class="text-primary-warm-gray inline-flex shrink-0 items-center gap-2 text-base tracking-wider uppercase"
|
||||
>
|
||||
<Clock class="size-5" />
|
||||
<span class="ppformula-text-center">
|
||||
{{ t('pricing.studentAmbassador.comingSoon', locale) }}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="ppformula-text-center shrink-0 text-base font-bold tracking-wider text-primary-comfy-canvas uppercase"
|
||||
>
|
||||
{{ t('pricing.studentAmbassador.label', locale) }}
|
||||
</span>
|
||||
|
||||
<p class="text-primary-warm-white ppformula-text-center flex-1 text-base">
|
||||
{{ t('pricing.studentAmbassador.description', locale) }}
|
||||
</p>
|
||||
|
||||
<Button
|
||||
:href="externalLinks.studentAmbassadorForm"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="underlineLink"
|
||||
class="shrink-0"
|
||||
>
|
||||
{{ t('pricing.studentAmbassador.cta', locale) }}
|
||||
</Button>
|
||||
</div>
|
||||
</PricingCard>
|
||||
</template>
|
||||
@@ -1,171 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import type { PlanFeatureGroup } from './PricingPlanFeatureList.vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { Component as ComponentIcon } from '@lucide/vue'
|
||||
|
||||
import { subscribeUrl } from '../../data/pricingPlans'
|
||||
import {
|
||||
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>
|
||||
7
apps/website/src/components/pricing/PricingTierCard.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
class="bg-primary-comfy-ink rounded-4.5xl row-span-7 grid grid-rows-subgrid pb-3"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,7 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
import { Clock } from '@lucide/vue'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import CheckIcon from '../icons/CheckIcon.vue'
|
||||
|
||||
@@ -56,7 +54,11 @@ const features: IncludedFeature[] = [
|
||||
},
|
||||
{
|
||||
titleKey: 'pricing.included.feature11.title',
|
||||
descriptionKey: 'pricing.included.feature11.description',
|
||||
descriptionKey: 'pricing.included.feature11.description'
|
||||
},
|
||||
{
|
||||
titleKey: 'pricing.included.feature12.title',
|
||||
descriptionKey: 'pricing.included.feature12.description',
|
||||
isComingSoon: true
|
||||
}
|
||||
]
|
||||
@@ -90,9 +92,11 @@ const features: IncludedFeature[] = [
|
||||
>
|
||||
<!-- Title -->
|
||||
<div class="flex items-start gap-3">
|
||||
<Clock
|
||||
<img
|
||||
v-if="feature.isComingSoon"
|
||||
class="mt-0.5 size-4 shrink-0 text-primary-comfy-canvas/55"
|
||||
src="/icons/clock.svg"
|
||||
alt=""
|
||||
class="mt-0.5 size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<CheckIcon
|
||||
@@ -101,12 +105,6 @@ const features: IncludedFeature[] = [
|
||||
/>
|
||||
<p class="text-sm font-medium text-primary-comfy-canvas">
|
||||
{{ t(feature.titleKey, locale) }}
|
||||
<span
|
||||
v-if="feature.isComingSoon"
|
||||
class="block text-primary-comfy-canvas/55"
|
||||
>
|
||||
{{ t('pricing.included.comingSoon', locale) }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../../i18n/translations'
|
||||
|
||||
import { externalLinks } from '../../../config/routes'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import CardArrow from '../../common/CardArrow.vue'
|
||||
import GlassCard from '../../common/GlassCard.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
@@ -29,7 +27,7 @@ const cards = [
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-4 pt-24 lg:px-20 lg:pt-40">
|
||||
<h2
|
||||
class="text-3.5xl/tight mx-auto max-w-3xl text-center font-light text-primary-comfy-canvas lg:text-5xl/tight"
|
||||
class="text-primary-comfy-canvas text-3.5xl/tight mx-auto max-w-3xl text-center font-light lg:text-5xl/tight"
|
||||
>
|
||||
{{ headingParts[0]
|
||||
}}<span class="text-white">{{
|
||||
@@ -39,11 +37,10 @@ const cards = [
|
||||
</h2>
|
||||
|
||||
<GlassCard class="mt-12 grid grid-cols-1 gap-6 lg:mt-20 lg:grid-cols-2">
|
||||
<a
|
||||
<div
|
||||
v-for="card in cards"
|
||||
:key="card.labelKey"
|
||||
:href="externalLinks.cloud"
|
||||
class="group rounded-4.5xl block overflow-hidden bg-primary-comfy-ink"
|
||||
class="bg-primary-comfy-ink rounded-4.5xl overflow-hidden"
|
||||
>
|
||||
<img
|
||||
:src="card.image"
|
||||
@@ -54,27 +51,23 @@ const cards = [
|
||||
/>
|
||||
|
||||
<div class="mt-8 p-6">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<p
|
||||
class="text-primary-comfy-yellow text-sm font-bold tracking-widest uppercase"
|
||||
>
|
||||
{{ t(card.labelKey, locale) }}
|
||||
</p>
|
||||
|
||||
<CardArrow hover="group" class="shrink-0" />
|
||||
</div>
|
||||
<p
|
||||
class="text-primary-comfy-yellow text-sm font-bold tracking-widest uppercase"
|
||||
>
|
||||
{{ t(card.labelKey, locale) }}
|
||||
</p>
|
||||
|
||||
<h3
|
||||
class="mt-8 text-3xl/tight font-light whitespace-pre-line text-primary-comfy-canvas"
|
||||
class="text-primary-comfy-canvas mt-8 text-3xl/tight font-light whitespace-pre-line"
|
||||
>
|
||||
{{ t(card.titleKey, locale) }}
|
||||
</h3>
|
||||
|
||||
<p class="mt-8 text-base/normal text-primary-comfy-canvas">
|
||||
<p class="text-primary-comfy-canvas mt-8 text-base/normal">
|
||||
{{ t(card.descriptionKey, locale) }}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -11,7 +11,6 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
:locale="locale"
|
||||
heading-key="cloud.faq.heading"
|
||||
faq-prefix="cloud.faq"
|
||||
:faq-count="12"
|
||||
footer-key="cloud.faq.footer"
|
||||
:faq-count="15"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -17,18 +17,18 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
>
|
||||
<div class="max-w-2xl">
|
||||
<h2
|
||||
class="text-2xl/tight font-medium text-primary-comfy-ink lg:text-3xl/tight"
|
||||
class="text-primary-comfy-ink text-2xl/tight font-medium lg:text-3xl/tight"
|
||||
>
|
||||
{{ t('cloud.pricing.title', locale) }}
|
||||
</h2>
|
||||
|
||||
<p class="mt-4 text-base text-primary-comfy-ink">
|
||||
<p class="text-primary-comfy-ink mt-4 text-base">
|
||||
{{ t('cloud.pricing.description', locale) }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="SHOW_FREE_TIER"
|
||||
class="mt-4 text-base font-bold text-primary-comfy-ink"
|
||||
class="text-primary-comfy-ink mt-4 text-base font-bold"
|
||||
>
|
||||
{{ t('cloud.pricing.tagline', locale) }}
|
||||
</p>
|
||||
@@ -36,7 +36,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
<a
|
||||
:href="getRoutes(locale).cloudPricing"
|
||||
class="text-primary-comfy-yellow shrink-0 rounded-2xl bg-primary-comfy-ink px-6 py-3 text-center text-sm font-semibold transition-opacity hover:opacity-90"
|
||||
class="bg-primary-comfy-ink text-primary-comfy-yellow shrink-0 rounded-2xl px-6 py-3 text-center text-sm font-semibold transition-opacity hover:opacity-90"
|
||||
>
|
||||
{{ t('cloud.pricing.cta', locale) }}
|
||||
</a>
|
||||
|
||||
@@ -6,7 +6,6 @@ import type { Locale } from '../../../i18n/translations'
|
||||
import { externalLinks } from '../../../config/routes'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import BrandButton from '../../common/BrandButton.vue'
|
||||
import CardArrow from '../../common/CardArrow.vue'
|
||||
|
||||
type ModelCard = {
|
||||
titleKey:
|
||||
@@ -15,10 +14,11 @@ type ModelCard = {
|
||||
| 'cloud.aiModels.card.seedance20'
|
||||
| 'cloud.aiModels.card.qwenImageEdit'
|
||||
| 'cloud.aiModels.card.wan22TextToVideo'
|
||||
| 'cloud.aiModels.card.gptImage2'
|
||||
imageSrc: string
|
||||
badgeIcon: string
|
||||
badgeClass: string
|
||||
layoutClass: string
|
||||
objectPosition?: string
|
||||
}
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
@@ -32,45 +32,48 @@ const modelCards: ModelCard[] = [
|
||||
imageSrc:
|
||||
'https://media.comfy.org/website/cloud/ai-models/seedance-20.webm',
|
||||
badgeIcon: '/icons/ai-models/bytedance.svg',
|
||||
badgeClass: `${badgeBase} rounded-2xl`
|
||||
badgeClass: `${badgeBase} rounded-2xl`,
|
||||
layoutClass: 'lg:col-span-6 lg:aspect-[16/7]'
|
||||
},
|
||||
{
|
||||
titleKey: 'cloud.aiModels.card.nanoBananaPro',
|
||||
imageSrc:
|
||||
'https://media.comfy.org/website/cloud/ai-models/nano-banana-pro.webp',
|
||||
badgeIcon: '/icons/ai-models/gemini.svg',
|
||||
badgeClass: `${badgeBase} rounded-2xl`
|
||||
badgeClass: `${badgeBase} rounded-2xl`,
|
||||
layoutClass: 'lg:col-span-6 lg:aspect-[16/7]',
|
||||
objectPosition: 'center 20%'
|
||||
},
|
||||
{
|
||||
titleKey: 'cloud.aiModels.card.grokImagine',
|
||||
imageSrc: 'https://media.comfy.org/website/cloud/ai-models/grok-video.webm',
|
||||
badgeIcon: '/icons/ai-models/grok.svg',
|
||||
badgeClass: `${badgeBase} rounded-2xl`
|
||||
badgeClass: `${badgeBase} rounded-2xl`,
|
||||
layoutClass: 'lg:col-span-4 lg:aspect-[4/3]'
|
||||
},
|
||||
{
|
||||
titleKey: 'cloud.aiModels.card.qwenImageEdit',
|
||||
imageSrc:
|
||||
'https://media.comfy.org/website/cloud/ai-models/qwen-image-edit.webp',
|
||||
badgeIcon: '/icons/ai-models/qwen.svg',
|
||||
badgeClass: `${badgeBase} rounded-2xl`
|
||||
badgeClass: `${badgeBase} rounded-2xl`,
|
||||
layoutClass: 'lg:col-span-4 lg:aspect-[4/3]'
|
||||
},
|
||||
{
|
||||
titleKey: 'cloud.aiModels.card.wan22TextToVideo',
|
||||
imageSrc: 'https://media.comfy.org/website/cloud/ai-models/wan-22.webm',
|
||||
badgeIcon: '/icons/ai-models/wan.svg',
|
||||
badgeClass: `${badgeBase} rounded-2xl`
|
||||
},
|
||||
{
|
||||
titleKey: 'cloud.aiModels.card.gptImage2',
|
||||
imageSrc:
|
||||
'https://media.comfy.org/website/cloud/ai-models/gpt-image-2.webm',
|
||||
badgeIcon: '/icons/ai-models/openai.svg',
|
||||
badgeClass: `${badgeBase} rounded-2xl`
|
||||
badgeClass: `${badgeBase} rounded-2xl`,
|
||||
layoutClass: 'lg:col-span-4 lg:aspect-[4/3]'
|
||||
}
|
||||
]
|
||||
|
||||
const cardClass =
|
||||
'group relative h-72 cursor-pointer overflow-hidden rounded-3xl bg-black/40 lg:col-span-4 lg:aspect-square lg:h-auto'
|
||||
function getCardClass(layoutClass: string): string {
|
||||
return cn(
|
||||
layoutClass,
|
||||
'group relative h-72 cursor-pointer overflow-hidden rounded-4xl bg-black/40 lg:h-auto'
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -97,18 +100,23 @@ const cardClass =
|
||||
</p>
|
||||
|
||||
<div class="mt-16 w-full lg:mt-24">
|
||||
<div class="rounded-4xl bg-white/8 p-2 lg:p-1.5">
|
||||
<div class="rounded-4xl border border-white/12 p-2 lg:p-1.5">
|
||||
<div class="grid grid-cols-1 gap-2 lg:grid-cols-12">
|
||||
<a
|
||||
v-for="card in modelCards"
|
||||
:key="card.titleKey"
|
||||
:href="externalLinks.workflows"
|
||||
:class="cardClass"
|
||||
:class="getCardClass(card.layoutClass)"
|
||||
>
|
||||
<video
|
||||
v-if="card.imageSrc.endsWith('.webm')"
|
||||
:src="card.imageSrc"
|
||||
:aria-label="t(card.titleKey, locale)"
|
||||
:style="
|
||||
card.objectPosition
|
||||
? { objectPosition: card.objectPosition }
|
||||
: undefined
|
||||
"
|
||||
class="size-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
autoplay
|
||||
loop
|
||||
@@ -126,6 +134,11 @@ const cardClass =
|
||||
v-else
|
||||
:src="card.imageSrc"
|
||||
:alt="t(card.titleKey, locale)"
|
||||
:style="
|
||||
card.objectPosition
|
||||
? { objectPosition: card.objectPosition }
|
||||
: undefined
|
||||
"
|
||||
class="size-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
@@ -155,14 +168,10 @@ const cardClass =
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="text-primary-warm-white absolute right-20 bottom-6 left-6 text-2xl/tight font-light whitespace-pre-line drop-shadow-[0_2px_8px_rgba(0,0,0,0.9)] lg:top-6 lg:right-auto lg:bottom-auto lg:text-3xl"
|
||||
class="text-primary-warm-white absolute inset-x-6 bottom-6 text-2xl/tight font-light whitespace-pre-line drop-shadow-[0_2px_8px_rgba(0,0,0,0.9)] lg:top-6 lg:right-auto lg:bottom-auto lg:text-3xl"
|
||||
>
|
||||
{{ t(card.titleKey, locale) }}
|
||||
</p>
|
||||
|
||||
<CardArrow
|
||||
class="absolute right-5 bottom-5 lg:right-6 lg:bottom-6"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/* eslint-disable vue/no-unused-properties -- props forwarded via useForwardPropsEmits */
|
||||
import type { AccordionRootEmits, AccordionRootProps } from 'reka-ui'
|
||||
import { AccordionRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
|
||||
const props = defineProps<AccordionRootProps>()
|
||||
const emits = defineEmits<AccordionRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AccordionRoot v-slot="slotProps" data-slot="accordion" v-bind="forwarded">
|
||||
<slot v-bind="slotProps" />
|
||||
</AccordionRoot>
|
||||
</template>
|
||||