Compare commits
17 Commits
codex/cove
...
pysssss/ch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04624eb8de | ||
|
|
be8e0010ee | ||
|
|
d0e97d6933 | ||
|
|
cb3765ba8f | ||
|
|
7546a6e960 | ||
|
|
b40974868a | ||
|
|
7b638a995a | ||
|
|
6dc7e6f4a2 | ||
|
|
0d16721eb9 | ||
|
|
8957d61a32 | ||
|
|
2f652aab92 | ||
|
|
10b89ee889 | ||
|
|
4f33013411 | ||
|
|
78421d9990 | ||
|
|
b02d2fea85 | ||
|
|
8fb5f49505 | ||
|
|
218b3cb260 |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
@@ -1,3 +1,3 @@
|
||||
<svg width="20" height="32" viewBox="0 0 20 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20 32V0C20 5.39616 15.5172 9.78053 10 9.78053C4.48276 9.78053 0 5.416 0 0V32C0 26.6038 4.48276 22.2195 10 22.2195C15.5172 22.2195 20 26.6038 20 32Z" fill="#F2FF59"/>
|
||||
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 20 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path id="Vector" d="M20 32V0C20 5.39616 15.5172 9.78053 10 9.78053C4.48276 9.78053 0 5.416 0 0V32C0 26.6038 4.48276 22.2195 10 22.2195C15.5172 22.2195 20 26.6038 20 32Z" fill="var(--fill-0, #F2FF59)"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 279 B After Width: | Height: | Size: 380 B |
BIN
apps/website/public/images/mcp/mcp-thumb-asphalt.webp
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
apps/website/public/images/mcp/mcp-thumb-concepts.webp
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
apps/website/public/images/mcp/mcp-thumb-kaiju.webp
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
apps/website/public/images/mcp/mcp-thumb-keyart.webp
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
apps/website/public/images/mcp/mcp-thumb-moodboard.webp
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
@@ -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
|
||||
|
||||
117
apps/website/src/components/blocks/FeatureGrid01.vue
Normal 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>
|
||||
100
apps/website/src/components/blocks/FeatureGrid02.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
import SectionHeader from '../common/SectionHeader.vue'
|
||||
import NodeUnionIcon from '../icons/NodeUnionIcon.vue'
|
||||
|
||||
type Cta = { label: string; href: string; target?: '_blank' }
|
||||
|
||||
export interface FeatureStep {
|
||||
id: string
|
||||
number: string
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
heading: string
|
||||
steps: readonly FeatureStep[]
|
||||
primaryCta?: Cta
|
||||
secondaryCta?: Cta
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
|
||||
<SectionHeader>{{ heading }}</SectionHeader>
|
||||
|
||||
<!-- Step cards in a row, joined by node-union connectors on desktop -->
|
||||
<div
|
||||
class="mt-12 flex flex-col gap-4 lg:flex-row lg:items-stretch lg:gap-0"
|
||||
>
|
||||
<template v-for="(step, i) in steps" :key="step.id">
|
||||
<div
|
||||
v-if="i > 0"
|
||||
class="relative z-10 -mx-px hidden shrink-0 items-center justify-center self-stretch lg:flex"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<NodeUnionIcon
|
||||
class="text-primary-comfy-yellow size-4 scale-x-150 rotate-90"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="border-primary-comfy-yellow flex flex-1 flex-col rounded-[40px] border-2 bg-primary-comfy-ink p-2"
|
||||
>
|
||||
<div class="flex flex-1 flex-col gap-4 p-8">
|
||||
<div>
|
||||
<p
|
||||
class="text-primary-comfy-yellow text-xs font-bold tracking-widest uppercase"
|
||||
>
|
||||
{{ step.number }}
|
||||
</p>
|
||||
<h3
|
||||
class="mt-1 text-2xl font-medium tracking-widest text-primary-comfy-canvas uppercase"
|
||||
>
|
||||
{{ step.title }}
|
||||
</h3>
|
||||
</div>
|
||||
<p class="text-primary-comfy-canvas">
|
||||
{{ step.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="primaryCta || secondaryCta"
|
||||
class="mt-12 flex flex-col items-center gap-4 lg:flex-row lg:justify-center"
|
||||
>
|
||||
<Button
|
||||
v-if="primaryCta"
|
||||
as="a"
|
||||
:href="primaryCta.href"
|
||||
:target="primaryCta.target"
|
||||
:rel="
|
||||
primaryCta.target === '_blank' ? 'noopener noreferrer' : undefined
|
||||
"
|
||||
size="lg"
|
||||
class="w-full lg:w-auto lg:min-w-48"
|
||||
>
|
||||
{{ primaryCta.label }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="secondaryCta"
|
||||
as="a"
|
||||
:href="secondaryCta.href"
|
||||
:target="secondaryCta.target"
|
||||
:rel="
|
||||
secondaryCta.target === '_blank' ? 'noopener noreferrer' : undefined
|
||||
"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
class="w-full lg:w-auto lg:min-w-48"
|
||||
>
|
||||
{{ secondaryCta.label }}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
108
apps/website/src/components/blocks/FeatureRows01.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import GlassCard from '../common/GlassCard.vue'
|
||||
import SectionHeader from '../common/SectionHeader.vue'
|
||||
import VideoPlayer from '../common/VideoPlayer.vue'
|
||||
import type { VideoTrack } from '../common/VideoPlayer.vue'
|
||||
|
||||
type RowMedia =
|
||||
| { type: 'image'; src: string; alt?: string }
|
||||
| {
|
||||
type: 'video'
|
||||
src: string
|
||||
// <video> has no native alt; used as the player's accessible label.
|
||||
alt?: string
|
||||
poster?: string
|
||||
tracks?: readonly VideoTrack[]
|
||||
autoplay?: boolean
|
||||
loop?: boolean
|
||||
minimal?: boolean
|
||||
hideControls?: boolean
|
||||
}
|
||||
|
||||
export interface FeatureRow {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
media: RowMedia
|
||||
}
|
||||
|
||||
const {
|
||||
heading,
|
||||
eyebrow,
|
||||
locale = 'en',
|
||||
rows
|
||||
} = defineProps<{
|
||||
heading: string
|
||||
eyebrow?: string
|
||||
locale?: Locale
|
||||
rows: readonly FeatureRow[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
|
||||
<SectionHeader :label="eyebrow" max-width="xl">
|
||||
{{ heading }}
|
||||
</SectionHeader>
|
||||
|
||||
<div class="mt-16 flex flex-col gap-4 lg:gap-6">
|
||||
<GlassCard
|
||||
v-for="(row, i) in rows"
|
||||
:key="row.id"
|
||||
class="flex flex-col gap-8 lg:flex-row lg:items-stretch lg:gap-0"
|
||||
>
|
||||
<!-- Text -->
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'order-2 flex flex-col justify-center gap-4 p-6 lg:w-1/2 lg:p-12',
|
||||
i % 2 === 0 ? 'lg:order-1' : 'lg:order-2'
|
||||
)
|
||||
"
|
||||
>
|
||||
<h3 class="text-2xl font-light text-primary-comfy-canvas lg:text-3xl">
|
||||
{{ row.title }}
|
||||
</h3>
|
||||
<p class="text-sm text-smoke-700 lg:text-base">
|
||||
{{ row.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Media: image or video -->
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'order-1 flex lg:w-1/2',
|
||||
i % 2 === 0 ? 'lg:order-2' : 'lg:order-1'
|
||||
)
|
||||
"
|
||||
>
|
||||
<img
|
||||
v-if="row.media.type === 'image'"
|
||||
:src="row.media.src"
|
||||
:alt="row.media.alt ?? row.title"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="aspect-4/3 w-full rounded-4xl object-cover"
|
||||
/>
|
||||
<VideoPlayer
|
||||
v-else
|
||||
:locale="locale"
|
||||
:aria-label="row.media.alt ?? row.title"
|
||||
:src="row.media.src"
|
||||
:poster="row.media.poster"
|
||||
:tracks="row.media.tracks"
|
||||
:autoplay="row.media.autoplay"
|
||||
:loop="row.media.loop"
|
||||
:minimal="row.media.minimal"
|
||||
:hide-controls="row.media.hideControls"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
import ProductHeroBadge from '../common/ProductHeroBadge.vue'
|
||||
@@ -27,6 +29,7 @@ const {
|
||||
badgeLogoAlt,
|
||||
title,
|
||||
titleHighlight,
|
||||
subtitle,
|
||||
features = [],
|
||||
primaryCta,
|
||||
secondaryCta,
|
||||
@@ -41,14 +44,17 @@ const {
|
||||
videoAutoplay = false,
|
||||
videoLoop = false,
|
||||
videoMinimal = false,
|
||||
videoHideControls = false
|
||||
videoHideControls = false,
|
||||
class: className
|
||||
} = defineProps<{
|
||||
locale?: Locale
|
||||
class?: HTMLAttributes['class']
|
||||
badgeText: string
|
||||
badgeLogoSrc?: string
|
||||
badgeLogoAlt?: string
|
||||
title: string
|
||||
titleHighlight?: string
|
||||
subtitle?: string
|
||||
features?: string[]
|
||||
primaryCta: Cta
|
||||
secondaryCta?: Cta
|
||||
@@ -72,7 +78,8 @@ const {
|
||||
:class="
|
||||
cn(
|
||||
'max-w-9xl relative mx-auto flex flex-col items-center gap-12 px-6 pt-20 pb-16 md:pt-28 md:pb-24 lg:items-center lg:gap-16 lg:px-16',
|
||||
imagePosition === 'right' ? 'lg:flex-row' : 'lg:flex-row-reverse'
|
||||
imagePosition === 'right' ? 'lg:flex-row' : 'lg:flex-row-reverse',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
@@ -84,7 +91,7 @@ const {
|
||||
/>
|
||||
|
||||
<h1
|
||||
class="mt-8 text-2xl leading-[125%] font-light tracking-[-1.44px] text-primary-comfy-canvas md:text-4xl lg:text-5xl"
|
||||
class="mt-8 text-2xl leading-[125%] font-light tracking-[-1.44px] whitespace-pre-line text-primary-comfy-canvas md:text-4xl lg:text-5xl"
|
||||
>
|
||||
<template v-if="titleHighlight">
|
||||
<span class="text-primary-warm-white">{{ titleHighlight }}</span>
|
||||
@@ -93,6 +100,13 @@ const {
|
||||
<template v-else>{{ title }}</template>
|
||||
</h1>
|
||||
|
||||
<p
|
||||
v-if="subtitle"
|
||||
class="mt-6 max-w-xl text-base text-primary-comfy-canvas/80"
|
||||
>
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
|
||||
<ul v-if="features.length" class="mt-8 space-y-3">
|
||||
<li
|
||||
v-for="feature in features"
|
||||
@@ -127,27 +141,29 @@ const {
|
||||
</div>
|
||||
|
||||
<div class="order-first w-full lg:order-last lg:flex-1">
|
||||
<VideoPlayer
|
||||
v-if="videoSrc"
|
||||
:locale
|
||||
:src="videoSrc"
|
||||
:poster="videoPoster"
|
||||
:tracks="videoTracks"
|
||||
:autoplay="videoAutoplay"
|
||||
:loop="videoLoop"
|
||||
:minimal="videoMinimal"
|
||||
:hide-controls="videoHideControls"
|
||||
/>
|
||||
<img
|
||||
v-else-if="imageSrc"
|
||||
:src="imageSrc"
|
||||
:alt="imageAlt"
|
||||
:width="imageWidth"
|
||||
:height="imageHeight"
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
class="aspect-4/3 w-full rounded-3xl object-cover"
|
||||
/>
|
||||
<slot name="media">
|
||||
<VideoPlayer
|
||||
v-if="videoSrc"
|
||||
:locale
|
||||
:src="videoSrc"
|
||||
:poster="videoPoster"
|
||||
:tracks="videoTracks"
|
||||
:autoplay="videoAutoplay"
|
||||
:loop="videoLoop"
|
||||
:minimal="videoMinimal"
|
||||
:hide-controls="videoHideControls"
|
||||
/>
|
||||
<img
|
||||
v-else-if="imageSrc"
|
||||
:src="imageSrc"
|
||||
:alt="imageAlt"
|
||||
:width="imageWidth"
|
||||
:height="imageHeight"
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
class="aspect-4/3 w-full rounded-3xl object-cover"
|
||||
/>
|
||||
</slot>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
59
apps/website/src/components/blocks/ReasonsSplit01.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
export interface Reason {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
const { highlightClass = 'text-white' } = defineProps<{
|
||||
heading: string
|
||||
headingHighlight?: string
|
||||
highlightClass?: string
|
||||
subtitle?: string
|
||||
reasons: readonly Reason[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="max-w-9xl mx-auto flex flex-col gap-4 px-6 py-16 lg:flex-row lg:gap-16 lg:py-24"
|
||||
>
|
||||
<!-- Left heading -->
|
||||
<div
|
||||
class="sticky top-20 z-10 w-full shrink-0 self-start bg-primary-comfy-ink py-4 lg:top-28 lg:w-115 lg:py-0"
|
||||
>
|
||||
<h2
|
||||
class="text-4xl/16 font-light whitespace-pre-line text-primary-comfy-canvas lg:text-5xl/16"
|
||||
>
|
||||
{{ heading
|
||||
}}<span v-if="headingHighlight" :class="highlightClass">{{
|
||||
headingHighlight
|
||||
}}</span>
|
||||
</h2>
|
||||
<p v-if="subtitle" class="mt-6 text-sm text-primary-comfy-canvas/70">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Right reasons list -->
|
||||
<div class="flex-1">
|
||||
<div
|
||||
v-for="reason in reasons"
|
||||
:key="reason.id"
|
||||
class="flex flex-col gap-4 border-b border-primary-comfy-canvas/20 py-10 first:pt-0 lg:gap-12 xl:flex-row"
|
||||
>
|
||||
<div class="shrink-0 xl:w-84">
|
||||
<h3
|
||||
class="text-2xl font-light whitespace-pre-line text-primary-comfy-canvas"
|
||||
>
|
||||
{{ reason.title }}
|
||||
</h3>
|
||||
<slot name="reason-extra" :reason="reason" />
|
||||
</div>
|
||||
<p class="flex-1 text-sm text-primary-comfy-canvas/70">
|
||||
{{ reason.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -7,12 +7,14 @@ const {
|
||||
label,
|
||||
headingTag = 'h2',
|
||||
maxWidth = 'lg',
|
||||
headingSize = 'section'
|
||||
headingSize = 'section',
|
||||
align = 'center'
|
||||
} = defineProps<{
|
||||
label?: string
|
||||
headingTag?: 'h1' | 'h2' | 'h3'
|
||||
maxWidth?: 'md' | 'lg' | 'xl'
|
||||
headingSize?: 'section' | 'hero'
|
||||
align?: 'center' | 'start'
|
||||
}>()
|
||||
|
||||
const maxWidthClass = {
|
||||
@@ -28,7 +30,14 @@ const headingSizeClass = {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('mx-auto text-center', maxWidthClass[maxWidth])">
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
maxWidthClass[maxWidth],
|
||||
align === 'center' ? 'mx-auto text-center' : 'text-left'
|
||||
)
|
||||
"
|
||||
>
|
||||
<SectionLabel v-if="label">{{ label }}</SectionLabel>
|
||||
<component
|
||||
:is="headingTag"
|
||||
|
||||
@@ -37,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 }
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
@@ -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/',
|
||||
|
||||
@@ -38,7 +38,7 @@ export const learningTutorials: readonly LearningTutorial[] = [
|
||||
label: 'English'
|
||||
}
|
||||
],
|
||||
// href: '#',
|
||||
href: 'https://comfy.org/workflows/8f2cf0df5da6-8f2cf0df5da6/',
|
||||
tags: [partnerNodesTag, imageToVideoTag]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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': '联系' },
|
||||
|
||||
24
apps/website/src/pages/mcp.astro
Normal 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>
|
||||
24
apps/website/src/pages/zh-CN/mcp.astro
Normal 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>
|
||||
@@ -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([*]);
|
||||
}
|
||||
|
||||
195
apps/website/src/templates/mcp/ComfyMcpDemo.vue
Normal 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_01–03.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>
|
||||
19
apps/website/src/templates/mcp/FAQSection.vue
Normal 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>
|
||||
27
apps/website/src/templates/mcp/HeroSection.vue
Normal 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>
|
||||
29
apps/website/src/templates/mcp/HowItWorksSection.vue
Normal 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>
|
||||
55
apps/website/src/templates/mcp/SetupSection.vue
Normal 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>
|
||||
66
apps/website/src/templates/mcp/ToolsSection.vue
Normal 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>
|
||||
26
apps/website/src/templates/mcp/WhySection.vue
Normal 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>
|
||||
27
apps/website/src/templates/mcp/ctas.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
144
browser_tests/tests/cloud/cancellationFlow.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
238
src/platform/cloud/churnkey/churnkeyClient.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
140
src/platform/cloud/churnkey/churnkeyClient.ts
Normal 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 }
|
||||
}
|
||||
199
src/platform/cloud/churnkey/embed-theme.css
Normal 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;
|
||||
}
|
||||
25
src/platform/cloud/churnkey/errors.ts
Normal 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'
|
||||
}
|
||||
}
|
||||
394
src/platform/cloud/churnkey/launchChurnkeyCancellation.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
143
src/platform/cloud/churnkey/launchChurnkeyCancellation.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
55
src/platform/cloud/churnkey/types.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
39
src/platform/cloud/subscription/launchCancellationFlow.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||