Compare commits

...

17 Commits

Author SHA1 Message Date
pythongosssss
04624eb8de Merge branch 'main' into pysssss/churnkey 2026-06-29 19:30:59 +01:00
imick-io
be8e0010ee feat(website): rebuild Comfy MCP page on the design system (+ zh-CN) (#13283)
Rebuilds the **Comfy MCP** marketing page on the website design-system
stack and adds the missing zh-CN page.

## What's here
- Replaces the bespoke `components/product/mcp/` section silo with thin
`templates/mcp/*` wrappers over reusable `blocks/` + `common/`
components.
- Adds `src/pages/zh-CN/mcp.astro` and threads `locale` through every
section (was English-only).
- New/extended design-system blocks:
- `FeatureGrid01` — setup steps, with a reusable `ui/CopyableField`
(uses `@vueuse/core` `useClipboard`).
- `FeatureGrid02` — how-it-works steps with `NodeUnionIcon` connectors +
a CTA pair via `ui/button`.
- `FeatureRows01` — alternating media rows; `ReasonsSplit01` — "why"
list.
- `HeroSplit01` gained `subtitle`, a `media` slot, and a `class`
passthrough; `SectionHeader` gained `align`.
- Standardized block section spacing on `px-6 py-16 lg:py-24`.
- Refreshed all 8 MCP FAQ answers (en + zh-CN) and hydrated the FAQ
section so the accordion is interactive.

## Notes
- Stacked on the original MCP landing-page commits (previously PR
#13095); those ride along here.
- `typecheck` and `build` are green; `/mcp` and `/zh-CN/mcp` both render
in both locales.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Balpreet Brar <balpreet.brar@growthnatives.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-06-29 10:04:24 -07:00
imick-io
d0e97d6933 fix(website): move launches nav item and add cleanplate workflow link (#13282)
## Summary
- Move the `/launches` nav item from **Company → More** to **Products →
Features** in the main navbar
- Add the workflow link to the **Cleanplate Walkthrough** learning
tutorial (`https://comfy.org/workflows/8f2cf0df5da6-8f2cf0df5da6/`)

## Changes
- `apps/website/src/data/mainNavigation.ts`
- `apps/website/src/data/learningTutorials.ts`

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 16:24:10 +00:00
pythongosssss
cb3765ba8f feat(billing): drive Churnkey from a single churnkey_app_id flag
- Replace churnkey_cancellation_enabled bool + build-time __CHURNKEY_APP_ID__ with one churnkey_app_id remote-config flag
- Truthy flag both gates the flow and supplies the embed app id; sourced via useFeatureFlags
- Drop the vite define, global.d.ts decl, env_example entry, and e2e window override
- Update unit + e2e tests to set the app id via the flag
2026-06-26 08:02:16 -07:00
pythongosssss
7546a6e960 Merge remote-tracking branch 'origin/main' into pysssss/churnkey
# Conflicts:
#	src/platform/workspace/components/SubscriptionPanelContentWorkspace.test.ts
#	src/platform/workspace/components/SubscriptionPanelContentWorkspace.vue
2026-06-26 04:31:00 -07:00
pythongosssss
b40974868a rabbit: pin timezone 2026-06-26 04:11:51 -07:00
pythongosssss
7b638a995a rabbit: honor disabled events for cancellation reconsidered 2026-06-26 04:05:28 -07:00
pythongosssss
6dc7e6f4a2 Merge remote-tracking branch 'origin/main' into pysssss/churnkey
# Conflicts:
#	src/platform/workspace/api/workspaceApi.ts
#	src/platform/workspace/components/SubscriptionPanelContentWorkspace.vue
#	src/services/dialogService.ts
2026-06-25 11:28:59 -07:00
pythongosssss
0d16721eb9 wip 2026-06-25 10:09:18 -07:00
pythongosssss
8957d61a32 test(churnkey): add e2e coverage for cancellation flow routing
- Cover flag-disabled, auth-404 fallback, and embed-launch routing in a
  @cloud Playwright spec with a stubbed window.churnkey
- Add __CHURNKEY_APP_ID_OVERRIDE__ window hook so built bundles can be
  configured per test (the app ID is otherwise a compile-time define)
- Add FeatureFlagHelper.setServerFeatures for production builds where
  localStorage flag overrides are dead-code-eliminated
- Route CancelSubscriptionDialog.open through launchCancellationFlow and
  allow skipping the visibility wait when Churnkey handles the flow
- Move window.churnkey declaration into churnkey/types.ts and type init
  against ChurnkeyInitConfig
- Document auth-404 fallback and failure_reason telemetry semantics
2026-06-12 13:46:38 -07:00
pythongosssss
2f652aab92 wip: churnkey impl
- handle legacy vs workspace, via cancel in Stripe
- move telemetry
- show cancelled date (fix? to check)
2026-06-12 13:16:01 -07:00
pythongosssss
10b89ee889 merge: origin/main into pysssss/churnkey 2026-06-12 13:09:34 -07:00
Glary-Bot
4f33013411 style(churnkey): scope disclaimer + use focus-visible for inputs
- Scope '.active-discount-disclaimer' to '#ck-app' so it can't leak
  into non-Churnkey UI that happens to use the same class name.
- Switch the embed input/select/textarea focus rules to ':focus-visible'
  with a higher-contrast ring, so keyboard users get a clear focus
  indicator instead of the dropped outline.
2026-05-14 19:22:04 +00:00
pythongosssss
78421d9990 wip: update churnkey cancellation flow
- add styling to churnkey ui elements
- handle missing 404 endpoint
- extract dialog display to platform layer to prevent circular dep
- update env var
2026-05-14 12:11:04 -07:00
Glary-Bot
b02d2fea85 test(billing): tighten Churnkey routing test cleanup
- Move console.warn restore into afterEach so it survives assertion
  failures, and assert the warning carries VITE_CHURNKEY_APP_ID rather
  than just any string.
- Drop the redundant import.meta.env restore in useChurnkey.test.ts;
  vi.unstubAllEnvs() already handles it.
- Flatten launchCancellationFlow with early returns to reduce nesting.
2026-05-09 02:52:46 +00:00
Glary-Bot
8fb5f49505 fix(billing): address Churnkey review feedback
- Fall back to the legacy cancellation dialog when the feature flag is
  enabled but VITE_CHURNKEY_APP_ID is not set, so a partially configured
  rollout never breaks cancellation.
- Emit cancellation_flow_closed exactly once per session (in onClose),
  carrying the survey_response captured in onCancel.
- Move the canceled/survey state into the launcher closure to avoid
  leaking module-level state across concurrent sessions.
- Stop rethrowing after the user-facing toast is shown; the only caller
  uses void launchCancellationFlow(...) and would otherwise produce an
  unhandled rejection on top of the toast.
- Add tests covering: routing fallback when not configured, single
  flow_closed emission on cancel, reconsidered tracking on plain close,
  and toast on embed failure.
2026-05-08 23:31:08 +00:00
Glary-Bot
218b3cb260 feat(billing): add Churnkey cancellation flow integration
Replaces the simple cancel-subscription confirmation dialog with the
Churnkey-hosted cancel flow when the churnkey_cancellation_enabled
feature flag is on, so we can collect survey data and run cancellation
A/B tests through the Churnkey dashboard.

- Vite plugin (cloud-only) injects the Churnkey embed loader using
  VITE_CHURNKEY_APP_ID; non-cloud builds and missing env var no-op.
- useChurnkey composable wraps window.churnkey.init; launcher fetches
  HMAC credentials from /api/billing/churnkey/auth and delegates
  cancellation to the existing /api/billing/subscription/cancel via
  handleCancel so the backend stays the source of truth.
- Feature-flag gating via useFeatureFlags + remoteConfig falls back to
  the legacy dialog whenever the flag is off, Churnkey is unconfigured,
  or the embed script fails to load.
- New PostHog events: cancellation_flow_opened, cancellation_flow_closed
  (with outcome), and cancellation_reconsidered (sets a user-level
  property for downstream cohort analysis).
2026-05-08 23:13:03 +00:00
58 changed files with 3106 additions and 63 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

@@ -26,7 +26,7 @@ function toggle(index: number) {
</script>
<template>
<section class="max-w-9xl mx-auto px-4 py-24 md:px-20 md:py-40">
<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

View File

@@ -0,0 +1,117 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { Component } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import CopyableField from '@/components/ui/copyable-field/CopyableField.vue'
import SectionHeader from '../common/SectionHeader.vue'
type CardAction =
| {
type: 'link'
label: string
href: string
target?: '_blank'
icon?: Component
}
| { type: 'code'; value: string }
export interface FeatureCard {
id: string
label?: string
title: string
description: string
action?: CardAction
}
const {
eyebrow,
heading,
subtitle,
columns = 3,
cards,
copyLabel,
copiedLabel
} = defineProps<{
eyebrow?: string
heading: string
subtitle?: string
columns?: 2 | 3 | 4
cards: readonly FeatureCard[]
copyLabel?: string
copiedLabel?: string
}>()
const columnClass: Record<2 | 3 | 4, string> = {
2: 'lg:grid-cols-2',
3: 'lg:grid-cols-3',
4: 'lg:grid-cols-4'
}
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<SectionHeader :label="eyebrow" align="start">
{{ heading }}
<template v-if="subtitle" #subtitle>
<p class="mt-4 max-w-xl text-sm text-smoke-700 lg:text-base">
{{ subtitle }}
</p>
</template>
</SectionHeader>
<div :class="cn('mt-16 grid grid-cols-1 gap-6', columnClass[columns])">
<div
v-for="card in cards"
:key="card.id"
class="bg-transparency-white-t4 flex flex-col rounded-3xl p-6 lg:p-8"
>
<p
v-if="card.label"
class="text-primary-comfy-yellow text-xs font-bold tracking-widest uppercase"
>
{{ card.label }}
</p>
<h3
:class="
cn(
'text-xl font-light text-primary-comfy-canvas lg:text-2xl',
card.label && 'mt-3'
)
"
>
{{ card.title }}
</h3>
<p class="mt-3 text-sm text-smoke-700">
{{ card.description }}
</p>
<div v-if="card.action" class="mt-6">
<Button
v-if="card.action.type === 'link'"
as="a"
:href="card.action.href"
:target="card.action.target"
:rel="
card.action.target === '_blank'
? 'noopener noreferrer'
: undefined
"
variant="outline"
:append-icon="card.action.icon"
>
{{ card.action.label }}
</Button>
<CopyableField
v-else
:value="card.action.value"
:copy-label="copyLabel"
:copied-label="copiedLabel"
/>
</div>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,100 @@
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import SectionHeader from '../common/SectionHeader.vue'
import NodeUnionIcon from '../icons/NodeUnionIcon.vue'
type Cta = { label: string; href: string; target?: '_blank' }
export interface FeatureStep {
id: string
number: string
title: string
description: string
}
defineProps<{
heading: string
steps: readonly FeatureStep[]
primaryCta?: Cta
secondaryCta?: Cta
}>()
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<SectionHeader>{{ heading }}</SectionHeader>
<!-- Step cards in a row, joined by node-union connectors on desktop -->
<div
class="mt-12 flex flex-col gap-4 lg:flex-row lg:items-stretch lg:gap-0"
>
<template v-for="(step, i) in steps" :key="step.id">
<div
v-if="i > 0"
class="relative z-10 -mx-px hidden shrink-0 items-center justify-center self-stretch lg:flex"
aria-hidden="true"
>
<NodeUnionIcon
class="text-primary-comfy-yellow size-4 scale-x-150 rotate-90"
/>
</div>
<div
class="border-primary-comfy-yellow flex flex-1 flex-col rounded-[40px] border-2 bg-primary-comfy-ink p-2"
>
<div class="flex flex-1 flex-col gap-4 p-8">
<div>
<p
class="text-primary-comfy-yellow text-xs font-bold tracking-widest uppercase"
>
{{ step.number }}
</p>
<h3
class="mt-1 text-2xl font-medium tracking-widest text-primary-comfy-canvas uppercase"
>
{{ step.title }}
</h3>
</div>
<p class="text-primary-comfy-canvas">
{{ step.description }}
</p>
</div>
</div>
</template>
</div>
<div
v-if="primaryCta || secondaryCta"
class="mt-12 flex flex-col items-center gap-4 lg:flex-row lg:justify-center"
>
<Button
v-if="primaryCta"
as="a"
:href="primaryCta.href"
:target="primaryCta.target"
:rel="
primaryCta.target === '_blank' ? 'noopener noreferrer' : undefined
"
size="lg"
class="w-full lg:w-auto lg:min-w-48"
>
{{ primaryCta.label }}
</Button>
<Button
v-if="secondaryCta"
as="a"
:href="secondaryCta.href"
:target="secondaryCta.target"
:rel="
secondaryCta.target === '_blank' ? 'noopener noreferrer' : undefined
"
variant="outline"
size="lg"
class="w-full lg:w-auto lg:min-w-48"
>
{{ secondaryCta.label }}
</Button>
</div>
</section>
</template>

View File

@@ -0,0 +1,108 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { Locale } from '../../i18n/translations'
import GlassCard from '../common/GlassCard.vue'
import SectionHeader from '../common/SectionHeader.vue'
import VideoPlayer from '../common/VideoPlayer.vue'
import type { VideoTrack } from '../common/VideoPlayer.vue'
type RowMedia =
| { type: 'image'; src: string; alt?: string }
| {
type: 'video'
src: string
// <video> has no native alt; used as the player's accessible label.
alt?: string
poster?: string
tracks?: readonly VideoTrack[]
autoplay?: boolean
loop?: boolean
minimal?: boolean
hideControls?: boolean
}
export interface FeatureRow {
id: string
title: string
description: string
media: RowMedia
}
const {
heading,
eyebrow,
locale = 'en',
rows
} = defineProps<{
heading: string
eyebrow?: string
locale?: Locale
rows: readonly FeatureRow[]
}>()
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<SectionHeader :label="eyebrow" max-width="xl">
{{ heading }}
</SectionHeader>
<div class="mt-16 flex flex-col gap-4 lg:gap-6">
<GlassCard
v-for="(row, i) in rows"
:key="row.id"
class="flex flex-col gap-8 lg:flex-row lg:items-stretch lg:gap-0"
>
<!-- Text -->
<div
:class="
cn(
'order-2 flex flex-col justify-center gap-4 p-6 lg:w-1/2 lg:p-12',
i % 2 === 0 ? 'lg:order-1' : 'lg:order-2'
)
"
>
<h3 class="text-2xl font-light text-primary-comfy-canvas lg:text-3xl">
{{ row.title }}
</h3>
<p class="text-sm text-smoke-700 lg:text-base">
{{ row.description }}
</p>
</div>
<!-- Media: image or video -->
<div
:class="
cn(
'order-1 flex lg:w-1/2',
i % 2 === 0 ? 'lg:order-2' : 'lg:order-1'
)
"
>
<img
v-if="row.media.type === 'image'"
:src="row.media.src"
:alt="row.media.alt ?? row.title"
loading="lazy"
decoding="async"
class="aspect-4/3 w-full rounded-4xl object-cover"
/>
<VideoPlayer
v-else
:locale="locale"
:aria-label="row.media.alt ?? row.title"
:src="row.media.src"
:poster="row.media.poster"
:tracks="row.media.tracks"
:autoplay="row.media.autoplay"
:loop="row.media.loop"
:minimal="row.media.minimal"
:hide-controls="row.media.hideControls"
class="w-full"
/>
</div>
</GlassCard>
</div>
</section>
</template>

View File

@@ -1,6 +1,8 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { HTMLAttributes } from 'vue'
import type { Locale } from '../../i18n/translations'
import BrandButton from '../common/BrandButton.vue'
import ProductHeroBadge from '../common/ProductHeroBadge.vue'
@@ -27,6 +29,7 @@ const {
badgeLogoAlt,
title,
titleHighlight,
subtitle,
features = [],
primaryCta,
secondaryCta,
@@ -41,14 +44,17 @@ const {
videoAutoplay = false,
videoLoop = false,
videoMinimal = false,
videoHideControls = false
videoHideControls = false,
class: className
} = defineProps<{
locale?: Locale
class?: HTMLAttributes['class']
badgeText: string
badgeLogoSrc?: string
badgeLogoAlt?: string
title: string
titleHighlight?: string
subtitle?: string
features?: string[]
primaryCta: Cta
secondaryCta?: Cta
@@ -72,7 +78,8 @@ const {
:class="
cn(
'max-w-9xl relative mx-auto flex flex-col items-center gap-12 px-6 pt-20 pb-16 md:pt-28 md:pb-24 lg:items-center lg:gap-16 lg:px-16',
imagePosition === 'right' ? 'lg:flex-row' : 'lg:flex-row-reverse'
imagePosition === 'right' ? 'lg:flex-row' : 'lg:flex-row-reverse',
className
)
"
>
@@ -84,7 +91,7 @@ const {
/>
<h1
class="mt-8 text-2xl leading-[125%] font-light tracking-[-1.44px] text-primary-comfy-canvas md:text-4xl lg:text-5xl"
class="mt-8 text-2xl leading-[125%] font-light tracking-[-1.44px] whitespace-pre-line text-primary-comfy-canvas md:text-4xl lg:text-5xl"
>
<template v-if="titleHighlight">
<span class="text-primary-warm-white">{{ titleHighlight }}</span>
@@ -93,6 +100,13 @@ const {
<template v-else>{{ title }}</template>
</h1>
<p
v-if="subtitle"
class="mt-6 max-w-xl text-base text-primary-comfy-canvas/80"
>
{{ subtitle }}
</p>
<ul v-if="features.length" class="mt-8 space-y-3">
<li
v-for="feature in features"
@@ -127,27 +141,29 @@ const {
</div>
<div class="order-first w-full lg:order-last lg:flex-1">
<VideoPlayer
v-if="videoSrc"
:locale
:src="videoSrc"
:poster="videoPoster"
:tracks="videoTracks"
:autoplay="videoAutoplay"
:loop="videoLoop"
:minimal="videoMinimal"
:hide-controls="videoHideControls"
/>
<img
v-else-if="imageSrc"
:src="imageSrc"
:alt="imageAlt"
:width="imageWidth"
:height="imageHeight"
fetchpriority="high"
decoding="async"
class="aspect-4/3 w-full rounded-3xl object-cover"
/>
<slot name="media">
<VideoPlayer
v-if="videoSrc"
:locale
:src="videoSrc"
:poster="videoPoster"
:tracks="videoTracks"
:autoplay="videoAutoplay"
:loop="videoLoop"
:minimal="videoMinimal"
:hide-controls="videoHideControls"
/>
<img
v-else-if="imageSrc"
:src="imageSrc"
:alt="imageAlt"
:width="imageWidth"
:height="imageHeight"
fetchpriority="high"
decoding="async"
class="aspect-4/3 w-full rounded-3xl object-cover"
/>
</slot>
</div>
</section>
</template>

View File

@@ -0,0 +1,59 @@
<script setup lang="ts">
export interface Reason {
id: string
title: string
description: string
}
const { highlightClass = 'text-white' } = defineProps<{
heading: string
headingHighlight?: string
highlightClass?: string
subtitle?: string
reasons: readonly Reason[]
}>()
</script>
<template>
<section
class="max-w-9xl mx-auto flex flex-col gap-4 px-6 py-16 lg:flex-row lg:gap-16 lg:py-24"
>
<!-- Left heading -->
<div
class="sticky top-20 z-10 w-full shrink-0 self-start bg-primary-comfy-ink py-4 lg:top-28 lg:w-115 lg:py-0"
>
<h2
class="text-4xl/16 font-light whitespace-pre-line text-primary-comfy-canvas lg:text-5xl/16"
>
{{ heading
}}<span v-if="headingHighlight" :class="highlightClass">{{
headingHighlight
}}</span>
</h2>
<p v-if="subtitle" class="mt-6 text-sm text-primary-comfy-canvas/70">
{{ subtitle }}
</p>
</div>
<!-- Right reasons list -->
<div class="flex-1">
<div
v-for="reason in reasons"
:key="reason.id"
class="flex flex-col gap-4 border-b border-primary-comfy-canvas/20 py-10 first:pt-0 lg:gap-12 xl:flex-row"
>
<div class="shrink-0 xl:w-84">
<h3
class="text-2xl font-light whitespace-pre-line text-primary-comfy-canvas"
>
{{ reason.title }}
</h3>
<slot name="reason-extra" :reason="reason" />
</div>
<p class="flex-1 text-sm text-primary-comfy-canvas/70">
{{ reason.description }}
</p>
</div>
</div>
</section>
</template>

View File

@@ -7,12 +7,14 @@ const {
label,
headingTag = 'h2',
maxWidth = 'lg',
headingSize = 'section'
headingSize = 'section',
align = 'center'
} = defineProps<{
label?: string
headingTag?: 'h1' | 'h2' | 'h3'
maxWidth?: 'md' | 'lg' | 'xl'
headingSize?: 'section' | 'hero'
align?: 'center' | 'start'
}>()
const maxWidthClass = {
@@ -28,7 +30,14 @@ const headingSizeClass = {
</script>
<template>
<div :class="cn('mx-auto text-center', maxWidthClass[maxWidth])">
<div
:class="
cn(
maxWidthClass[maxWidth],
align === 'center' ? 'mx-auto text-center' : 'text-left'
)
"
>
<SectionLabel v-if="label">{{ label }}</SectionLabel>
<component
:is="headingTag"

View File

@@ -37,7 +37,8 @@ const topColumns: { title: string; links: FooterLink[] }[] = [
{ label: t('nav.comfyLocal', locale), href: routes.download },
{ label: t('nav.comfyCloud', locale), href: routes.cloud },
{ label: t('nav.comfyApi', locale), href: routes.api },
{ label: t('nav.comfyEnterprise', locale), href: routes.cloudEnterprise }
{ label: t('nav.comfyEnterprise', locale), href: routes.cloudEnterprise },
{ label: t('nav.mcpServer', locale), href: routes.mcp }
]
},
{

View File

@@ -0,0 +1,37 @@
<script setup lang="ts">
import { Check, Copy } from '@lucide/vue'
import { useClipboard } from '@vueuse/core'
// Interactive: the copy button is inert until its host island is hydrated.
// Render under a `client:*` directive (e.g. `client:visible`) when the page
// needs it to work.
const {
value,
copyLabel = 'Copy',
copiedLabel = 'Copied'
} = defineProps<{ value: string; copyLabel?: string; copiedLabel?: string }>()
const { copy, copied } = useClipboard({ copiedDuring: 2000 })
function handleCopy() {
void copy(value)
}
</script>
<template>
<div
class="bg-transparency-white-t4 border-primary-warm-gray flex items-center gap-2 rounded-xl border px-4 py-3"
>
<span class="flex-1 truncate font-mono text-xs text-primary-comfy-canvas">
{{ value }}
</span>
<button
type="button"
:aria-label="copied ? copiedLabel : copyLabel"
class="text-primary-warm-gray shrink-0 cursor-pointer transition-colors hover:text-primary-comfy-canvas"
@click="handleCopy"
>
<component :is="copied ? Check : Copy" class="size-4" />
</button>
</div>
</template>

View File

@@ -19,7 +19,8 @@ const baseRoutes = {
affiliates: '/affiliates',
affiliateTerms: '/affiliates/terms',
contact: '/contact',
models: '/p/supported-models'
models: '/p/supported-models',
mcp: '/mcp'
} as const
type Routes = typeof baseRoutes
@@ -65,6 +66,7 @@ export const externalLinks = {
github: 'https://github.com/Comfy-Org/ComfyUI',
githubInstall: 'https://github.com/Comfy-Org/ComfyUI#installing',
instagram: 'https://www.instagram.com/comfyui/',
mcpServer: 'https://cloud.comfy.org/mcp',
platform: 'https://platform.comfy.org',
platformUsage: 'https://platform.comfy.org/profile/usage',
reddit: 'https://www.reddit.com/r/comfyui/',

View File

@@ -38,7 +38,7 @@ export const learningTutorials: readonly LearningTutorial[] = [
label: 'English'
}
],
// href: '#',
href: 'https://comfy.org/workflows/8f2cf0df5da6-8f2cf0df5da6/',
tags: [partnerNodesTag, imageToVideoTag]
},
{

View File

@@ -69,10 +69,19 @@ export function getMainNavigation(locale: Locale): NavItem[] {
{
header: t('nav.colFeatures', locale),
items: [
{
label: t('nav.mcpServer', locale),
href: routes.mcp,
badge: 'new'
},
// TODO: no page yet — re-enable when landing pages ship
// { label: t('nav.mcpServer', locale), href: '#', badge: 'new' },
// { label: t('nav.appMode', locale), href: '#' },
// { label: t('nav.agentSkills', locale), href: '#' },
{
label: t('nav.launches', locale),
href: routes.launches,
badge: 'new'
},
{
label: t('nav.docs', locale),
href: externalLinks.docs,
@@ -180,11 +189,6 @@ export function getMainNavigation(locale: Locale): NavItem[] {
},
// TODO: no /brand page yet
// { label: t('nav.brand', locale), href: '#' },
{
label: t('nav.launches', locale),
href: routes.launches,
badge: 'new'
},
{
label: t('nav.blogs', locale),
href: externalLinks.blog,

View File

@@ -11,6 +11,16 @@ const translations = {
'zh-CN': '图像生成视频'
},
// UI (global, reusable across sections)
'ui.copy': {
en: 'Copy',
'zh-CN': '复制'
},
'ui.copied': {
en: 'Copied',
'zh-CN': '已复制'
},
// CTAs (global, reusable across sections)
'cta.tryWorkflow': {
en: 'Try Workflow',
@@ -1825,6 +1835,308 @@ const translations = {
'我们尽力为经历面试流程的候选人提供有意义的反馈。由于申请量较大,在简历筛选阶段可能无法提供详细反馈。'
},
// MCP Meta
'mcp.meta.title': {
en: 'Comfy MCP — Drive ComfyUI from any AI agent',
'zh-CN': 'Comfy MCP — 让任何 AI 智能体驱动 ComfyUI'
},
'mcp.meta.description': {
en: 'Comfy MCP exposes the full ComfyUI engine over the Model Context Protocol. Generate images, video, audio, and 3D from Claude Code, Claude Desktop, and any MCP-compatible client.',
'zh-CN':
'Comfy MCP 通过模型上下文协议暴露完整的 ComfyUI 引擎,可在 Claude Code、Claude Desktop 及任何兼容 MCP 的客户端中生成图像、视频、音频和 3D 内容。'
},
// MCP HeroSection
'mcp.hero.heading': {
en: 'Drive ComfyUI from\nany AI agent.',
'zh-CN': '让任何 AI 智能体\n驱动 ComfyUI。'
},
'mcp.hero.subtitle': {
en: 'Comfy MCP exposes the full ComfyUI engine over the Model Context Protocol — so your assistant can access the ecosystem, build workflows, and generate images, video, audio, or 3D.',
'zh-CN':
'Comfy MCP 通过模型上下文协议暴露完整的 ComfyUI 引擎——让你的助手能够接入生态系统、构建工作流,并生成图像、视频、音频或 3D 内容。'
},
'mcp.hero.demoPrompt': {
en: "match this frame's palette, make the hero key art",
'zh-CN': '匹配这一帧的配色,生成主视觉关键画面'
},
'mcp.hero.viewDocs': {
en: 'VIEW DOCS',
'zh-CN': '查看文档'
},
'mcp.hero.runWorkflow': {
en: 'RUN A WORKFLOW',
'zh-CN': '运行工作流'
},
'mcp.hero.demoGenerate': {
en: 'GENERATE',
'zh-CN': '生成'
},
'mcp.hero.demoActionGenerateImage': {
en: 'GENERATE-IMAGE',
'zh-CN': '生成图像'
},
'mcp.hero.demoActionGenerate3d': {
en: 'GENERATE-3D ASSET',
'zh-CN': '生成 3D 资产'
},
'mcp.hero.demoActionUpscale': {
en: 'UPSCALE-IMAGE',
'zh-CN': '放大图像'
},
// MCP SetupStepsSection
'mcp.setup.label': {
en: 'GET STARTED',
'zh-CN': '快速开始'
},
'mcp.setup.heading': {
en: 'Set up Comfy MCP in three steps',
'zh-CN': '三步完成 Comfy MCP 配置'
},
'mcp.setup.subtitle': {
en: 'Add Comfy Cloud as a built-in connector in Claude, and the full ComfyUI toolset is available right in your chat.',
'zh-CN':
'将 Comfy Cloud 添加为 Claude 的内置连接器ComfyUI 全套工具即可直接在对话中使用。'
},
'mcp.setup.step1.label': { en: 'STEP 1', 'zh-CN': '第 1 步' },
'mcp.setup.step1.title': {
en: 'Open Claude settings',
'zh-CN': '打开 Claude 设置'
},
'mcp.setup.step1.description': {
en: 'Launch the app or open claude.ai and go to Settings > Connections',
'zh-CN': '启动应用或打开 claude.ai前往"设置 > 连接"'
},
'mcp.setup.step1.cta': {
en: 'SETTINGS → CONNECTIONS',
'zh-CN': '设置 > 连接'
},
'mcp.setup.step2.label': { en: 'STEP 2', 'zh-CN': '第 2 步' },
'mcp.setup.step2.title': {
en: 'Add the Comfy Cloud custom connector',
'zh-CN': '添加 Comfy Cloud 自定义连接器'
},
'mcp.setup.step2.description': {
en: 'Name it Comfy Cloud and paste the URL',
'zh-CN': '将其命名为 Comfy Cloud 并粘贴 URL'
},
'mcp.setup.step3.label': { en: 'STEP 3', 'zh-CN': '第 3 步' },
'mcp.setup.step3.title': {
en: 'Connect and sign in',
'zh-CN': '连接并登录'
},
'mcp.setup.step3.description': {
en: "Click Add > Connect, sign in with your Comfy account. You're all set. Now just ask Claude to generate an image.",
'zh-CN':
'点击"添加 > 连接",使用 Comfy 账户登录。配置完成。现在直接让 Claude 生成图像即可。'
},
// MCP WhyBuildSection
'mcp.why.heading': {
en: 'Why build on\n',
'zh-CN': '为什么选择\n'
},
'mcp.why.headingHighlight': {
en: 'Comfy MCP?',
'zh-CN': 'Comfy MCP'
},
'mcp.why.subtitle': {
en: 'A trusted infrastructure that lets engineers and professionals ship faster.',
'zh-CN': '一套值得信赖的基础设施,让工程师和专业人士交付更快。'
},
'mcp.why.1.title': {
en: 'Open protocol,\nany client.',
'zh-CN': '开放协议,\n任意客户端。'
},
'mcp.why.1.description': {
en: 'MCP is an open standard, so any MCP-compatible client can connect. Today Comfy supports Claude Code and Claude Desktop, with more clients coming.',
'zh-CN':
'MCP 是开放标准,因此任何兼容 MCP 的客户端都能接入。目前 Comfy 支持 Claude Code 和 Claude Desktop更多客户端即将推出。'
},
'mcp.why.2.title': {
en: 'The full engine,\nnot a sandbox.',
'zh-CN': '完整引擎,\n非沙箱环境。'
},
'mcp.why.2.description': {
en: 'Same tool your team uses. Fully connected multi-step, multi-GPU workflows. Everything available now and in the future.',
'zh-CN':
'与你团队使用的相同工具。完整连接的多步骤、多 GPU 工作流。当前及未来的所有功能均可使用。'
},
'mcp.why.3.title': {
en: 'Outputs you keep.',
'zh-CN': '输出归你所有。'
},
'mcp.why.3.description': {
en: 'Downloads go to your Comfy library — store, reuse, remix, and share without leaving the ecosystem.',
'zh-CN':
'下载内容保存到你的 Comfy 库——在生态系统内存储、复用、二次创作和分享。'
},
'mcp.why.4.title': {
en: 'Powered by\nComfy Cloud.',
'zh-CN': '由 Comfy Cloud\n提供支持。'
},
'mcp.why.4.description': {
en: 'Run without a local GPU through the same infrastructure your team already trusts.',
'zh-CN': '无需本地 GPU通过你团队信赖的相同基础设施运行。'
},
// MCP ToolsSection
'mcp.tools.heading': {
en: 'Everything ComfyUI can do,\nnow available as tools.',
'zh-CN': 'ComfyUI 能做的一切,\n现在都可作为工具调用。'
},
'mcp.tools.1.title': {
en: 'Generate anything',
'zh-CN': '生成任意内容'
},
'mcp.tools.1.description': {
en: 'Generate images, video, audio, 3D, upscale, or remove backgrounds. Add or remove elements in images, create or modify any visual, audio, or 3D asset at any scale.',
'zh-CN':
'生成图像、视频、音频、3D 内容,放大分辨率或移除背景。添加或删除图像元素,以任意规模创建或修改任何视觉、音频或 3D 资产。'
},
'mcp.tools.1.alt': {
en: 'Comfy MCP generating images, video, audio, and 3D assets from a single prompt',
'zh-CN': 'Comfy MCP 通过单个提示生成图像、视频、音频和 3D 资产'
},
'mcp.tools.2.title': {
en: 'Search the ecosystem',
'zh-CN': '搜索生态系统'
},
'mcp.tools.2.description': {
en: 'Query thousands of models, browse rankings, and choose workflow templates straight from your response.',
'zh-CN': '查询数千个模型,浏览排名,直接在对话中选择工作流模板。'
},
'mcp.tools.2.alt': {
en: 'Comfy MCP searching the ecosystem of models, rankings, and workflow templates',
'zh-CN': 'Comfy MCP 搜索模型、排名和工作流模板的生态系统'
},
'mcp.tools.3.title': {
en: 'Run real workflows',
'zh-CN': '运行真实工作流'
},
'mcp.tools.3.description': {
en: 'Turn any ComfyUI workflow into a callable tool. The full power of the engine, driven by your agent.',
'zh-CN':
'将任何 ComfyUI 工作流转换为可调用的工具。由你的智能体驱动完整的引擎能力。'
},
'mcp.tools.3.alt': {
en: 'Comfy MCP running a ComfyUI workflow as a callable tool from a chat',
'zh-CN': 'Comfy MCP 在对话中将 ComfyUI 工作流作为可调用工具运行'
},
// MCP HowItWorksSection
'mcp.howItWorks.heading': {
en: 'How it works',
'zh-CN': '工作原理'
},
'mcp.howItWorks.step1.number': { en: '01', 'zh-CN': '01' },
'mcp.howItWorks.step1.title': {
en: 'CONNECT',
'zh-CN': '连接'
},
'mcp.howItWorks.step1.description': {
en: 'Add the Comfy Cloud MCP server to Claude Code or Claude Desktop and sign in once with OAuth. No API keys to manage.',
'zh-CN':
'将 Comfy Cloud MCP 服务器添加到 Claude Code 或 Claude Desktop通过 OAuth 一次性登录。无需管理 API 密钥。'
},
'mcp.howItWorks.step2.number': { en: '02', 'zh-CN': '02' },
'mcp.howItWorks.step2.title': {
en: 'DISCOVER',
'zh-CN': '发现'
},
'mcp.howItWorks.step2.description': {
en: "Your agent gets Comfy's tools: search, generate, submit, and retrieve — everything it needs to create.",
'zh-CN':
'你的智能体获得 Comfy 的工具:搜索、生成、提交和获取——一切所需,应有尽有。'
},
'mcp.howItWorks.step3.number': { en: '03', 'zh-CN': '03' },
'mcp.howItWorks.step3.title': {
en: 'CREATE',
'zh-CN': '创作'
},
'mcp.howItWorks.step3.description': {
en: 'Request what you want, the agent queues and runs the workflow, and returns the finished result.',
'zh-CN': '描述你的需求,智能体排队执行工作流,并返回最终结果。'
},
// MCP FAQSection
'mcp.faq.heading': {
en: 'Q&As',
'zh-CN': '常见问答'
},
'mcp.faq.1.q': {
en: 'Which clients are supported?',
'zh-CN': '支持哪些客户端?'
},
'mcp.faq.1.a': {
en: 'Claude Code and Claude Desktop today, both signing in with OAuth. Support for more clients is coming.',
'zh-CN':
'目前支持 Claude Code 和 Claude Desktop均通过 OAuth 登录。更多客户端的支持即将推出。'
},
'mcp.faq.2.q': {
en: 'Do I need an API key?',
'zh-CN': '我需要 API 密钥吗?'
},
'mcp.faq.2.a': {
en: 'Not for Claude Code or Claude Desktop. They use OAuth. An API key is only needed for headless or CI setups with no browser.',
'zh-CN':
'Claude Code 和 Claude Desktop 不需要,它们使用 OAuth。仅在没有浏览器的无头或 CI 环境中才需要 API 密钥。'
},
'mcp.faq.3.q': {
en: 'Do the slash commands work in Claude Desktop?',
'zh-CN': '斜杠命令在 Claude Desktop 中可以使用吗?'
},
'mcp.faq.3.a': {
en: 'No. They ship in the Claude Code plugin. Desktop connects to the same MCP server, so the tools work; just ask in plain language.',
'zh-CN':
'不可以。斜杠命令包含在 Claude Code 插件中。Claude Desktop 连接的是同一个 MCP 服务器,因此工具可以正常使用;直接用自然语言提问即可。'
},
'mcp.faq.4.q': {
en: "The sign-in didn't open a browser.",
'zh-CN': '登录时没有打开浏览器。'
},
'mcp.faq.4.a': {
en: 'In Claude Code, run /mcp, select comfy-cloud, and choose Authenticate. In Claude Desktop, reopen the connector from Customize → Connectors.',
'zh-CN':
'在 Claude Code 中,运行 /mcp选择 comfy-cloud然后选择 Authenticate授权。在 Claude Desktop 中,从“自定义 → 连接器”重新打开该连接器。'
},
'mcp.faq.5.q': {
en: 'How do I connect in Claude Code?',
'zh-CN': '如何在 Claude Code 中连接?'
},
'mcp.faq.5.a': {
en: 'Add the marketplace and install the comfy-cloud plugin, then run /mcp → comfy-cloud → Authenticate. It adds the connection and slash commands in one step.',
'zh-CN':
'添加插件市场并安装 comfy-cloud 插件,然后运行 /mcp → comfy-cloud → Authenticate授权。一步即可添加连接和斜杠命令。'
},
'mcp.faq.6.q': {
en: "What's the server URL for Claude Desktop?",
'zh-CN': 'Claude Desktop 的服务器 URL 是什么?'
},
'mcp.faq.6.a': {
en: 'Add a custom connector in Customize → Connectors pointing to https://cloud.comfy.org/mcp, then sign in when prompted.',
'zh-CN':
'在“自定义 → 连接器”中添加一个指向 https://cloud.comfy.org/mcp 的自定义连接器,然后在提示时登录。'
},
'mcp.faq.7.q': {
en: 'What can my agent do once connected?',
'zh-CN': '连接后我的智能体能做什么?'
},
'mcp.faq.7.a': {
en: 'Generate images, video, audio, and 3D; search models, nodes, and templates; and run ComfyUI workflows, all from a chat.',
'zh-CN':
'生成图像、视频、音频和 3D搜索模型、节点和模板并运行 ComfyUI 工作流——全部在对话中完成。'
},
'mcp.faq.8.q': {
en: 'Is it generally available?',
'zh-CN': '现已正式发布了吗?'
},
'mcp.faq.8.a': {
en: 'Comfy Cloud MCP is in open beta and available to everyone.',
'zh-CN': 'Comfy Cloud MCP 目前处于公开测试阶段,所有人均可使用。'
},
// SiteNav
'nav.products': { en: 'Products', 'zh-CN': '产品' },
'nav.pricing': { en: 'Pricing', 'zh-CN': '价格' },
@@ -1867,6 +2179,7 @@ const translations = {
'nav.back': { en: 'BACK', 'zh-CN': '返回' },
'nav.badgeNew': { en: 'NEW', 'zh-CN': '新' },
// Column headers used in HeaderMainDesktop dropdowns
'nav.mcpServer': { en: 'Comfy MCP', 'zh-CN': 'Comfy MCP' },
'nav.colFeatures': { en: 'Features', 'zh-CN': '功能' },
'nav.colPrograms': { en: 'Programs', 'zh-CN': '项目' },
'nav.colConnect': { en: 'Connect', 'zh-CN': '联系' },

View File

@@ -0,0 +1,24 @@
---
import BaseLayout from '../layouts/BaseLayout.astro'
import ProductCardsSection from '../components/common/ProductCardsSection.vue'
import HeroSection from '../templates/mcp/HeroSection.vue'
import SetupSection from '../templates/mcp/SetupSection.vue'
import WhySection from '../templates/mcp/WhySection.vue'
import ToolsSection from '../templates/mcp/ToolsSection.vue'
import HowItWorksSection from '../templates/mcp/HowItWorksSection.vue'
import FAQSection from '../templates/mcp/FAQSection.vue'
import { t } from '../i18n/translations'
---
<BaseLayout
title={t('mcp.meta.title', 'en')}
description={t('mcp.meta.description', 'en')}
>
<HeroSection locale="en" client:load />
<SetupSection locale="en" client:visible />
<WhySection locale="en" />
<ToolsSection locale="en" />
<HowItWorksSection locale="en" />
<ProductCardsSection locale="en" label-key="products.labelProducts" />
<FAQSection client:visible locale="en" />
</BaseLayout>

View File

@@ -0,0 +1,24 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import ProductCardsSection from '../../components/common/ProductCardsSection.vue'
import HeroSection from '../../templates/mcp/HeroSection.vue'
import SetupSection from '../../templates/mcp/SetupSection.vue'
import WhySection from '../../templates/mcp/WhySection.vue'
import ToolsSection from '../../templates/mcp/ToolsSection.vue'
import HowItWorksSection from '../../templates/mcp/HowItWorksSection.vue'
import FAQSection from '../../templates/mcp/FAQSection.vue'
import { t } from '../../i18n/translations'
---
<BaseLayout
title={t('mcp.meta.title', 'zh-CN')}
description={t('mcp.meta.description', 'zh-CN')}
>
<HeroSection locale="zh-CN" client:load />
<SetupSection locale="zh-CN" client:visible />
<WhySection locale="zh-CN" />
<ToolsSection locale="zh-CN" />
<HowItWorksSection locale="zh-CN" />
<ProductCardsSection locale="zh-CN" label-key="products.labelProducts" />
<FAQSection client:visible locale="zh-CN" />
</BaseLayout>

View File

@@ -162,6 +162,45 @@
animation: ripple-effect 4s linear infinite;
}
@keyframes cursor-blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0;
}
}
@utility animate-cursor-blink {
animation: cursor-blink 1s step-end infinite;
}
.card-slide-enter-active {
transition:
transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94),
opacity 0.4s ease;
}
.card-slide-enter-from {
transform: translateX(56px);
opacity: 0;
}
/* Existing cards slide down smoothly when a new card is prepended. */
.card-slide-move {
transition: transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
.card-slide-leave-active {
transition: opacity 0.2s ease;
}
.card-slide-leave-to {
opacity: 0;
}
@utility animate-delay-* {
animation-delay: --value([*]);
}

View File

@@ -0,0 +1,195 @@
<script setup lang="ts">
import { Check } from '@lucide/vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const PROMPT = t('mcp.hero.demoPrompt', locale)
const generateLabel = t('mcp.hero.demoGenerate', locale)
const cards = [
{
actionKey: 'mcp.hero.demoActionGenerateImage',
file: 'moodboard_v1.png · 6-up',
tag: 'Gmail',
thumb: '/images/mcp/mcp-thumb-moodboard.webp'
},
{
actionKey: 'mcp.hero.demoActionGenerateImage',
file: 'concepts_0103.png',
tag: 'Notion',
thumb: '/images/mcp/mcp-thumb-concepts.webp'
},
{
actionKey: 'mcp.hero.demoActionGenerateImage',
file: 'hero_keyart.png',
tag: 'Figma',
thumb: '/images/mcp/mcp-thumb-keyart.webp'
},
{
actionKey: 'mcp.hero.demoActionGenerate3d',
file: 'asphalt_pbr/ · 5 maps',
tag: 'Blender',
thumb: '/images/mcp/mcp-thumb-asphalt.webp'
},
{
actionKey: 'mcp.hero.demoActionUpscale',
file: 'kaiju_neon_4k.png · 4096',
tag: null,
thumb: '/images/mcp/mcp-thumb-kaiju.webp'
}
] as const
const visibleCount = ref(0)
const displayedPrompt = ref('')
const promptDone = ref(false)
const displayedCards = computed(() =>
cards
.slice(0, visibleCount.value)
.map((card) => ({ ...card, action: t(card.actionKey, locale) }))
// Newest card first — it slides in right below the prompt box and pushes
// the rest down.
.reverse()
)
let timer: ReturnType<typeof setTimeout> | null = null
let active = false
function schedule(fn: () => void, ms: number) {
timer = setTimeout(() => {
if (active) fn()
}, ms)
}
function typePrompt(onDone: () => void) {
displayedPrompt.value = ''
promptDone.value = false
let i = 0
function step() {
i++
displayedPrompt.value = PROMPT.slice(0, i)
if (i < PROMPT.length) {
schedule(step, 35)
} else {
promptDone.value = true
schedule(onDone, 350)
}
}
schedule(step, 50)
}
function revealNextCard() {
if (visibleCount.value >= cards.length) {
// All done — pause then reset
schedule(() => {
visibleCount.value = 0
schedule(revealNextCard, 500)
}, 2500)
return
}
// Type the prompt, then slide in the next card
typePrompt(() => {
visibleCount.value++
schedule(revealNextCard, 400)
})
}
onMounted(() => {
active = true
schedule(revealNextCard, 600)
})
onUnmounted(() => {
active = false
if (timer) clearTimeout(timer)
})
</script>
<template>
<div class="flex flex-col gap-6 max-lg:h-[50vh]">
<!-- Prompt panel -->
<div
class="rounded-5xl flex flex-col justify-between gap-8 overflow-hidden bg-white/4 p-8"
>
<p
class="font-formula text-[17px] leading-relaxed font-light text-primary-comfy-canvas"
>
{{ displayedPrompt
}}<span
class="bg-primary-comfy-yellow ml-0.5 inline-block h-5.5 w-2 translate-y-0.5"
:class="promptDone ? 'animate-cursor-blink' : ''"
/>
</p>
<div class="flex items-center gap-3">
<div class="h-px flex-1 bg-white/10" />
<div
class="bg-primary-comfy-yellow font-formula rounded-2xl px-4 py-3 text-sm font-extrabold tracking-[0.7px] text-primary-comfy-ink uppercase"
>
{{ generateLabel }}
</div>
</div>
</div>
<!-- Cards accumulate each slides in from the right after its prompt cycle -->
<div class="relative overflow-hidden max-lg:min-h-0 max-lg:flex-1">
<TransitionGroup
name="card-slide"
tag="div"
class="flex flex-col gap-2.5 max-lg:absolute max-lg:inset-x-0 max-lg:top-0"
>
<div
v-for="(card, i) in displayedCards"
:key="card.file"
class="flex items-center gap-3.5 overflow-hidden rounded-3xl px-4 py-3.5"
:class="i === 0 ? 'bg-white/8' : 'bg-white/4'"
>
<img
:src="card.thumb"
:alt="card.action"
class="size-13.5 shrink-0 rounded-[14px] object-cover"
/>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<p
class="font-formula text-primary-comfy-yellow text-xs font-extrabold tracking-[0.7px] uppercase"
>
{{ card.action }}
</p>
<p
class="font-formula truncate text-sm font-light text-primary-comfy-canvas"
>
{{ card.file }}
</p>
</div>
<span
v-if="card.tag"
class="font-formula relative isolate inline-flex h-8 shrink-0 items-center justify-center overflow-visible bg-transparent px-5 text-sm font-extrabold tracking-[0.7px] text-white/60 uppercase before:absolute before:inset-0 before:-z-10 before:-skew-x-12 before:rounded-sm before:bg-white/20"
>
<span class="ppformula-text-center">
{{ card.tag }}
</span>
</span>
<Check
class="size-4 shrink-0 text-primary-comfy-canvas/60"
:stroke-width="1.5"
/>
</div>
</TransitionGroup>
<!-- Bottom fade so accumulating cards dissolve into the page background -->
<div
class="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-32 bg-linear-to-t from-primary-comfy-ink to-transparent lg:hidden"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import FAQSplit01 from '../../components/blocks/FAQSplit01.vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const faqNumbers = [1, 2, 3, 4, 5, 6, 7, 8] as const
const faqs = faqNumbers.map((n) => ({
id: String(n),
question: t(`mcp.faq.${n}.q`, locale),
answer: t(`mcp.faq.${n}.a`, locale)
}))
</script>
<template>
<FAQSplit01 :heading="t('mcp.faq.heading', locale)" :faqs="faqs" />
</template>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import HeroSplit01 from '../../components/blocks/HeroSplit01.vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import ComfyMcpDemo from './ComfyMcpDemo.vue'
import { mcpCtas } from './ctas'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const ctas = mcpCtas(locale)
</script>
<template>
<HeroSplit01
:locale="locale"
class="min-h-screen"
badge-text="MCP"
:title="t('mcp.hero.heading', locale)"
:subtitle="t('mcp.hero.subtitle', locale)"
:primary-cta="ctas.runWorkflow"
:secondary-cta="ctas.docs"
>
<template #media>
<ComfyMcpDemo :locale="locale" />
</template>
</HeroSplit01>
</template>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import FeatureGrid02 from '../../components/blocks/FeatureGrid02.vue'
import type { FeatureStep } from '../../components/blocks/FeatureGrid02.vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import { mcpCtas } from './ctas'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const ctas = mcpCtas(locale)
const stepNumbers = [1, 2, 3] as const
const steps: FeatureStep[] = stepNumbers.map((n) => ({
id: String(n),
number: t(`mcp.howItWorks.step${n}.number`, locale),
title: t(`mcp.howItWorks.step${n}.title`, locale),
description: t(`mcp.howItWorks.step${n}.description`, locale)
}))
</script>
<template>
<FeatureGrid02
:heading="t('mcp.howItWorks.heading', locale)"
:steps="steps"
:primary-cta="ctas.runWorkflow"
:secondary-cta="ctas.docs"
/>
</template>

View File

@@ -0,0 +1,55 @@
<script setup lang="ts">
import { ArrowUpRight } from '@lucide/vue'
import FeatureGrid01 from '../../components/blocks/FeatureGrid01.vue'
import type { FeatureCard } from '../../components/blocks/FeatureGrid01.vue'
import { externalLinks } from '../../config/routes'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const cards: FeatureCard[] = [
{
id: 'step1',
label: t('mcp.setup.step1.label', locale),
title: t('mcp.setup.step1.title', locale),
description: t('mcp.setup.step1.description', locale),
action: {
type: 'link',
label: t('mcp.setup.step1.cta', locale),
href: `${externalLinks.cloud}/settings/connections`,
target: '_blank',
icon: ArrowUpRight
}
},
{
id: 'step2',
label: t('mcp.setup.step2.label', locale),
title: t('mcp.setup.step2.title', locale),
description: t('mcp.setup.step2.description', locale),
action: {
type: 'code',
value: externalLinks.mcpServer
}
},
{
id: 'step3',
label: t('mcp.setup.step3.label', locale),
title: t('mcp.setup.step3.title', locale),
description: t('mcp.setup.step3.description', locale)
}
]
</script>
<template>
<FeatureGrid01
:eyebrow="t('mcp.setup.label', locale)"
:heading="t('mcp.setup.heading', locale)"
:subtitle="t('mcp.setup.subtitle', locale)"
:columns="3"
:cards="cards"
:copy-label="t('ui.copy', locale)"
:copied-label="t('ui.copied', locale)"
/>
</template>

View File

@@ -0,0 +1,66 @@
<script setup lang="ts">
import FeatureRows01 from '../../components/blocks/FeatureRows01.vue'
import type { FeatureRow } from '../../components/blocks/FeatureRows01.vue'
import type { Locale, TranslationKey } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
type ToolMedia =
| { type: 'image'; src: string }
| {
type: 'video'
src: string
autoplay?: boolean
loop?: boolean
hideControls?: boolean
}
const tools: { n: 1 | 2 | 3; media: ToolMedia; altKey?: TranslationKey }[] = [
{
n: 1,
media: {
type: 'image',
src: 'https://media.comfy.org/website/mcp/generate-everything.gif'
},
altKey: 'mcp.tools.1.alt'
},
{
n: 2,
media: {
type: 'image',
src: 'https://media.comfy.org/website/mcp/search-ecosystem.png'
},
altKey: 'mcp.tools.2.alt'
},
{
n: 3,
media: {
type: 'video',
src: 'https://media.comfy.org/website/mcp/run-real-workflows.mp4',
autoplay: true,
loop: true,
hideControls: true
},
altKey: 'mcp.tools.3.alt'
}
]
const rows: FeatureRow[] = tools.map(({ n, media, altKey }) => {
const alt = altKey ? t(altKey, locale) : undefined
return {
id: String(n),
title: t(`mcp.tools.${n}.title`, locale),
description: t(`mcp.tools.${n}.description`, locale),
media: { ...media, alt }
}
})
</script>
<template>
<FeatureRows01
:locale="locale"
:heading="t('mcp.tools.heading', locale)"
:rows="rows"
/>
</template>

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
import ReasonsSplit01 from '../../components/blocks/ReasonsSplit01.vue'
import type { Reason } from '../../components/blocks/ReasonsSplit01.vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const reasonNumbers = [1, 2, 3, 4] as const
const reasons: Reason[] = reasonNumbers.map((n) => ({
id: String(n),
title: t(`mcp.why.${n}.title`, locale),
description: t(`mcp.why.${n}.description`, locale)
}))
</script>
<template>
<ReasonsSplit01
:heading="t('mcp.why.heading', locale)"
:heading-highlight="t('mcp.why.headingHighlight', locale)"
highlight-class="text-primary-comfy-yellow"
:subtitle="t('mcp.why.subtitle', locale)"
:reasons="reasons"
/>
</template>

View File

@@ -0,0 +1,27 @@
import { externalLinks, getRoutes } from '../../config/routes'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
export interface McpCta {
label: string
href: string
target?: '_blank'
}
/**
* The two calls-to-action shared by the MCP hero and "how it works" sections:
* view the docs, or run a workflow in the cloud.
*/
export function mcpCtas(locale: Locale): { docs: McpCta; runWorkflow: McpCta } {
return {
docs: {
label: t('mcp.hero.viewDocs', locale),
href: externalLinks.docsMcp,
target: '_blank'
},
runWorkflow: {
label: t('mcp.hero.runWorkflow', locale),
href: getRoutes(locale).cloud
}
}
}

View File

@@ -21,12 +21,18 @@ export class CancelSubscriptionDialog extends BaseDialog {
})
}
async open(cancelAt?: string) {
/** Launches the cancellation flow without waiting for the legacy dialog
* (e.g. when the Churnkey embed is expected to handle it instead). */
async launch(cancelAt?: string) {
await this.page.evaluate((date) => {
void (
window.app!.extensionManager as WorkspaceStore
).dialog.showCancelSubscriptionDialog(date)
).dialog.launchCancellationFlow(date)
}, cancelAt)
}
async open(cancelAt?: string) {
await this.launch(cancelAt)
await this.waitForVisible()
}
}

View File

@@ -1,5 +1,7 @@
import type { Page, Route } from '@playwright/test'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
export class FeatureFlagHelper {
private featuresRouteHandler: ((route: Route) => void) | null = null
@@ -51,6 +53,68 @@ export class FeatureFlagHelper {
})
}
/**
* Set server feature flags at runtime by mutating the reactive
* `api.serverFeatureFlags` ref. Use this when `setFlags()` (localStorage)
* won't work — namely in production builds, where the dev-override
* reader is gated on `import.meta.env.DEV` and dead-code-eliminated.
*
* Note: server features are the LOWEST-priority flag source. If the
* backend's remote config (`/api/features`) defines the same key, the
* remote-config value wins — use `overrideFlags()` to control flags
* deterministically regardless of what the backend serves.
*/
async setServerFeatures(features: Record<string, unknown>): Promise<void> {
await this.page.evaluate((flagMap: Record<string, unknown>) => {
const api = window.app!.api
api.serverFeatureFlags.value = {
...api.serverFeatureFlags.value,
...flagMap
}
}, features)
}
/**
* Deterministically override flags resolved via `useFeatureFlags()` in
* production cloud builds, where dev overrides (the highest-priority
* source) are compiled out. Covers both remaining sources:
*
* 1. Remote config — mutates the live config object in place
* (`window.__CONFIG__` is the same object held by the `remoteConfig`
* ref, whose consumers read keys lazily on access) and intercepts
* `/api/features` so any later refresh (auth change, 10-minute poll)
* re-applies the overrides instead of clobbering them.
* 2. Server features — mutates `api.serverFeatureFlags` as a fallback
* for environments where remote config never loaded.
*/
async overrideFlags(features: Record<string, unknown>): Promise<void> {
await this.page.route('**/api/features', async (route) => {
const response = await route.fetch()
let config: RemoteConfig = {}
try {
config = (await response.json()) as RemoteConfig
} catch {
// Non-JSON response (e.g. backend without the endpoint); serve
// just the overrides.
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ ...config, ...features })
})
})
await this.page.evaluate((flagMap: Record<string, unknown>) => {
const config = (window as { __CONFIG__?: Record<string, unknown> })
.__CONFIG__
if (config) Object.assign(config, flagMap)
const api = window.app!.api
api.serverFeatureFlags.value = {
...api.serverFeatureFlags.value,
...flagMap
}
}, features)
}
/**
* Mock server feature flags via route interception on /api/features.
*/

View File

@@ -0,0 +1,144 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { CancelSubscriptionDialog } from '@e2e/fixtures/components/CancelSubscriptionDialog'
import type { ChurnkeyInitConfig } from '@/platform/cloud/churnkey/types'
import type { ChurnkeyAuthResponse } from '@/platform/workspace/api/workspaceApi'
const CANCEL_AT = '2026-12-31T12:00:00Z'
const STUB_APP_ID = 'e2e-stub'
const VALID_AUTH_RESPONSE = {
customer_id: 'cus_e2e_test',
auth_hash: 'fake-hmac',
mode: 'test'
} satisfies ChurnkeyAuthResponse
// The production router's catch-all body for undeployed routes (verified
// against cloud.comfy.org) — what the frontend sees until the backend
// ships the endpoint.
const NOT_DEPLOYED_RESPONSE = {
error: { message: 'Not Found', type: 'not_found' }
}
interface ChurnkeyInitCall {
action: string
config: ChurnkeyInitConfig
}
interface ChurnkeyStubWindow extends Window {
__churnkeyCalls?: ChurnkeyInitCall[]
}
async function stubChurnkey(page: Page): Promise<void> {
await page.evaluate(() => {
const w = window as ChurnkeyStubWindow
w.__churnkeyCalls = []
// Defining `init` up front also makes the client skip injecting the
// real embed script.
w.churnkey = {
created: true,
init: (action, config) => {
w.__churnkeyCalls!.push({ action, config })
},
clearState: () => {}
}
})
}
const AUTH_ROUTE_GLOB = '**/api/billing/churnkey/auth'
async function mockAuthEndpoint(
page: Page,
fulfill:
| { status: 200; body: ChurnkeyAuthResponse }
| { status: 404; body: typeof NOT_DEPLOYED_RESPONSE }
): Promise<void> {
await page.route(AUTH_ROUTE_GLOB, (route) =>
route.fulfill({
status: fulfill.status,
contentType: 'application/json',
body: JSON.stringify(fulfill.body)
})
)
}
async function getChurnkeyInitCalls(page: Page): Promise<ChurnkeyInitCall[]> {
return page.evaluate(
() => (window as ChurnkeyStubWindow).__churnkeyCalls ?? []
)
}
test.describe('Cancellation flow routing', { tag: '@cloud' }, () => {
let dialog: CancelSubscriptionDialog
test.use({ timezoneId: 'UTC' })
test.beforeEach(async ({ comfyPage }) => {
dialog = new CancelSubscriptionDialog(comfyPage.page)
})
test.describe('app id not set', () => {
test('routes to the legacy cancel dialog', async ({ comfyPage }) => {
await comfyPage.featureFlags.overrideFlags({
churnkey_app_id: ''
})
await dialog.open(CANCEL_AT)
await expect(dialog.heading).toBeVisible()
await expect(dialog.root).toContainText('December 31, 2026')
})
})
test.describe('app id set', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.featureFlags.overrideFlags({
churnkey_app_id: STUB_APP_ID
})
await stubChurnkey(comfyPage.page)
})
test('routes to the legacy dialog when auth endpoint 404s', async ({
comfyPage
}) => {
await mockAuthEndpoint(comfyPage.page, {
status: 404,
body: NOT_DEPLOYED_RESPONSE
})
await dialog.open(CANCEL_AT)
await expect(dialog.heading).toBeVisible()
expect(await getChurnkeyInitCalls(comfyPage.page)).toEqual([])
})
test('launches the Churnkey embed when auth returns valid credentials', async ({
comfyPage
}) => {
await mockAuthEndpoint(comfyPage.page, {
status: 200,
body: VALID_AUTH_RESPONSE
})
await dialog.launch(CANCEL_AT)
await expect
.poll(() => getChurnkeyInitCalls(comfyPage.page).then((c) => c.length))
.toBeGreaterThan(0)
const [firstCall] = await getChurnkeyInitCalls(comfyPage.page)
expect(firstCall.action).toBe('show')
expect(firstCall.config).toMatchObject({
authHash: 'fake-hmac',
customerId: 'cus_e2e_test',
mode: 'test',
provider: 'stripe'
})
await expect(dialog.root).toBeHidden()
})
})
})

View File

@@ -28,6 +28,7 @@ export enum ServerFeatureFlag {
WORKFLOW_SHARING_ENABLED = 'workflow_sharing_enabled',
COMFYHUB_UPLOAD_ENABLED = 'comfyhub_upload_enabled',
COMFYHUB_PROFILE_GATE_ENABLED = 'comfyhub_profile_gate_enabled',
CHURNKEY_APP_ID = 'churnkey_app_id',
SHOW_SIGNIN_BUTTON = 'show_signin_button',
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth',
SIGNUP_TURNSTILE = 'signup_turnstile'
@@ -162,6 +163,14 @@ export function useFeatureFlags() {
false
)
},
get churnkeyAppId() {
if (!isCloud) return ''
return resolveFlag(
ServerFeatureFlag.CHURNKEY_APP_ID,
remoteConfig.value.churnkey_app_id,
''
)
},
get showSignInButton(): boolean | undefined {
return api.getServerFeature<boolean | undefined>(
ServerFeatureFlag.SHOW_SIGNIN_BUTTON,

View File

@@ -2543,7 +2543,7 @@
"title": "Your subscription has been canceled",
"description": "You won't be charged again. Your features remain active until {date}."
},
"cancelSuccess": "Subscription cancelled successfully",
"cancelSuccess": "Subscription canceled successfully",
"cancelDialog": {
"title": "Cancel subscription",
"description": "Your access continues until {date}. You won't be charged again, and your workspace and credits will be preserved. You can resubscribe anytime.",
@@ -2575,6 +2575,7 @@
"monthlyBonusDescription": "Monthly credit bonus",
"prepaidDescription": "Pre-paid credits",
"prepaidCreditsInfo": "Pre-paid credits expire after 1 year from purchase date.",
"creditsIncluded": "Included",
"creditsRemainingThisMonth": "Included (Refills {date})",
"creditsRemainingThisYear": "Included (Refills {date})",
"creditsYouveAdded": "Additional",

View File

@@ -0,0 +1,238 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ChurnkeyAuthResponse } from '@/platform/workspace/api/workspaceApi'
import { ChurnkeyAuthUnavailableError, ChurnkeyEmbedLoadError } from './errors'
import type { ChurnkeyWindow } from './types'
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
workspaceApi: {
getChurnkeyAuth: vi.fn()
}
}))
const featureFlags = vi.hoisted(() => ({ churnkeyAppId: '' }))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({ flags: featureFlags })
}))
const { workspaceApi } = await import('@/platform/workspace/api/workspaceApi')
const { isChurnkeyConfigured, prepareChurnkey } =
await import('./churnkeyClient')
const getChurnkeyAuth = vi.mocked(workspaceApi.getChurnkeyAuth)
type ChurnkeyInit = NonNullable<ChurnkeyWindow['init']>
const AUTH_RESPONSE = {
customer_id: 'cus_123',
auth_hash: 'hash_abc',
mode: 'test'
} as const
describe('churnkeyClient', () => {
beforeEach(() => {
featureFlags.churnkeyAppId = 'app-test-123'
getChurnkeyAuth.mockReset()
})
afterEach(() => {
delete window.churnkey
vi.restoreAllMocks()
})
it('reports isConfigured=false when the churnkey_app_id flag is unset', () => {
featureFlags.churnkeyAppId = ''
expect(isChurnkeyConfigured()).toBe(false)
})
it('reports isConfigured=true when the churnkey_app_id flag is set', () => {
featureFlags.churnkeyAppId = 'app-from-flag'
expect(isChurnkeyConfigured()).toBe(true)
})
it('rejects when the churnkey_app_id flag is unset', async () => {
featureFlags.churnkeyAppId = ''
await expect(prepareChurnkey()).rejects.toThrow(
'Churnkey is not configured'
)
})
it('rejects with ChurnkeyAuthUnavailableError when getChurnkeyAuth returns null', async () => {
window.churnkey = { init: vi.fn<ChurnkeyInit>() }
getChurnkeyAuth.mockResolvedValue(null)
await expect(prepareChurnkey()).rejects.toBeInstanceOf(
ChurnkeyAuthUnavailableError
)
})
it('uses the dev auth override instead of the backend endpoint when set', async () => {
const init = vi.fn<ChurnkeyInit>()
window.churnkey = { init }
const windowWithAuth = window as {
__CHURNKEY_AUTH_OVERRIDE__?: ChurnkeyAuthResponse
}
windowWithAuth.__CHURNKEY_AUTH_OVERRIDE__ = {
customer_id: 'cus_dev',
auth_hash: 'dev-hash',
mode: 'sandbox'
}
try {
const session = await prepareChurnkey()
void session.show({})
expect(getChurnkeyAuth).not.toHaveBeenCalled()
expect(init.mock.calls[0][1]).toMatchObject({
customerId: 'cus_dev',
authHash: 'dev-hash',
mode: 'sandbox'
})
} finally {
delete windowWithAuth.__CHURNKEY_AUTH_OVERRIDE__
}
})
it('forwards customer credentials and provider config to churnkey.init', async () => {
const init = vi.fn<ChurnkeyInit>()
window.churnkey = { init }
getChurnkeyAuth.mockResolvedValue(AUTH_RESPONSE)
const onCancel = vi.fn()
const session = await prepareChurnkey()
const shown = session.show({
onCancel,
customerAttributes: { tier: 'PRO', cycle: 'MONTHLY' }
})
expect(init).toHaveBeenCalledTimes(1)
const [action, config] = init.mock.calls[0]
expect(action).toBe('show')
expect(config).toMatchObject({
appId: 'app-test-123',
authHash: 'hash_abc',
customerId: 'cus_123',
provider: 'stripe',
mode: 'test',
customerAttributes: { tier: 'PRO', cycle: 'MONTHLY' }
})
// No handleCancel - Churnkey handles the Stripe cancellation itself.
expect(config.handleCancel).toBeUndefined()
config.onCancel?.('cus_123', 'too_expensive')
expect(onCancel).toHaveBeenCalledWith('too_expensive')
config.onClose?.({ status: 'closed' })
await expect(shown).resolves.toEqual({ status: 'closed' })
})
it('adapts handleCancel to drop the customer argument', async () => {
const init = vi.fn<ChurnkeyInit>()
window.churnkey = { init }
getChurnkeyAuth.mockResolvedValue(AUTH_RESPONSE)
const handleCancel = vi.fn(async () => ({ message: 'ok' }))
const session = await prepareChurnkey()
void session.show({ handleCancel })
const [, config] = init.mock.calls[0]
await config.handleCancel?.('cus_123', 'too_expensive', 'feedback')
expect(handleCancel).toHaveBeenCalledWith('too_expensive', 'feedback')
})
it('clears Churnkey session state when the modal closes', async () => {
const init = vi.fn<ChurnkeyInit>()
const clearState = vi.fn()
window.churnkey = { init, clearState }
getChurnkeyAuth.mockResolvedValue(AUTH_RESPONSE)
const session = await prepareChurnkey()
const shown = session.show({})
init.mock.calls[0][1].onClose?.({ status: 'closed' })
await shown
expect(clearState).toHaveBeenCalledTimes(1)
})
it('rejects show() when churnkey.init throws', async () => {
window.churnkey = {
init: vi.fn<ChurnkeyInit>(() => {
throw new Error('init exploded')
})
}
getChurnkeyAuth.mockResolvedValue(AUTH_RESPONSE)
const session = await prepareChurnkey()
await expect(session.show({})).rejects.toThrow('init exploded')
})
it('passes the churnkey_app_id flag value as the init config appId', async () => {
const init = vi.fn<ChurnkeyInit>()
window.churnkey = { init }
featureFlags.churnkeyAppId = 'app-from-flag'
getChurnkeyAuth.mockResolvedValue(AUTH_RESPONSE)
const session = await prepareChurnkey()
void session.show({})
expect(init.mock.calls[0][1]).toMatchObject({ appId: 'app-from-flag' })
})
describe('embed script loading', () => {
function interceptInjectedScripts(): HTMLScriptElement[] {
const scripts: HTMLScriptElement[] = []
vi.spyOn(document.head, 'append').mockImplementation((...nodes) => {
scripts.push(...(nodes as HTMLScriptElement[]))
})
return scripts
}
it('rejects with ChurnkeyEmbedLoadError when the script fails to load', async () => {
const scripts = interceptInjectedScripts()
const prepare = prepareChurnkey()
expect(scripts).toHaveLength(1)
scripts[0].onerror?.(new Event('error'))
await expect(prepare).rejects.toBeInstanceOf(ChurnkeyEmbedLoadError)
expect(getChurnkeyAuth).not.toHaveBeenCalled()
})
it('retries the script load on the next launch after a failure', async () => {
const scripts = interceptInjectedScripts()
const first = prepareChurnkey()
scripts[0].onerror?.(new Event('error'))
await expect(first).rejects.toBeInstanceOf(ChurnkeyEmbedLoadError)
const second = prepareChurnkey()
expect(scripts).toHaveLength(2)
scripts[1].onerror?.(new Event('error'))
await expect(second).rejects.toBeInstanceOf(ChurnkeyEmbedLoadError)
})
it('rejects with ChurnkeyEmbedLoadError when the script loads without defining init', async () => {
const scripts = interceptInjectedScripts()
const prepare = prepareChurnkey()
scripts[0].onload?.call(scripts[0], new Event('load'))
await expect(prepare).rejects.toBeInstanceOf(ChurnkeyEmbedLoadError)
})
it('proceeds to auth once the loaded script provides init', async () => {
const scripts = interceptInjectedScripts()
getChurnkeyAuth.mockResolvedValue(AUTH_RESPONSE)
const prepare = prepareChurnkey()
expect(scripts[0].src).toContain('appId=app-test-123')
window.churnkey!.init = vi.fn<ChurnkeyInit>()
scripts[0].onload?.call(scripts[0], new Event('load'))
const session = await prepare
expect(getChurnkeyAuth).toHaveBeenCalledTimes(1)
expect(session.show).toBeTypeOf('function')
})
})
})

View File

@@ -0,0 +1,140 @@
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import type { ChurnkeyAuthResponse } from '@/platform/workspace/api/workspaceApi'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import './embed-theme.css'
import { ChurnkeyAuthUnavailableError, ChurnkeyEmbedLoadError } from './errors'
import type {
ChurnkeyHandlerResult,
ChurnkeyInitConfig,
ChurnkeySessionResults
} from './types'
const EMBED_SCRIPT_URL = 'https://assets.churnkey.co/js/app.js'
function readAppId(): string {
return useFeatureFlags().flags.churnkeyAppId
}
function readAuthOverride(): ChurnkeyAuthResponse | null {
// Dev-only manual-testing hook: set `window.__CHURNKEY_AUTH_OVERRIDE__` to
// exercise the embed before the backend `/billing/churnkey/auth` endpoint
// is deployed. It forges credentials, so it is gated to dev and stripped
// from production builds via import.meta.env.DEV tree-shaking.
if (!import.meta.env.DEV) return null
return (
(window as { __CHURNKEY_AUTH_OVERRIDE__?: ChurnkeyAuthResponse })
.__CHURNKEY_AUTH_OVERRIDE__ ?? null
)
}
export function isChurnkeyConfigured(): boolean {
return !!readAppId()
}
let embedScriptPromise: Promise<void> | null = null
function injectEmbedScript(appId: string): Promise<void> {
return new Promise<void>((resolve, reject) => {
window.churnkey ??= { created: true }
const script = document.createElement('script')
script.src = `${EMBED_SCRIPT_URL}?appId=${encodeURIComponent(appId)}`
script.async = true
script.onload = () => {
if (window.churnkey?.init) resolve()
else reject(new ChurnkeyEmbedLoadError())
}
script.onerror = () => {
script.remove()
reject(new ChurnkeyEmbedLoadError())
}
document.head.append(script)
})
}
function loadEmbedScript(appId: string): Promise<void> {
if (window.churnkey?.init) return Promise.resolve()
embedScriptPromise ??= injectEmbedScript(appId).catch((err: unknown) => {
// Clear the cached attempt so the next launch can retry the load.
embedScriptPromise = null
throw err
})
return embedScriptPromise
}
interface ChurnkeyShowOptions {
handleCancel?: (
surveyResponse: string,
freeformFeedback?: string
) => Promise<ChurnkeyHandlerResult>
onCancel?: (surveyResponse: string) => void
customerAttributes?: Record<string, string | number>
}
export interface ChurnkeySession {
/**
* Opens the Churnkey modal. Resolves with the session results when the
* modal closes; rejects only if `churnkey.init` itself throws.
*/
show: (options: ChurnkeyShowOptions) => Promise<ChurnkeySessionResults>
}
/**
* Loads the Churnkey embed script (on demand, cached) and fetches signed
* auth credentials. Throws {@link ChurnkeyEmbedLoadError} or
* {@link ChurnkeyAuthUnavailableError} so callers can fall back to the
* legacy cancel dialog before any cancellation-funnel telemetry fires.
*/
export async function prepareChurnkey(): Promise<ChurnkeySession> {
const appId = readAppId()
if (!appId) {
throw new Error(
'Churnkey is not configured (churnkey_app_id flag is unset)'
)
}
await loadEmbedScript(appId)
const init = window.churnkey?.init
if (!init) throw new ChurnkeyEmbedLoadError()
const override = readAuthOverride()
const auth = override ?? (await workspaceApi.getChurnkeyAuth())
if (auth === null) {
throw new ChurnkeyAuthUnavailableError()
}
// Arrow assignment (not a hoisted declaration) so the narrowing of
// `init` and `auth` above carries into the closure.
const show = (options: ChurnkeyShowOptions) =>
new Promise<ChurnkeySessionResults>((resolve, reject) => {
const config: ChurnkeyInitConfig = {
appId,
authHash: auth.auth_hash,
customerId: auth.customer_id,
provider: 'stripe',
mode: auth.mode,
record: true,
customerAttributes: options.customerAttributes,
onCancel: (_customer, surveyResponse) =>
options.onCancel?.(surveyResponse),
onClose: (results) => {
// Reset Churnkey's cached session state so the next launch
// restarts at step 1 (e.g. user visited Stripe but did not cancel).
window.churnkey?.clearState?.()
resolve(results)
}
}
if (options.handleCancel) {
const userHandleCancel = options.handleCancel
config.handleCancel = (_customer, surveyResponse, freeformFeedback) =>
userHandleCancel(surveyResponse, freeformFeedback)
}
try {
init('show', config)
} catch (err) {
reject(err instanceof Error ? err : new Error(String(err)))
}
})
return { show }
}

View File

@@ -0,0 +1,199 @@
#ck-app .ck-modal-container {
z-index: 10000 !important;
}
.ck-style,
.ck-style * {
font-family: inherit !important;
}
.ck-background-overlay,
#ck-cf-modal-overlay {
background: rgb(0 0 0 / 0.7) !important;
}
/* Churnkey uses var(--color-brand-black) for primary text (titles, etc.).
Remap it to our light foreground so that text is readable on the dark
modal. Background utilities that also use it (bg-brand-black) are guarded
below so they don't turn light. */
#ck-app,
.ck-style {
--color-brand-black: var(--base-foreground) !important;
}
.ck-modal,
#ck-cf-modal {
background: var(--base-background) !important;
border: 1px solid var(--border-default) !important;
box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.5) !important;
border-radius: 16px !important;
}
.ck-step,
.ck-survey-step,
.ck-confirm-step,
.ck-freeform-step,
.ck-pause-step,
.ck-discount-step,
.ck-contact-step,
.ck-redirect-step,
.ck-complete-step,
.ck-progress-step,
.ck-error-step {
background: var(--base-background) !important;
}
.ck-step-header {
background: var(--base-background) !important;
border-bottom: 1px solid var(--border-subtle) !important;
}
.ck-step-header-text {
color: var(--base-foreground) !important;
}
.ck-step-description-text,
.ck-description,
.ck-style .subtitle {
color: var(--muted-foreground) !important;
}
.ck-step-body {
background: var(--base-background) !important;
color: var(--muted-foreground) !important;
}
.ck-step-footer {
background: var(--base-background) !important;
border-top: 1px solid var(--border-subtle) !important;
}
.ck-style select,
.ck-style input,
.ck-style textarea {
background-color: var(--secondary-background) !important;
color: var(--base-foreground) !important;
border-color: var(--border-default) !important;
}
.ck-style select:focus-visible,
.ck-style input:focus-visible,
.ck-style textarea:focus-visible {
border-color: var(--primary-background) !important;
box-shadow: 0 0 0 2px var(--primary-background) !important;
outline: 2px solid transparent !important;
outline-offset: 2px !important;
}
.ck-style ::placeholder {
color: var(--muted-foreground) !important;
opacity: 0.6 !important;
}
.ck-style option {
background-color: var(--secondary-background) !important;
color: var(--base-foreground) !important;
}
/* Churnkey injects its compiled utility CSS at runtime, AFTER this bundled
sheet, so `.ck-style`-scoped overrides tie on specificity and lose on
source order — leaving dark brand/gray text on the dark modal. The
`#ck-app` id prefix raises specificity above Churnkey's `.ck-style`
utilities so these win regardless of injection order. */
#ck-app .text-gray-900,
#ck-app .text-gray-800,
#ck-app .text-brand-black {
color: var(--base-foreground) !important;
}
#ck-app .text-gray-700,
#ck-app .text-gray-600,
#ck-app .text-gray-500,
#ck-app .text-gray-400 {
color: var(--muted-foreground) !important;
}
#ck-app .border-gray-100,
#ck-app .border-gray-200,
#ck-app .border-gray-300 {
border-color: var(--border-default) !important;
}
#ck-app .bg-gray-100,
#ck-app .bg-gray-200,
#ck-app .bg-gray-300 {
background-color: var(--secondary-background) !important;
}
/* Guard: brand-black is remapped to a light foreground for text, so force
its background usage to a dark surface. Primary buttons override this to
the accent via their own rule below. */
#ck-app .bg-brand-black {
background-color: var(--secondary-background) !important;
}
#ck-app .text-opacity-60,
#ck-app .text-opacity-80,
#ck-app .text-opacity-90 {
--tw-text-opacity: 1 !important;
}
#ck-app [class*='bg-client-primary-light'] {
background-color: var(--secondary-background) !important;
}
#ck-app .bg-opacity-5 {
--tw-bg-opacity: 1 !important;
}
.ck-pause-subscription-details {
border-color: var(--border-default) !important;
color: var(--muted-foreground) !important;
}
.ck-pause-subscription-details b {
color: var(--base-foreground) !important;
}
#ck-app .active-discount-disclaimer {
color: var(--muted-foreground) !important;
background-color: var(--secondary-background) !important;
}
.ck-step-body li,
.ck-step-body label {
color: var(--muted-foreground) !important;
}
#ck-app .h-14.rounded-t-lg {
background-color: var(--secondary-background) !important;
}
#ck-app .bg-client-primary {
background-color: var(--primary-background) !important;
}
#ck-app .text-client-primary,
#ck-app .text-client-primary-light {
color: var(--muted-foreground) !important;
}
#ck-app .text-client-primary-middle {
color: var(--muted-foreground) !important;
opacity: 0.4 !important;
}
#ck-app .border-client-primary {
border-color: var(--primary-background) !important;
}
#ck-app .border-client-primary-light,
#ck-app .border-text-client-primary {
border-color: var(--border-default) !important;
}
/* Buttons carry both Churnkey component classes (.ck-*-button) and raw
utilities (bg-brand-black, bg-gray-200, text-white, text-brand-black).
Scope under the #ck-app id so these win over Churnkey's runtime-injected
`.ck-style` utilities — otherwise the utility bg/text colors leak through
and produce light-on-light / dark-on-dark buttons. */
#ck-app .ck-primary-button,
#ck-app .ck-black-primary-button {
background: var(--primary-background) !important;
color: var(--base-foreground) !important;
border: none !important;
border-radius: 8px !important;
font-weight: 600 !important;
}
#ck-app .ck-primary-button:hover,
#ck-app .ck-black-primary-button:hover {
background: var(--primary-background-hover) !important;
}
#ck-app .ck-gray-primary-button {
background: var(--secondary-background) !important;
color: var(--base-foreground) !important;
border: 1px solid var(--border-default) !important;
border-radius: 8px !important;
}
#ck-app .ck-text-button,
#ck-app .ck-black-text-button {
color: var(--muted-foreground) !important;
}

View File

@@ -0,0 +1,25 @@
/**
* Thrown when the backend's `/billing/churnkey/auth` endpoint is missing
* (e.g. backend hasn't been deployed yet). Callers should treat this the
* same as Churnkey not being configured at all and fall back to the
* legacy cancel dialog rather than surfacing a toast.
*/
export class ChurnkeyAuthUnavailableError extends Error {
constructor() {
super('Churnkey auth endpoint not available')
this.name = 'ChurnkeyAuthUnavailableError'
}
}
/**
* Thrown when the Churnkey embed script fails to load — network failure or,
* more likely, an ad blocker (churn-prevention scripts are on common
* blocklists). Callers must fall back to the legacy cancel dialog so the
* user always has a way to cancel.
*/
export class ChurnkeyEmbedLoadError extends Error {
constructor() {
super('Churnkey embed script failed to load')
this.name = 'ChurnkeyEmbedLoadError'
}
}

View File

@@ -0,0 +1,394 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ChurnkeyAuthUnavailableError, ChurnkeyEmbedLoadError } from './errors'
const mocks = vi.hoisted(() => ({
fetchStatus: vi.fn(),
cancelSubscription: vi.fn(),
trackCancellationFlowOpened: vi.fn(),
trackCancellationFlowClosed: vi.fn(),
trackMonthlySubscriptionCancelled: vi.fn(),
toastAdd: vi.fn(),
prepareChurnkey: vi.fn(),
show: vi.fn(),
billingType: { value: 'workspace' as 'legacy' | 'workspace' },
subscription: {
value: null as {
tier: string | null
duration: string | null
planSlug: string | null
} | null
}
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({ add: mocks.toastAdd })
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
type: {
get value() {
return mocks.billingType.value
}
},
fetchStatus: mocks.fetchStatus,
cancelSubscription: mocks.cancelSubscription,
subscription: {
get value() {
return mocks.subscription.value
}
}
})
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
trackCancellationFlowOpened: mocks.trackCancellationFlowOpened,
trackCancellationFlowClosed: mocks.trackCancellationFlowClosed,
trackMonthlySubscriptionCancelled: mocks.trackMonthlySubscriptionCancelled
})
}))
vi.mock('./churnkeyClient', () => ({
prepareChurnkey: mocks.prepareChurnkey
}))
const { launchChurnkeyCancellation } =
await import('./launchChurnkeyCancellation')
interface CapturedShowOptions {
customerAttributes?: Record<string, string>
handleCancel?: () => Promise<{ message?: string }>
onCancel: (surveyResponse: string) => void
}
type SessionResults = Record<string, unknown>
/**
* Mirrors the real client contract: show() captures the session callbacks
* and resolves with the session results when the modal closes.
*/
function openDeferredSession() {
let resolveShow!: (results: SessionResults) => void
let rejectShow!: (err: unknown) => void
let options: CapturedShowOptions | undefined
mocks.show.mockImplementation((opts: CapturedShowOptions) => {
options = opts
return new Promise<SessionResults>((resolve, reject) => {
resolveShow = resolve
rejectShow = reject
})
})
return {
options: () => {
if (!options) throw new Error('churnkey session.show was not called')
return options
},
close: (results: SessionResults) => resolveShow(results),
fail: (err: unknown) => rejectShow(err)
}
}
async function waitForShow() {
await vi.waitFor(() => expect(mocks.show).toHaveBeenCalled())
}
describe('launchChurnkeyCancellation', () => {
beforeEach(() => {
mocks.billingType.value = 'workspace'
mocks.subscription.value = null
mocks.prepareChurnkey.mockReset()
mocks.prepareChurnkey.mockResolvedValue({ show: mocks.show })
mocks.show.mockReset()
mocks.show.mockResolvedValue({ status: 'closed' })
mocks.fetchStatus.mockReset()
mocks.fetchStatus.mockResolvedValue(undefined)
mocks.cancelSubscription.mockReset()
mocks.cancelSubscription.mockResolvedValue(undefined)
mocks.trackCancellationFlowOpened.mockReset()
mocks.trackCancellationFlowClosed.mockReset()
mocks.trackMonthlySubscriptionCancelled.mockReset()
mocks.toastAdd.mockReset()
})
it('emits exactly one cancellation_flow_closed when the user cancels', async () => {
const session = openDeferredSession()
const launch = launchChurnkeyCancellation()
await waitForShow()
session.options().onCancel('too_expensive')
session.close({ status: 'canceled' })
await launch
expect(mocks.trackCancellationFlowClosed).toHaveBeenCalledTimes(1)
expect(mocks.trackCancellationFlowClosed).toHaveBeenCalledWith({
outcome: 'canceled',
survey_response: 'too_expensive'
})
expect(mocks.trackMonthlySubscriptionCancelled).toHaveBeenCalledTimes(1)
})
it('tracks opened once per session, after preparation succeeds', async () => {
await launchChurnkeyCancellation()
expect(mocks.trackCancellationFlowOpened).toHaveBeenCalledTimes(1)
const prepareOrder = mocks.prepareChurnkey.mock.invocationCallOrder[0]
const openedOrder =
mocks.trackCancellationFlowOpened.mock.invocationCallOrder[0]
const showOrder = mocks.show.mock.invocationCallOrder[0]
expect(prepareOrder).toBeLessThan(openedOrder)
expect(openedOrder).toBeLessThan(showOrder)
})
it('passes handleCancel and calls billing.cancelSubscription for workspace billing', async () => {
mocks.billingType.value = 'workspace'
const session = openDeferredSession()
const launch = launchChurnkeyCancellation()
await waitForShow()
const handleCancel = session.options().handleCancel
expect(handleCancel).toBeTypeOf('function')
await expect(handleCancel?.()).resolves.toEqual({
message: 'subscription.cancelSuccess'
})
expect(mocks.cancelSubscription).toHaveBeenCalledTimes(1)
session.close({ status: 'canceled' })
await launch
})
it('omits handleCancel for legacy billing so Churnkey cancels via Stripe', async () => {
mocks.billingType.value = 'legacy'
const session = openDeferredSession()
const launch = launchChurnkeyCancellation()
await waitForShow()
expect(session.options().handleCancel).toBeUndefined()
expect(mocks.cancelSubscription).not.toHaveBeenCalled()
session.close({ status: 'closed' })
await launch
})
it('rejects handleCancel with the API error message and records cancel_api_failed on close', async () => {
const apiError = new Error('card declined')
mocks.cancelSubscription.mockRejectedValue(apiError)
const session = openDeferredSession()
const launch = launchChurnkeyCancellation()
await waitForShow()
// Churnkey shows this rejection message in its own UI.
await expect(session.options().handleCancel?.()).rejects.toMatchObject({
message: 'card declined',
cause: apiError
})
session.close({ status: 'closed' })
await launch
expect(mocks.trackCancellationFlowClosed).toHaveBeenCalledWith({
outcome: 'unknown',
failure_reason: 'cancel_api_failed'
})
})
it('clears the cancel_api_failed flag when a retry succeeds', async () => {
mocks.cancelSubscription
.mockRejectedValueOnce(new Error('card declined'))
.mockResolvedValueOnce(undefined)
const session = openDeferredSession()
const launch = launchChurnkeyCancellation()
await waitForShow()
const handleCancel = session.options().handleCancel
await expect(handleCancel?.()).rejects.toThrow('card declined')
await expect(handleCancel?.()).resolves.toEqual({
message: 'subscription.cancelSuccess'
})
session.options().onCancel('too_expensive')
session.close({ status: 'canceled' })
await launch
expect(mocks.trackCancellationFlowClosed).toHaveBeenCalledWith({
outcome: 'canceled',
survey_response: 'too_expensive'
})
})
it('refreshes local billing state after a cancel', async () => {
const session = openDeferredSession()
const launch = launchChurnkeyCancellation()
await waitForShow()
session.options().onCancel('too_expensive')
session.close({ status: 'canceled' })
await launch
await vi.waitFor(() => expect(mocks.fetchStatus).toHaveBeenCalledTimes(1))
})
it('does not refresh local state when the user closes without canceling', async () => {
await launchChurnkeyCancellation()
expect(mocks.fetchStatus).not.toHaveBeenCalled()
})
it('records reconsidered when the user closes without canceling', async () => {
await launchChurnkeyCancellation()
expect(mocks.trackCancellationFlowClosed).toHaveBeenCalledTimes(1)
expect(mocks.trackCancellationFlowClosed).toHaveBeenCalledWith({
outcome: 'reconsidered'
})
})
it('maps Churnkey discounted status to discounted outcome', async () => {
mocks.show.mockResolvedValue({ status: 'discounted' })
await launchChurnkeyCancellation()
expect(mocks.trackCancellationFlowClosed).toHaveBeenCalledWith({
outcome: 'discounted'
})
})
it('maps Churnkey paused status to paused outcome', async () => {
mocks.show.mockResolvedValue({ status: 'paused' })
await launchChurnkeyCancellation()
expect(mocks.trackCancellationFlowClosed).toHaveBeenCalledWith({
outcome: 'paused'
})
})
it('swallows fetchStatus failures after the cancel', async () => {
mocks.fetchStatus.mockRejectedValue(new Error('network'))
const session = openDeferredSession()
const launch = launchChurnkeyCancellation()
await waitForShow()
session.options().onCancel('too_expensive')
session.close({ status: 'canceled' })
await expect(launch).resolves.toBeUndefined()
await vi.waitFor(() => expect(mocks.fetchStatus).toHaveBeenCalledTimes(1))
expect(mocks.trackCancellationFlowClosed).toHaveBeenCalledWith({
outcome: 'canceled',
survey_response: 'too_expensive'
})
})
it('forwards customerAttributes from billing subscription', async () => {
mocks.subscription.value = {
tier: 'PRO',
duration: 'MONTHLY',
planSlug: 'pro-monthly'
}
await launchChurnkeyCancellation()
expect(mocks.show.mock.calls[0][0].customerAttributes).toEqual({
tier: 'PRO',
cycle: 'MONTHLY',
plan_slug: 'pro-monthly'
})
})
it('omits customerAttributes when subscription is null', async () => {
await launchChurnkeyCancellation()
expect(mocks.show.mock.calls[0][0].customerAttributes).toBeUndefined()
})
it('re-throws ChurnkeyAuthUnavailableError without toast or telemetry', async () => {
mocks.prepareChurnkey.mockRejectedValue(new ChurnkeyAuthUnavailableError())
await expect(launchChurnkeyCancellation()).rejects.toBeInstanceOf(
ChurnkeyAuthUnavailableError
)
expect(mocks.toastAdd).not.toHaveBeenCalled()
expect(mocks.trackCancellationFlowOpened).not.toHaveBeenCalled()
expect(mocks.trackCancellationFlowClosed).not.toHaveBeenCalled()
})
it('re-throws ChurnkeyEmbedLoadError without toast or telemetry', async () => {
mocks.prepareChurnkey.mockRejectedValue(new ChurnkeyEmbedLoadError())
await expect(launchChurnkeyCancellation()).rejects.toBeInstanceOf(
ChurnkeyEmbedLoadError
)
expect(mocks.toastAdd).not.toHaveBeenCalled()
expect(mocks.trackCancellationFlowOpened).not.toHaveBeenCalled()
expect(mocks.trackCancellationFlowClosed).not.toHaveBeenCalled()
})
it('shows a toast without telemetry when preparation fails unexpectedly', async () => {
mocks.prepareChurnkey.mockRejectedValue(new Error('auth endpoint 500'))
await expect(launchChurnkeyCancellation()).resolves.toBeUndefined()
expect(mocks.toastAdd).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'error',
detail: 'auth endpoint 500'
})
)
expect(mocks.trackCancellationFlowOpened).not.toHaveBeenCalled()
expect(mocks.trackCancellationFlowClosed).not.toHaveBeenCalled()
})
it('shows a toast and a balancing closed event when the session fails after opening', async () => {
const session = openDeferredSession()
const launch = launchChurnkeyCancellation()
await waitForShow()
session.fail(new Error('init exploded'))
await launch
expect(mocks.toastAdd).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'error',
detail: 'init exploded'
})
)
expect(mocks.trackCancellationFlowOpened).toHaveBeenCalledTimes(1)
expect(mocks.trackCancellationFlowClosed).toHaveBeenCalledWith({
outcome: 'unknown',
failure_reason: 'unexpected'
})
})
it('ignores concurrent calls while the session is open', async () => {
const session = openDeferredSession()
const first = launchChurnkeyCancellation()
await waitForShow()
await launchChurnkeyCancellation()
expect(mocks.prepareChurnkey).toHaveBeenCalledTimes(1)
expect(mocks.trackCancellationFlowOpened).toHaveBeenCalledTimes(1)
session.close({ status: 'closed' })
await first
// Guard released on close; a fresh launch proceeds.
mocks.show.mockReset()
mocks.show.mockResolvedValue({ status: 'closed' })
await launchChurnkeyCancellation()
expect(mocks.prepareChurnkey).toHaveBeenCalledTimes(2)
})
it('releases the in-flight guard when preparation fails', async () => {
mocks.prepareChurnkey.mockRejectedValueOnce(new Error('boom'))
await launchChurnkeyCancellation()
await launchChurnkeyCancellation()
expect(mocks.prepareChurnkey).toHaveBeenCalledTimes(2)
})
})

View File

@@ -0,0 +1,143 @@
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { t } from '@/i18n'
import { useTelemetry } from '@/platform/telemetry'
import type { CancellationFlowClosedMetadata } from '@/platform/telemetry/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
import type { ChurnkeySession } from './churnkeyClient'
import { prepareChurnkey } from './churnkeyClient'
import { ChurnkeyAuthUnavailableError, ChurnkeyEmbedLoadError } from './errors'
import type { ChurnkeySessionResults } from './types'
type CancellationOutcome = CancellationFlowClosedMetadata['outcome']
function deriveOutcome(
results: ChurnkeySessionResults,
canceledThisSession: boolean,
cancelApiFailed: boolean
): CancellationOutcome {
if (canceledThisSession) return 'canceled'
if (cancelApiFailed) return 'unknown'
if (results.status === 'closed') return 'reconsidered'
return results.status ?? 'unknown'
}
function buildCustomerAttributes(
billing: ReturnType<typeof useBillingContext>
): Record<string, string> | undefined {
const sub = billing.subscription.value
if (!sub) return undefined
const attrs: Record<string, string> = {}
if (sub.tier) attrs.tier = sub.tier
if (sub.duration) attrs.cycle = sub.duration
if (sub.planSlug) attrs.plan_slug = sub.planSlug
return Object.keys(attrs).length > 0 ? attrs : undefined
}
let inFlight = false
export async function launchChurnkeyCancellation(): Promise<void> {
if (inFlight) return
inFlight = true
try {
await runCancellationFlow()
} finally {
inFlight = false
}
}
async function runCancellationFlow(): Promise<void> {
const billing = useBillingContext()
const telemetry = useTelemetry()
const toast = useToastStore()
function showFailureToast(err: unknown) {
toast.add({
severity: 'error',
summary: t('subscription.cancelDialog.failed'),
detail: err instanceof Error ? err.message : t('g.unknownError'),
life: 5000
})
}
let session: ChurnkeySession
try {
session = await prepareChurnkey()
} catch (err) {
if (
err instanceof ChurnkeyAuthUnavailableError ||
err instanceof ChurnkeyEmbedLoadError
) {
// Re-throw so the caller can route to the legacy dialog.
throw err
}
showFailureToast(err)
return
}
let canceledThisSession = false
let cancelApiFailed = false
let lastSurveyResponse: string | undefined
telemetry?.trackCancellationFlowOpened()
try {
const results = await session.show({
customerAttributes: buildCustomerAttributes(billing),
// Workspace billing cancels through our API; legacy billing omits
// handleCancel so Churnkey cancels directly via Stripe.
...(billing.type.value === 'workspace' && {
handleCancel: async () => {
try {
await billing.cancelSubscription()
} catch (err) {
cancelApiFailed = true
const message =
err instanceof Error
? err.message
: t('subscription.cancelDialog.failed')
// Churnkey displays the rejection message in its own UI.
throw new Error(message, { cause: err })
}
cancelApiFailed = false
return { message: t('subscription.cancelSuccess') }
}
}),
// Fires after a successful cancel — whether via handleCancel (team)
// or Churnkey's own Stripe cancel (legacy). No double-fire with
// useSubscriptionCancellationWatcher: that watcher only runs after
// opening the Stripe billing portal via manageSubscription.
onCancel: (surveyResponse) => {
canceledThisSession = true
lastSurveyResponse = surveyResponse
telemetry?.trackMonthlySubscriptionCancelled()
}
})
const outcome = deriveOutcome(results, canceledThisSession, cancelApiFailed)
const failureReason = cancelApiFailed
? ('cancel_api_failed' as const)
: undefined
telemetry?.trackCancellationFlowClosed({
outcome,
...(lastSurveyResponse !== undefined && {
survey_response: lastSurveyResponse
}),
...(failureReason !== undefined && { failure_reason: failureReason })
})
if (canceledThisSession) {
// Refresh local state so the UI reflects the cancellation. Failure
// here is non-blocking; the next page load will catch up.
void billing.fetchStatus().catch(() => {})
}
} catch (err) {
// session.show only rejects when churnkey.init itself throws — keep
// the funnel balanced since `opened` has already been tracked.
telemetry?.trackCancellationFlowClosed({
outcome: 'unknown',
failure_reason: 'unexpected'
})
showFailureToast(err)
}
}

View File

@@ -0,0 +1,55 @@
// Subset of the Churnkey embed API. No official @types package exists.
// Docs: https://docs.churnkey.co/cancel-flows/further-configuration/
export type ChurnkeyMode = 'live' | 'test' | 'sandbox'
type ChurnkeyProvider = 'stripe' | 'chargebee' | 'braintree' | 'paddle'
export interface ChurnkeyHandlerResult {
message?: string
}
export interface ChurnkeyInitConfig {
appId: string
authHash: string
customerId: string
subscriptionId?: string
provider: ChurnkeyProvider
mode: ChurnkeyMode
record?: boolean
preview?: boolean
report?: boolean
bypassDiscountAppliedScreen?: boolean
bypassPauseAppliedScreen?: boolean
customerAttributes?: Record<string, string | number>
handleCancel?: (
customer: string,
surveyResponse: string,
freeformFeedback?: string
) => Promise<ChurnkeyHandlerResult>
handleSupportRequest?: (customer: string) => void
onCancel?: (customer: string, surveyResponse: string) => void
onClose?: (sessionResults: ChurnkeySessionResults) => void
onGoToAccount?: (sessionResults: ChurnkeySessionResults) => void
}
export interface ChurnkeySessionResults {
status?: 'canceled' | 'discounted' | 'paused' | 'closed'
[key: string]: unknown
}
export interface ChurnkeyWindow {
created?: boolean
/** Defined once the embed script (loaded on demand) has executed. */
init?: (action: 'show' | 'restart', config: ChurnkeyInitConfig) => void
hide?: () => void
clearState?: () => void
}
declare global {
interface Window {
churnkey?: ChurnkeyWindow
}
}

View File

@@ -0,0 +1,85 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
ChurnkeyAuthUnavailableError,
ChurnkeyEmbedLoadError
} from '@/platform/cloud/churnkey/errors'
const showCancelSubscriptionDialog = vi.hoisted(() => vi.fn())
const launchChurnkeyCancellationMock = vi.hoisted(() => vi.fn())
const isChurnkeyConfiguredMock = vi.hoisted(() => vi.fn())
vi.mock('./showCancelSubscriptionDialog', () => ({
showCancelSubscriptionDialog
}))
vi.mock('@/platform/cloud/churnkey/churnkeyClient', () => ({
isChurnkeyConfigured: isChurnkeyConfiguredMock
}))
vi.mock('@/platform/cloud/churnkey/launchChurnkeyCancellation', () => ({
launchChurnkeyCancellation: launchChurnkeyCancellationMock
}))
const { launchCancellationFlow } = await import('./launchCancellationFlow')
describe('launchCancellationFlow', () => {
beforeEach(() => {
showCancelSubscriptionDialog.mockReset()
launchChurnkeyCancellationMock.mockReset()
isChurnkeyConfiguredMock.mockReset()
})
it('launches Churnkey when the churnkey_app_id flag is set', async () => {
isChurnkeyConfiguredMock.mockReturnValue(true)
launchChurnkeyCancellationMock.mockResolvedValue(undefined)
await launchCancellationFlow('2026-12-01')
expect(launchChurnkeyCancellationMock).toHaveBeenCalledTimes(1)
expect(showCancelSubscriptionDialog).not.toHaveBeenCalled()
})
it('falls back to the legacy dialog when the churnkey_app_id flag is not set', async () => {
isChurnkeyConfiguredMock.mockReturnValue(false)
await launchCancellationFlow('2026-12-01')
expect(launchChurnkeyCancellationMock).not.toHaveBeenCalled()
expect(showCancelSubscriptionDialog).toHaveBeenCalledWith('2026-12-01')
})
it('falls back to the legacy dialog on ChurnkeyAuthUnavailableError', async () => {
isChurnkeyConfiguredMock.mockReturnValue(true)
launchChurnkeyCancellationMock.mockRejectedValue(
new ChurnkeyAuthUnavailableError()
)
await launchCancellationFlow('2026-12-01')
expect(showCancelSubscriptionDialog).toHaveBeenCalledWith('2026-12-01')
})
it('falls back to the legacy dialog when the embed script fails to load', async () => {
isChurnkeyConfiguredMock.mockReturnValue(true)
launchChurnkeyCancellationMock.mockRejectedValue(
new ChurnkeyEmbedLoadError()
)
await launchCancellationFlow('2026-12-01')
expect(showCancelSubscriptionDialog).toHaveBeenCalledWith('2026-12-01')
})
it('does not fall back when Churnkey throws other errors', async () => {
isChurnkeyConfiguredMock.mockReturnValue(true)
launchChurnkeyCancellationMock.mockRejectedValue(
new Error('something else')
)
await expect(launchCancellationFlow('2026-12-01')).rejects.toThrow(
'something else'
)
expect(showCancelSubscriptionDialog).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,39 @@
import { isChurnkeyConfigured } from '@/platform/cloud/churnkey/churnkeyClient'
import {
ChurnkeyAuthUnavailableError,
ChurnkeyEmbedLoadError
} from '@/platform/cloud/churnkey/errors'
import { launchChurnkeyCancellation } from '@/platform/cloud/churnkey/launchChurnkeyCancellation'
import { showCancelSubscriptionDialog } from './showCancelSubscriptionDialog'
function shouldUseChurnkey(): boolean {
if (isChurnkeyConfigured()) return true
console.info(
'[Churnkey] Using legacy cancel dialog: churnkey_app_id flag is not set.'
)
return false
}
export async function launchCancellationFlow(cancelAt?: string): Promise<void> {
if (!shouldUseChurnkey()) {
await showCancelSubscriptionDialog(cancelAt)
return
}
try {
await launchChurnkeyCancellation()
} catch (err) {
const fallbackReason =
err instanceof ChurnkeyAuthUnavailableError
? 'auth endpoint unavailable'
: err instanceof ChurnkeyEmbedLoadError
? 'embed script failed to load (often blocked by an ad blocker)'
: null
if (fallbackReason === null) throw err
console.warn(
`[Churnkey] Falling back to legacy cancel dialog: ${fallbackReason}.`
)
await showCancelSubscriptionDialog(cancelAt)
}
}

View File

@@ -0,0 +1,15 @@
import { workspaceDialogProps } from '@/platform/workspace/components/dialogs/workspaceDialogProps'
import { useDialogStore } from '@/stores/dialogStore'
export async function showCancelSubscriptionDialog(cancelAt?: string) {
const { default: component } =
await import('@/components/dialog/content/subscription/CancelSubscriptionDialogContent.vue')
return useDialogStore().showDialog({
key: 'cancel-subscription',
component,
props: { cancelAt },
dialogComponentProps: {
...workspaceDialogProps
}
})
}

View File

@@ -109,6 +109,7 @@ export type RemoteConfig = {
workflow_sharing_enabled?: boolean
comfyhub_upload_enabled?: boolean
comfyhub_profile_gate_enabled?: boolean
churnkey_app_id?: string
unified_cloud_auth?: boolean
sentry_dsn?: string
turnstile_sitekey?: string

View File

@@ -3,6 +3,7 @@ import type { AuditLog } from '@/services/customerEventsService'
import type {
AuthMetadata,
BeginCheckoutMetadata,
CancellationFlowClosedMetadata,
DefaultViewSetMetadata,
EnterLinearMetadata,
ShareFlowMetadata,
@@ -268,4 +269,14 @@ export class TelemetryRegistry implements TelemetryDispatcher {
trackPageView(pageName: string, properties?: PageViewMetadata): void {
this.dispatch((provider) => provider.trackPageView?.(pageName, properties))
}
trackCancellationFlowOpened(): void {
this.dispatch((provider) => provider.trackCancellationFlowOpened?.())
}
trackCancellationFlowClosed(metadata: CancellationFlowClosedMetadata): void {
this.dispatch((provider) =>
provider.trackCancellationFlowClosed?.(metadata)
)
}
}

View File

@@ -488,6 +488,54 @@ describe('PostHogTelemetryProvider', () => {
})
})
describe('cancellation flow', () => {
it('stamps the reconsidered person property when the flow closes reconsidered', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
provider.trackCancellationFlowClosed({ outcome: 'reconsidered' })
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.CANCELLATION_FLOW_CLOSED,
{ outcome: 'reconsidered' }
)
expect(hoisted.mockPeopleSet).toHaveBeenCalledWith({
cancellation_reconsidered_at: expect.any(String)
})
})
it('does not stamp the person property for other outcomes', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
provider.trackCancellationFlowClosed({ outcome: 'canceled' })
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.CANCELLATION_FLOW_CLOSED,
{ outcome: 'canceled' }
)
expect(hoisted.mockPeopleSet).not.toHaveBeenCalled()
})
it('does not stamp the person property when the closed event is disabled', async () => {
hoisted.refs.remoteConfig.value = {
telemetry_disabled_events: [TelemetryEvents.CANCELLATION_FLOW_CLOSED]
}
const provider = createProvider()
await vi.dynamicImportSettled()
provider.trackCancellationFlowClosed({ outcome: 'reconsidered' })
expect(hoisted.mockCapture).not.toHaveBeenCalledWith(
TelemetryEvents.CANCELLATION_FLOW_CLOSED,
expect.anything()
)
expect(hoisted.mockPeopleSet).not.toHaveBeenCalledWith({
cancellation_reconsidered_at: expect.any(String)
})
})
})
describe('disabled events', () => {
it('does not capture default disabled events', async () => {
const provider = createProvider()

View File

@@ -11,6 +11,7 @@ import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type {
AuthMetadata,
CancellationFlowClosedMetadata,
DefaultViewSetMetadata,
EnterLinearMetadata,
ShareFlowMetadata,
@@ -536,4 +537,24 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
...properties
})
}
trackCancellationFlowOpened(): void {
this.trackEvent(TelemetryEvents.CANCELLATION_FLOW_OPENED)
}
trackCancellationFlowClosed(metadata: CancellationFlowClosedMetadata): void {
this.trackEvent(TelemetryEvents.CANCELLATION_FLOW_CLOSED, metadata)
if (metadata.outcome !== 'reconsidered') return
if (!this.posthog || !this.isEnabled) return
if (this.disabledEvents.has(TelemetryEvents.CANCELLATION_FLOW_CLOSED))
return
try {
this.posthog.people.set({
cancellation_reconsidered_at: new Date().toISOString()
})
} catch (error) {
console.error('Failed to set PostHog user property:', error)
}
}
}

View File

@@ -452,6 +452,18 @@ interface EcommerceMetadata {
items: EcommerceItemMetadata[]
}
export interface CancellationFlowClosedMetadata {
outcome: 'canceled' | 'reconsidered' | 'discounted' | 'paused' | 'unknown'
survey_response?: string
/**
* Categorized reason when `outcome === 'unknown'` so PostHog dashboards
* can separate a failed cancel API call from an embed failure. Fallbacks
* to the legacy dialog (auth endpoint missing, embed script blocked)
* happen before the flow opens and emit no events at all.
*/
failure_reason?: 'cancel_api_failed' | 'unexpected'
}
export interface SubscriptionSuccessMetadata extends Record<string, unknown> {
user_id?: string
checkout_attempt_id: string
@@ -564,6 +576,10 @@ export interface TelemetryProvider {
// Page view tracking
trackPageView?(pageName: string, properties?: PageViewMetadata): void
// Cancellation flow events
trackCancellationFlowOpened?(): void
trackCancellationFlowClosed?(metadata: CancellationFlowClosedMetadata): void
}
/**
@@ -660,7 +676,11 @@ export const TelemetryEvents = {
UI_BUTTON_CLICKED: 'app:ui_button_clicked',
// Page View
PAGE_VIEW: 'app:page_view'
PAGE_VIEW: 'app:page_view',
// Cancellation Flow
CANCELLATION_FLOW_OPENED: 'app:cancellation_flow_opened',
CANCELLATION_FLOW_CLOSED: 'app:cancellation_flow_closed'
} as const
export type TelemetryEventName =
@@ -709,3 +729,4 @@ export type TelemetryEventProperties =
| DefaultViewSetMetadata
| SubscriptionMetadata
| SubscriptionSuccessMetadata
| CancellationFlowClosedMetadata

View File

@@ -335,6 +335,46 @@ describe('workspaceApi', () => {
})
expect(result).toEqual(data)
})
it('getChurnkeyAuth() returns the credentials on success', async () => {
const data = {
customer_id: 'cus_123',
auth_hash: 'hash_abc',
mode: 'live'
}
mockAxiosInstance.get.mockResolvedValue({ data })
const result = await workspaceApi.getChurnkeyAuth()
expect(mockAxiosInstance.get).toHaveBeenCalledWith(
'/api/billing/churnkey/auth',
{ headers: AUTH_HEADER }
)
expect(result).toEqual(data)
})
it('getChurnkeyAuth() returns null on 404 so callers fall back', async () => {
mockAxiosInstance.get.mockRejectedValue({
isAxiosError: true,
response: { status: 404, data: { error: { message: 'Not Found' } } },
message: 'Request failed'
})
await expect(workspaceApi.getChurnkeyAuth()).resolves.toBeNull()
})
it('getChurnkeyAuth() rethrows non-404 errors', async () => {
mockAxiosInstance.get.mockRejectedValue({
isAxiosError: true,
response: { status: 500, data: { message: 'Server Error' } },
message: 'Request failed'
})
await expect(workspaceApi.getChurnkeyAuth()).rejects.toMatchObject({
name: 'WorkspaceApiError',
status: 500
})
})
})
describe('subscription', () => {

View File

@@ -1,6 +1,7 @@
import axios from 'axios'
import { attachUnifiedRemintInterceptor } from '@/platform/auth/unified/remintRetry'
import type { ChurnkeyMode } from '@/platform/cloud/churnkey/types'
import type { SubscriptionTier } from '@/platform/cloud/subscription/constants/tierPricing'
import type {
WorkspaceId,
@@ -214,6 +215,13 @@ interface PaymentPortalResponse {
url: string
}
export interface ChurnkeyAuthResponse {
customer_id: string
subscription_id?: string
auth_hash: string
mode: ChurnkeyMode
}
interface PreviewPlanInfo {
slug: string
tier: SubscriptionTier
@@ -775,6 +783,37 @@ export const workspaceApi = {
}
},
/**
* Get Churnkey auth credentials (customer ID + HMAC) for the active workspace.
* GET /api/billing/churnkey/auth
* Used by the cancellation flow to launch the Churnkey embedded modal.
*
* Returns `null` on any 404 — callers fall back to the legacy cancel
* dialog. Verified against production (2026-06-12): an undeployed route
* hits the router's catch-all, which returns a JSON 404 body of
* `{"error":{"message":"Not Found","type":"not_found"}}` (application
* errors use a `{"code": ...}` shape instead, e.g. UNAUTHORIZED). A
* future application-level 404 such as "no Churnkey customer" also
* correctly falls back to the legacy dialog.
*
* The HMAC must be signed server-side; never derive it on the client.
*/
async getChurnkeyAuth(): Promise<ChurnkeyAuthResponse | null> {
const headers = await getAuthHeaderOrThrow()
const url = api.apiURL('/billing/churnkey/auth')
try {
const response = await workspaceApiClient.get<ChurnkeyAuthResponse>(url, {
headers
})
return response.data
} catch (err) {
if (axios.isAxiosError(err) && err.response?.status === 404) {
return null
}
handleAxiosError(err)
}
},
/**
* Get billing operation status
* GET /api/billing/ops/:id

View File

@@ -76,7 +76,7 @@ const mockManageSubscription = vi.fn()
const mockShowSubscriptionDialog = vi.fn()
const mockResubscribe = vi.fn()
const mockShowLeaveWorkspaceDialog = vi.fn()
const mockShowCancelSubscriptionDialog = vi.fn()
const mockLaunchCancellationFlow = vi.fn()
const mockShowEditWorkspaceDialog = vi.fn()
const mockShowDeleteWorkspaceDialog = vi.fn()
@@ -198,7 +198,7 @@ vi.mock('@/platform/workspace/stores/billingOperationStore', () => ({
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
showCancelSubscriptionDialog: mockShowCancelSubscriptionDialog,
launchCancellationFlow: mockLaunchCancellationFlow,
showLeaveWorkspaceDialog: mockShowLeaveWorkspaceDialog,
showEditWorkspaceDialog: mockShowEditWorkspaceDialog,
showDeleteWorkspaceDialog: mockShowDeleteWorkspaceDialog
@@ -635,7 +635,7 @@ describe('SubscriptionPanelContentWorkspace', () => {
).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Cancel plan' }))
expect(mockShowCancelSubscriptionDialog).toHaveBeenCalledOnce()
expect(mockLaunchCancellationFlow).toHaveBeenCalledOnce()
})
it('enables Delete for the original owner once the plan is cancelled', () => {

View File

@@ -0,0 +1,13 @@
const SELF_STYLED_PANEL_CONTENT_CLASS =
'w-fit max-w-[calc(100vw-1rem)] sm:max-w-[calc(100vw-1rem)] border-none bg-transparent shadow-none'
/**
* Reka chrome shared by headless workspace dialogs whose content draws its
* own panel — neutralize the DialogContent box and shrink-wrap it around the
* content.
*/
export const workspaceDialogProps = {
renderer: 'reka',
headless: true,
contentClass: SELF_STYLED_PANEL_CONTENT_CLASS
} as const

View File

@@ -26,7 +26,7 @@ export function useWorkspaceMenuItems() {
deleteDisabledTooltipKey
} = useWorkspaceUI()
const {
showCancelSubscriptionDialog,
launchCancellationFlow,
showEditWorkspaceDialog,
showDeleteWorkspaceDialog,
showLeaveWorkspaceDialog
@@ -37,7 +37,7 @@ export function useWorkspaceMenuItems() {
}
function cancelSubscription() {
void showCancelSubscriptionDialog(subscription.value?.endDate ?? undefined)
void launchCancellationFlow(subscription.value?.endDate ?? undefined)
}
function deleteWorkspace() {

View File

@@ -6,6 +6,7 @@ import ErrorDialogContent from '@/components/dialog/content/ErrorDialogContent.v
import PromptDialogContent from '@/components/dialog/content/PromptDialogContent.vue'
import TopUpCreditsDialogContentLegacy from '@/components/dialog/content/TopUpCreditsDialogContentLegacy.vue'
import TopUpCreditsDialogContentWorkspace from '@/platform/workspace/components/TopUpCreditsDialogContentWorkspace.vue'
import { workspaceDialogProps } from '@/platform/workspace/components/dialogs/workspaceDialogProps'
import { t } from '@/i18n'
import { useTelemetry } from '@/platform/telemetry'
import { isCloud } from '@/platform/distribution/types'
@@ -456,12 +457,6 @@ export const useDialogService = () => {
}
// Workspace dialogs - dynamically imported to avoid bundling when feature flag is off
const workspaceDialogProps = {
renderer: 'reka',
headless: true,
contentClass: SELF_STYLED_PANEL_CONTENT_CLASS
} as const
async function showDeleteWorkspaceDialog(options?: {
workspaceId?: string
workspaceName?: string
@@ -612,16 +607,15 @@ export const useDialogService = () => {
}
async function showCancelSubscriptionDialog(cancelAt?: string) {
const { default: component } =
await import('@/components/dialog/content/subscription/CancelSubscriptionDialogContent.vue')
return dialogStore.showDialog({
key: 'cancel-subscription',
component,
props: { cancelAt },
dialogComponentProps: {
...workspaceDialogProps
}
})
const { showCancelSubscriptionDialog: show } =
await import('@/platform/cloud/subscription/showCancelSubscriptionDialog')
return show(cancelAt)
}
async function launchCancellationFlow(cancelAt?: string): Promise<void> {
const { launchCancellationFlow: launch } =
await import('@/platform/cloud/subscription/launchCancellationFlow')
return launch(cancelAt)
}
/**
@@ -734,6 +728,7 @@ export const useDialogService = () => {
showInviteMemberUpsellDialog,
showBillingComingSoonDialog,
showCancelSubscriptionDialog,
launchCancellationFlow,
showDowngradeToPersonalDialog
}
}