Compare commits

...

7 Commits

Author SHA1 Message Date
Glary-Bot
202288db36 fix(website): include cta labelKey in more_drops analytics payload
Brings More Drops in line with the hero CTAs, which already log a
'cta' identifier. Skipping the suggested 'card_id' field because it
would just duplicate the existing 'drop_id' on every event.
2026-06-04 01:33:50 +00:00
Glary-Bot
8ec1fa7da4 feat(website): drop OSS VRAM CTA pending June launch post
Holding off on linking the OSS VRAM card to the Mar 25 'Dynamic VRAM'
blog post — the June work is likely distinct from that publication.
Card now renders with title/tagline/body only and no CTA button.

Refactor: replaced the InternalDropItem/ExternalDropItem discriminated
union on DropItem with an optional cta?: DropCta object, where DropCta
itself is the internal-vs-external discriminator. This makes it trivial
to drop or restore CTAs per item without losing type safety, and
matches how the team will actually edit this page going forward.

Tests updated to cover the new shape: 'omits the CTA button for items
without a CTA (e.g. oss-vram pending blog post)' locks the regression.
2026-06-04 01:26:35 +00:00
Glary-Bot
193b013c25 fix(website): include href in secondary hero CTA analytics payload
Brings 'see_all_workflows' in line with every other drops_cta_clicked
event, which already log the resolved destination href.
2026-06-04 01:21:41 +00:00
Glary-Bot
7f676425c2 feat(website): wire /drops CTAs to live-demo + dynamic VRAM blog
- Hero 'EXPLORE WHAT'S NEW' now opens the 6/24 live-demo Luma event
- OSS VRAM CTA links to the Dynamic VRAM blog post on blog.comfy.org
- Desktop Client CTA stays on /download (already correct)
- MCP, CLI, and Team Plans cards share a 'REGISTER FOR THE LIVE DEMO'
  CTA pointing at the Luma event while real product/docs pages are
  not yet ready; old per-item CTA i18n keys removed
- 'WATCH THE DROPS' Fresh card now routes to the same Luma event
- Adds externalLinks.liveDemoEvent and externalLinks.blogDynamicVram so
  the URLs live in one place
2026-06-04 01:15:26 +00:00
Glary-Bot
2f394f018e fix(website): address coderabbit review on /drops
- dropItems: split into internal (routeKey) vs external (url) discriminated
  union; add resolveDropHref(item, routes) so internal CTAs go through
  getRoutes(locale) instead of hardcoded paths
- MoreDropsSection: use resolveDropHref(item, routes) and log the resolved
  href in the PostHog event
- useDropClickCapture: spread properties first so callers cannot override
  the explicit 'location' field
- dropsLanding.test: import TranslationKey type (drop 'as never'), add
  tests covering locale-aware internal resolution and external pass-through
2026-06-04 00:32:28 +00:00
Glary-Bot
46e4b5ecd1 fix(website): address review on /drops wireframe
- Hero permalink now routes to /drops (was /gallery) and shows comfy.org/drops
- Add 'drops' to LOCALE_INVARIANT_ROUTE_KEYS so getRoutes('zh-CN').drops
  stays /drops rather than the dead /zh-CN/drops
- Use cn() instead of :class=[] in MoreDropsSection (repo convention)
2026-06-04 00:17:57 +00:00
Glary-Bot
b16b29e969 feat(website): add /drops launches wireframe
Hardcoded marketing page at /drops mirroring the affiliates page pattern:
hero, five product drops (Desktop Client, OSS VRAM, MCP, CLI, Team Plans),
3-step get-started, Fresh from Comfy Create, and footer line. Wires up
PostHog 'drops_cta_clicked' on every CTA. Placeholder SVG art per card.
2026-06-04 00:10:18 +00:00
18 changed files with 958 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 270" fill="none">
<rect width="480" height="270" fill="#211927"/>
<text x="240" y="40" text-anchor="middle" font-family="sans-serif" font-size="14" font-weight="600" fill="#c2bfb9" letter-spacing="2">COMFY CLI</text>
<rect x="60" y="70" width="360" height="160" rx="8" fill="#000000" stroke="#f2ff59" stroke-width="2" opacity="0.9"/>
<g font-family="monospace" font-size="14" fill="#f2ff59">
<text x="76" y="100">$ comfy launch</text>
<text x="76" y="124" opacity="0.7">→ starting ComfyUI...</text>
<text x="76" y="148" opacity="0.7">→ http://127.0.0.1:8188</text>
<text x="76" y="172">$ comfy node install</text>
<text x="76" y="196" opacity="0.7">→ installed 3 nodes ✓</text>
<text x="76" y="220">$ _</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 821 B

View File

@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 270" fill="none">
<rect width="480" height="270" fill="#211927"/>
<text x="240" y="40" text-anchor="middle" font-family="sans-serif" font-size="14" font-weight="600" fill="#c2bfb9" letter-spacing="2">COMFY MCP</text>
<circle cx="240" cy="155" r="44" fill="#f2ff59"/>
<text x="240" y="165" text-anchor="middle" font-family="sans-serif" font-size="20" font-weight="800" fill="#211927">MCP</text>
<g stroke="#f2ff59" stroke-width="2" opacity="0.6">
<line x1="240" y1="155" x2="120" y2="100"/>
<line x1="240" y1="155" x2="120" y2="210"/>
<line x1="240" y1="155" x2="360" y2="100"/>
<line x1="240" y1="155" x2="360" y2="210"/>
</g>
<g fill="#f2ff59" opacity="0.4">
<circle cx="120" cy="100" r="14"/>
<circle cx="120" cy="210" r="14"/>
<circle cx="360" cy="100" r="14"/>
<circle cx="360" cy="210" r="14"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 917 B

View File

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 270" fill="none">
<rect width="480" height="270" fill="#211927"/>
<rect x="80" y="55" width="320" height="200" rx="12" fill="#f2ff59" opacity="0.08" stroke="#f2ff59" stroke-width="2"/>
<rect x="80" y="55" width="320" height="28" rx="12" fill="#f2ff59" opacity="0.15"/>
<circle cx="96" cy="69" r="4" fill="#f2ff59"/>
<circle cx="110" cy="69" r="4" fill="#f2ff59" opacity="0.6"/>
<circle cx="124" cy="69" r="4" fill="#f2ff59" opacity="0.4"/>
<rect x="104" y="110" width="100" height="60" rx="8" fill="#f2ff59" opacity="0.25"/>
<rect x="220" y="110" width="100" height="60" rx="8" fill="#f2ff59" opacity="0.4"/>
<rect x="104" y="185" width="216" height="40" rx="8" fill="#f2ff59" opacity="0.15"/>
<text x="240" y="42" text-anchor="middle" font-family="sans-serif" font-size="14" font-weight="600" fill="#c2bfb9" letter-spacing="2">DESKTOP CLIENT</text>
</svg>

After

Width:  |  Height:  |  Size: 934 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 270" fill="none">
<rect width="480" height="270" fill="#211927"/>
<text x="240" y="40" text-anchor="middle" font-family="sans-serif" font-size="14" font-weight="600" fill="#c2bfb9" letter-spacing="2">COMFY CREATE 2026</text>
<rect x="80" y="70" width="320" height="170" rx="12" fill="#f2ff59" opacity="0.08" stroke="#f2ff59" stroke-width="2"/>
<polygon points="220,130 220,200 290,165" fill="#f2ff59"/>
<circle cx="255" cy="165" r="60" fill="none" stroke="#f2ff59" stroke-width="2" opacity="0.4"/>
</svg>

After

Width:  |  Height:  |  Size: 572 B

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 270" fill="none">
<rect width="480" height="270" fill="#211927"/>
<text x="240" y="40" text-anchor="middle" font-family="sans-serif" font-size="14" font-weight="600" fill="#c2bfb9" letter-spacing="2">COMFY ORIGINALS</text>
<rect x="120" y="80" width="240" height="160" rx="8" fill="#f2ff59" opacity="0.15" stroke="#f2ff59" stroke-width="2"/>
<path d="M180 130 L240 90 L300 130 L300 200 L180 200 Z" fill="#f2ff59" opacity="0.3"/>
<circle cx="240" cy="160" r="22" fill="#f2ff59"/>
<text x="240" y="167" text-anchor="middle" font-family="sans-serif" font-size="16" font-weight="800" fill="#211927">C</text>
</svg>

After

Width:  |  Height:  |  Size: 680 B

View File

@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 270" fill="none">
<rect width="480" height="270" fill="#211927"/>
<text x="240" y="40" text-anchor="middle" font-family="sans-serif" font-size="14" font-weight="600" fill="#c2bfb9" letter-spacing="2">VRAM IMPROVEMENTS</text>
<g transform="translate(80, 80)">
<rect width="320" height="24" rx="4" fill="#f2ff59" opacity="0.1"/>
<rect width="280" height="24" rx="4" fill="#f2ff59" opacity="0.4"/>
<text x="160" y="17" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#211927" font-weight="700">BEFORE: 22GB / 24GB</text>
</g>
<g transform="translate(80, 130)">
<rect width="320" height="24" rx="4" fill="#f2ff59" opacity="0.1"/>
<rect width="180" height="24" rx="4" fill="#f2ff59"/>
<text x="160" y="17" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#211927" font-weight="700">AFTER: 14GB / 24GB</text>
</g>
<text x="240" y="210" text-anchor="middle" font-family="monospace" font-size="32" font-weight="700" fill="#f2ff59">-36%</text>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 270" fill="none">
<rect width="480" height="270" fill="#211927"/>
<text x="240" y="40" text-anchor="middle" font-family="sans-serif" font-size="14" font-weight="600" fill="#c2bfb9" letter-spacing="2">TEAM PLANS</text>
<g transform="translate(80, 85)">
<circle cx="40" cy="40" r="32" fill="#f2ff59" opacity="0.9"/>
<circle cx="110" cy="40" r="32" fill="#f2ff59" opacity="0.7"/>
<circle cx="180" cy="40" r="32" fill="#f2ff59" opacity="0.5"/>
<circle cx="250" cy="40" r="32" fill="#f2ff59" opacity="0.3"/>
<circle cx="320" cy="40" r="32" fill="#c2bfb9" opacity="0.2" stroke="#c2bfb9" stroke-width="2" stroke-dasharray="4 4"/>
<text x="320" y="46" text-anchor="middle" font-family="sans-serif" font-size="20" font-weight="700" fill="#c2bfb9">+</text>
</g>
<text x="240" y="220" text-anchor="middle" font-family="sans-serif" font-size="18" font-weight="300" fill="#c2bfb9">Shared workspace • Pooled credits</text>
</svg>

After

Width:  |  Height:  |  Size: 1009 B

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section
class="bg-secondary-mauve/30 mt-12 border-t border-primary-comfy-canvas/10 px-6 py-24 text-center md:py-32"
data-testid="drops-footer-cta"
>
<h2
class="mx-auto max-w-5xl text-4xl/tight font-light tracking-tight uppercase md:text-6xl/tight lg:text-7xl/tight"
>
<span class="block text-primary-comfy-canvas">
{{ t('drops-landing.footerCta.line1', locale) }}
</span>
<span class="text-primary-comfy-yellow block font-bold">
{{ t('drops-landing.footerCta.line2', locale) }}
</span>
</h2>
</section>
</template>

View File

@@ -0,0 +1,106 @@
<script setup lang="ts">
import type { Locale, TranslationKey } from '../../i18n/translations'
import { externalLinks } from '../../config/routes'
import { t } from '../../i18n/translations'
import BrandButton from '../common/BrandButton.vue'
import { captureDropClick } from './useDropClickCapture'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
interface FreshCard {
id: string
titleKey: TranslationKey
bodyKey: TranslationKey
ctaKey: TranslationKey
href: string
imageUrl: string
}
const cards: FreshCard[] = [
{
id: 'all-latest-drops',
titleKey: 'drops-landing.fresh.card1.title',
bodyKey: 'drops-landing.fresh.card1.body',
ctaKey: 'drops-landing.fresh.card1.cta',
href: externalLinks.liveDemoEvent,
imageUrl: '/drops/fresh-drops.svg'
},
{
id: 'comfy-originals',
titleKey: 'drops-landing.fresh.card2.title',
bodyKey: 'drops-landing.fresh.card2.body',
ctaKey: 'drops-landing.fresh.card2.cta',
href: externalLinks.blog,
imageUrl: '/drops/fresh-originals.svg'
}
]
</script>
<template>
<section
class="border-t border-primary-comfy-canvas/10 px-6 py-20 md:px-20 md:py-28"
data-testid="drops-fresh"
>
<div class="mx-auto max-w-6xl text-center">
<p
class="text-sm tracking-widest text-primary-comfy-canvas/60 uppercase md:text-base"
>
{{ t('drops-landing.fresh.eyebrow', locale) }}
</p>
<h2
class="text-primary-comfy-yellow mt-2 text-4xl font-bold tracking-tight uppercase md:text-6xl"
>
{{ t('drops-landing.fresh.headingAccent', locale) }}
</h2>
</div>
<ul class="mx-auto mt-16 grid max-w-6xl grid-cols-1 gap-8 md:grid-cols-2">
<li
v-for="card in cards"
:key="card.id"
class="bg-transparency-white-t4 flex flex-col overflow-hidden rounded-4xl border border-primary-comfy-canvas/10"
:data-testid="`drops-fresh-${card.id}`"
>
<div
class="flex aspect-video items-center justify-center overflow-hidden bg-primary-comfy-ink/40 p-6"
>
<img
:src="card.imageUrl"
:alt="t(card.titleKey, locale)"
class="max-h-full max-w-full object-contain"
loading="lazy"
decoding="async"
/>
</div>
<div class="flex flex-1 flex-col gap-4 p-8">
<h3 class="text-2xl font-light text-primary-comfy-canvas md:text-3xl">
{{ t(card.titleKey, locale) }}
</h3>
<p class="text-base text-primary-comfy-canvas/70">
{{ t(card.bodyKey, locale) }}
</p>
<div class="mt-auto pt-4">
<BrandButton
:href="card.href"
target="_blank"
rel="noopener noreferrer"
variant="outline"
size="md"
:data-testid="`drops-fresh-${card.id}-cta`"
class="px-6 py-3 text-sm"
@click="
captureDropClick('fresh', {
card_id: card.id,
href: card.href
})
"
>
{{ t(card.ctaKey, locale) }}
</BrandButton>
</div>
</div>
</li>
</ul>
</section>
</template>

View File

@@ -0,0 +1,75 @@
<script setup lang="ts">
import type { Locale, TranslationKey } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
interface Step {
number: string
titleKey: TranslationKey
bodyKey: TranslationKey
}
const steps: Step[] = [
{
number: '1',
titleKey: 'drops-landing.getStarted.step.1.title',
bodyKey: 'drops-landing.getStarted.step.1.body'
},
{
number: '2',
titleKey: 'drops-landing.getStarted.step.2.title',
bodyKey: 'drops-landing.getStarted.step.2.body'
},
{
number: '3',
titleKey: 'drops-landing.getStarted.step.3.title',
bodyKey: 'drops-landing.getStarted.step.3.body'
}
]
</script>
<template>
<section
class="border-t border-primary-comfy-canvas/10 px-6 py-20 md:px-20 md:py-28"
data-testid="drops-get-started"
>
<div class="mx-auto max-w-6xl text-center">
<p
class="text-sm tracking-widest text-primary-comfy-canvas/60 uppercase md:text-base"
>
{{ t('drops-landing.getStarted.eyebrow', locale) }}
</p>
<h2
class="text-primary-comfy-yellow mt-2 text-4xl font-bold tracking-tight uppercase md:text-6xl"
>
{{ t('drops-landing.getStarted.headingAccent', locale) }}
</h2>
</div>
<ol
class="mx-auto mt-16 grid max-w-6xl grid-cols-1 gap-6 md:grid-cols-3 md:gap-8"
>
<li
v-for="step in steps"
:key="step.number"
class="bg-transparency-white-t4 flex flex-col gap-4 rounded-4xl border border-primary-comfy-canvas/10 p-8"
:data-testid="`drops-step-${step.number}`"
>
<span
class="text-primary-comfy-yellow font-mono text-5xl font-light md:text-6xl"
aria-hidden="true"
>
{{ step.number }}
</span>
<h3 class="text-2xl font-light text-primary-comfy-canvas">
{{ t(step.titleKey, locale) }}
</h3>
<p class="text-base text-primary-comfy-canvas/70">
{{ t(step.bodyKey, locale) }}
</p>
</li>
</ol>
</section>
</template>

View File

@@ -0,0 +1,82 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { externalLinks, getRoutes } from '../../config/routes'
import { t } from '../../i18n/translations'
import BrandButton from '../common/BrandButton.vue'
import { captureDropClick } from './useDropClickCapture'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const routes = getRoutes(locale)
const dropsUrl = `comfy.org${routes.drops}`
</script>
<template>
<section
class="max-w-9xl mx-auto px-6 pt-16 pb-12 text-center lg:px-20 lg:pt-28 lg:pb-20"
data-testid="drops-hero"
>
<h1
class="mx-auto max-w-5xl text-5xl/none font-light tracking-tight text-primary-comfy-canvas uppercase md:text-7xl/none lg:text-8xl/none"
>
<span class="block">{{ t('drops-landing.hero.eyebrow', locale) }}</span>
<span class="text-primary-comfy-yellow block font-bold">
{{ t('drops-landing.hero.headingAccent', locale) }}
</span>
</h1>
<p
class="mx-auto mt-10 max-w-2xl text-lg text-primary-comfy-canvas md:text-xl"
>
{{ t('drops-landing.hero.body1', locale) }}
</p>
<p
class="mx-auto mt-4 max-w-2xl text-base text-primary-comfy-canvas/80 md:text-lg"
>
{{ t('drops-landing.hero.body2', locale) }}
</p>
<div
class="mt-10 flex flex-col items-center justify-center gap-4 sm:flex-row"
>
<BrandButton
:href="externalLinks.liveDemoEvent"
target="_blank"
rel="noopener noreferrer"
size="lg"
data-testid="drops-hero-primary-cta"
class="px-8 py-4 text-base"
@click="
captureDropClick('hero', {
cta: 'explore_whats_new',
href: externalLinks.liveDemoEvent
})
"
>
{{ t('drops-landing.hero.primaryCta', locale) }}
</BrandButton>
<BrandButton
:href="externalLinks.workflows"
target="_blank"
rel="noopener noreferrer"
variant="outline"
size="lg"
data-testid="drops-hero-secondary-cta"
class="px-8 py-4 text-base"
@click="
captureDropClick('hero', {
cta: 'see_all_workflows',
href: externalLinks.workflows
})
"
>
{{ t('drops-landing.hero.secondaryCta', locale) }}
</BrandButton>
</div>
<a
:href="routes.drops"
class="mt-8 inline-block text-xs tracking-widest text-primary-comfy-canvas/40 uppercase"
>
{{ dropsUrl }}
</a>
</section>
</template>

View File

@@ -0,0 +1,102 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { DropCta, DropItem } from './dropItems'
import type { Locale } from '../../i18n/translations'
import { getRoutes } from '../../config/routes'
import { t } from '../../i18n/translations'
import BrandButton from '../common/BrandButton.vue'
import { dropItems, resolveCtaHref } from './dropItems'
import { captureDropClick } from './useDropClickCapture'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const routes = getRoutes(locale)
function hrefFor(cta: DropCta): string {
return resolveCtaHref(cta, routes)
}
function handleCtaClick(item: DropItem, cta: DropCta) {
captureDropClick('more_drops', {
drop_id: item.id,
cta: cta.labelKey,
href: hrefFor(cta),
external: cta.external
})
}
</script>
<template>
<section
id="more-drops"
class="border-t border-primary-comfy-canvas/10 px-6 py-20 md:px-20 md:py-28"
data-testid="drops-more-drops"
>
<div class="mx-auto max-w-6xl text-center">
<p
class="text-sm tracking-widest text-primary-comfy-canvas/60 uppercase md:text-base"
>
{{ t('drops-landing.moreDrops.eyebrow', locale) }}
</p>
<h2
class="text-primary-comfy-yellow mt-2 text-4xl font-bold tracking-tight uppercase md:text-6xl"
>
{{ t('drops-landing.moreDrops.headingAccent', locale) }}
</h2>
</div>
<ul class="mx-auto mt-16 flex max-w-6xl flex-col gap-8 md:gap-12">
<li
v-for="(item, index) in dropItems"
:key="item.id"
class="bg-transparency-white-t4 grid grid-cols-1 gap-8 overflow-hidden rounded-4xl border border-primary-comfy-canvas/10 md:gap-12 lg:grid-cols-5"
:data-testid="`drops-item-${item.id}`"
>
<div
:class="
cn(
'flex aspect-video items-center justify-center overflow-hidden bg-primary-comfy-ink/40 p-8 lg:col-span-2 lg:aspect-auto',
index % 2 === 1 && 'lg:order-2'
)
"
>
<img
:src="item.imageUrl"
:alt="t(item.titleKey, locale)"
class="max-h-72 max-w-full object-contain"
loading="lazy"
decoding="async"
/>
</div>
<div
class="flex flex-col justify-center gap-5 p-8 md:p-10 lg:col-span-3"
>
<h3 class="text-3xl font-light text-primary-comfy-canvas md:text-4xl">
{{ t(item.titleKey, locale) }}
</h3>
<p class="text-primary-comfy-yellow text-lg font-light md:text-xl">
{{ t(item.taglineKey, locale) }}
</p>
<p class="text-base text-primary-comfy-canvas/70">
{{ t(item.bodyKey, locale) }}
</p>
<div v-if="item.cta" class="mt-2">
<BrandButton
:href="hrefFor(item.cta)"
:target="item.cta.external ? '_blank' : undefined"
:rel="item.cta.external ? 'noopener noreferrer' : undefined"
size="md"
:data-testid="`drops-item-${item.id}-cta`"
class="px-6 py-3 text-sm"
@click="handleCtaClick(item, item.cta)"
>
{{ t(item.cta.labelKey, locale) }}
</BrandButton>
</div>
</div>
</li>
</ul>
</section>
</template>

View File

@@ -0,0 +1,83 @@
import type { getRoutes } from '../../config/routes'
import type { TranslationKey } from '../../i18n/translations'
type Routes = ReturnType<typeof getRoutes>
interface InternalCta {
labelKey: TranslationKey
external: false
routeKey: keyof Routes
}
interface ExternalCta {
labelKey: TranslationKey
external: true
url: string
}
export type DropCta = InternalCta | ExternalCta
export interface DropItem {
id: string
titleKey: TranslationKey
taglineKey: TranslationKey
bodyKey: TranslationKey
imageUrl: string
cta?: DropCta
}
const REGISTER_FOR_LIVE_DEMO_CTA: ExternalCta = {
labelKey: 'drops-landing.item.register-for-live-demo.cta',
external: true,
url: 'https://luma.com/l7c5z4gp?tk=nm3dWZ'
}
export const dropItems: DropItem[] = [
{
id: 'desktop-client',
titleKey: 'drops-landing.item.desktop-client.title',
taglineKey: 'drops-landing.item.desktop-client.tagline',
bodyKey: 'drops-landing.item.desktop-client.body',
imageUrl: '/drops/desktop-client.svg',
cta: {
labelKey: 'drops-landing.item.desktop-client.cta',
external: false,
routeKey: 'download'
}
},
{
id: 'oss-vram',
titleKey: 'drops-landing.item.oss-vram.title',
taglineKey: 'drops-landing.item.oss-vram.tagline',
bodyKey: 'drops-landing.item.oss-vram.body',
imageUrl: '/drops/oss-vram.svg'
},
{
id: 'comfy-mcp',
titleKey: 'drops-landing.item.comfy-mcp.title',
taglineKey: 'drops-landing.item.comfy-mcp.tagline',
bodyKey: 'drops-landing.item.comfy-mcp.body',
imageUrl: '/drops/comfy-mcp.svg',
cta: REGISTER_FOR_LIVE_DEMO_CTA
},
{
id: 'comfy-cli',
titleKey: 'drops-landing.item.comfy-cli.title',
taglineKey: 'drops-landing.item.comfy-cli.tagline',
bodyKey: 'drops-landing.item.comfy-cli.body',
imageUrl: '/drops/comfy-cli.svg',
cta: REGISTER_FOR_LIVE_DEMO_CTA
},
{
id: 'team-plans',
titleKey: 'drops-landing.item.team-plans.title',
taglineKey: 'drops-landing.item.team-plans.tagline',
bodyKey: 'drops-landing.item.team-plans.body',
imageUrl: '/drops/team-plans.svg',
cta: REGISTER_FOR_LIVE_DEMO_CTA
}
]
export function resolveCtaHref(cta: DropCta, routes: Routes): string {
return cta.external ? cta.url : routes[cta.routeKey]
}

View File

@@ -0,0 +1,157 @@
import { describe, expect, it } from 'vitest'
import type { TranslationKey } from '../../i18n/translations'
import { getRoutes } from '../../config/routes'
import { hasKey, t, translationKeys } from '../../i18n/translations'
import { dropItems, resolveCtaHref } from './dropItems'
const PREFIX = 'drops-landing'
const EXPECTED_SECTION_PREFIXES = [
'page',
'hero',
'moreDrops',
'item',
'getStarted',
'fresh',
'footerCta'
] as const
const STEP_COUNT = 3
const FRESH_CARD_COUNT = 2
const INTERNAL_KEY_PATTERNS = [
/open-questions/,
/todo/i,
/draft/i,
/placeholder/i,
/internal/i
]
function dropsKeys(): string[] {
return translationKeys.filter((k) => k.startsWith(`${PREFIX}.`))
}
describe('drops landing i18n', () => {
it('exposes the canonical top-level section prefixes', () => {
const keys = dropsKeys()
for (const section of EXPECTED_SECTION_PREFIXES) {
const hit = keys.some((k) => k.startsWith(`${PREFIX}.${section}.`))
expect(hit, `missing section: ${section}`).toBe(true)
}
})
it('exposes page, hero, and section headings', () => {
expect(hasKey(`${PREFIX}.page.title`)).toBe(true)
expect(hasKey(`${PREFIX}.page.description`)).toBe(true)
expect(hasKey(`${PREFIX}.hero.eyebrow`)).toBe(true)
expect(hasKey(`${PREFIX}.hero.headingAccent`)).toBe(true)
expect(hasKey(`${PREFIX}.hero.body1`)).toBe(true)
expect(hasKey(`${PREFIX}.hero.body2`)).toBe(true)
expect(hasKey(`${PREFIX}.hero.primaryCta`)).toBe(true)
expect(hasKey(`${PREFIX}.hero.secondaryCta`)).toBe(true)
expect(hasKey(`${PREFIX}.moreDrops.eyebrow`)).toBe(true)
expect(hasKey(`${PREFIX}.moreDrops.headingAccent`)).toBe(true)
expect(hasKey(`${PREFIX}.getStarted.eyebrow`)).toBe(true)
expect(hasKey(`${PREFIX}.getStarted.headingAccent`)).toBe(true)
expect(hasKey(`${PREFIX}.fresh.eyebrow`)).toBe(true)
expect(hasKey(`${PREFIX}.fresh.headingAccent`)).toBe(true)
expect(hasKey(`${PREFIX}.footerCta.line1`)).toBe(true)
expect(hasKey(`${PREFIX}.footerCta.line2`)).toBe(true)
})
it('matches every drop item to title, tagline, and body translation keys', () => {
for (const item of dropItems) {
expect(hasKey(item.titleKey)).toBe(true)
expect(hasKey(item.taglineKey)).toBe(true)
expect(hasKey(item.bodyKey)).toBe(true)
}
})
it('matches every CTA label to a translation key', () => {
for (const item of dropItems) {
if (!item.cta) continue
expect(hasKey(item.cta.labelKey)).toBe(true)
}
})
it('exposes the get-started step copy', () => {
for (let n = 1; n <= STEP_COUNT; n++) {
expect(hasKey(`${PREFIX}.getStarted.step.${n}.title`)).toBe(true)
expect(hasKey(`${PREFIX}.getStarted.step.${n}.body`)).toBe(true)
}
})
it('exposes the fresh-from-comfy-create card copy', () => {
for (let n = 1; n <= FRESH_CARD_COUNT; n++) {
expect(hasKey(`${PREFIX}.fresh.card${n}.title`)).toBe(true)
expect(hasKey(`${PREFIX}.fresh.card${n}.body`)).toBe(true)
expect(hasKey(`${PREFIX}.fresh.card${n}.cta`)).toBe(true)
}
})
it('returns non-empty english copy for every drops-landing key', () => {
for (const key of dropsKeys()) {
expect(t(key as TranslationKey, 'en').trim().length).toBeGreaterThan(0)
}
})
it('does not leak internal-only keys (drafts, todos, open questions)', () => {
const leaks = dropsKeys().filter((k) =>
INTERNAL_KEY_PATTERNS.some((re) => re.test(k))
)
expect(leaks).toEqual([])
})
it('uses unique drop item ids', () => {
const ids = dropItems.map((item) => item.id)
expect(new Set(ids).size).toBe(ids.length)
})
it('has a non-empty image url and resolvable href for every drop item with a CTA', () => {
const routes = getRoutes('en')
for (const item of dropItems) {
expect(item.imageUrl.length).toBeGreaterThan(0)
if (!item.cta) continue
expect(resolveCtaHref(item.cta, routes).length).toBeGreaterThan(0)
}
})
it('resolves internal CTA hrefs through the locale-aware route helper', () => {
const internalCtas = dropItems
.map((item) => item.cta)
.filter(
(cta): cta is NonNullable<typeof cta> & { external: false } =>
cta !== undefined && !cta.external
)
expect(internalCtas.length).toBeGreaterThan(0)
const enRoutes = getRoutes('en')
const zhRoutes = getRoutes('zh-CN')
for (const cta of internalCtas) {
expect(resolveCtaHref(cta, enRoutes)).toBe(enRoutes[cta.routeKey])
expect(resolveCtaHref(cta, zhRoutes)).toBe(zhRoutes[cta.routeKey])
}
})
it('preserves external CTA hrefs regardless of locale', () => {
const externalCtas = dropItems
.map((item) => item.cta)
.filter(
(cta): cta is NonNullable<typeof cta> & { external: true } =>
cta !== undefined && cta.external
)
expect(externalCtas.length).toBeGreaterThan(0)
const enRoutes = getRoutes('en')
const zhRoutes = getRoutes('zh-CN')
for (const cta of externalCtas) {
expect(resolveCtaHref(cta, enRoutes)).toBe(cta.url)
expect(resolveCtaHref(cta, zhRoutes)).toBe(cta.url)
}
})
it('omits the CTA button for items without a CTA (e.g. oss-vram pending blog post)', () => {
const itemsWithoutCta = dropItems.filter((item) => !item.cta)
expect(itemsWithoutCta.map((item) => item.id)).toContain('oss-vram')
})
})

View File

@@ -0,0 +1,12 @@
import posthog from 'posthog-js'
export function captureDropClick(
location: string,
properties: Record<string, unknown> = {}
): void {
try {
posthog.capture('drops_cta_clicked', { ...properties, location })
} catch (error) {
console.error('PostHog capture failed', error)
}
}

View File

@@ -12,6 +12,7 @@ const baseRoutes = {
careers: '/careers',
customers: '/customers',
demos: '/demos',
drops: '/drops',
termsOfService: '/terms-of-service',
privacyPolicy: '/privacy-policy',
affiliates: '/affiliates',
@@ -35,6 +36,7 @@ type Routes = typeof baseRoutes
const LOCALE_INVARIANT_ROUTE_KEYS = new Set<keyof Routes>([
'affiliates',
'affiliateTerms',
'drops',
'termsOfService'
])
@@ -61,6 +63,7 @@ export const externalLinks = {
docsSubscription: 'https://docs.comfy.org/support/subscription/subscribing',
github: 'https://github.com/Comfy-Org/ComfyUI',
githubInstall: 'https://github.com/Comfy-Org/ComfyUI#installing',
liveDemoEvent: 'https://luma.com/l7c5z4gp?tk=nm3dWZ',
platform: 'https://platform.comfy.org',
platformUsage: 'https://platform.comfy.org/profile/usage',
support: 'https://support.comfy.org/hc/en-us',

View File

@@ -4731,6 +4731,210 @@ const translations = {
'affiliate-landing.footerCta.termsLink': {
en: 'Read the affiliate program terms',
'zh-CN': 'Read the affiliate program terms'
},
// Drops landing page (/drops)
// English-only copy; zh-CN values mirror en until a localized version lands.
'drops-landing.page.title': {
en: 'Discover Our Latest Drops — Comfy',
'zh-CN': 'Discover Our Latest Drops — Comfy'
},
'drops-landing.page.description': {
en: 'Every model, every feature, every update — visible, inspectable, and ready to run. Build with the latest. Ship faster than any one company could alone.',
'zh-CN':
'Every model, every feature, every update — visible, inspectable, and ready to run. Build with the latest. Ship faster than any one company could alone.'
},
// Hero
'drops-landing.hero.eyebrow': {
en: "WHAT'S",
'zh-CN': "WHAT'S"
},
'drops-landing.hero.headingAccent': {
en: 'NEW',
'zh-CN': 'NEW'
},
'drops-landing.hero.body1': {
en: 'Every model, every feature, every update — visible, inspectable, and ready to run.',
'zh-CN':
'Every model, every feature, every update — visible, inspectable, and ready to run.'
},
'drops-landing.hero.body2': {
en: 'Build with the latest. Ship faster than any one company could alone.',
'zh-CN':
'Build with the latest. Ship faster than any one company could alone.'
},
'drops-landing.hero.primaryCta': {
en: "EXPLORE WHAT'S NEW",
'zh-CN': "EXPLORE WHAT'S NEW"
},
'drops-landing.hero.secondaryCta': {
en: 'SEE ALL WORKFLOWS',
'zh-CN': 'SEE ALL WORKFLOWS'
},
// More Drops section
'drops-landing.moreDrops.eyebrow': {
en: 'MORE DROPS FROM',
'zh-CN': 'MORE DROPS FROM'
},
'drops-landing.moreDrops.headingAccent': {
en: 'COMFY CREATE',
'zh-CN': 'COMFY CREATE'
},
// Drop items (5 of them)
'drops-landing.item.desktop-client.title': {
en: 'New Desktop Client',
'zh-CN': 'New Desktop Client'
},
'drops-landing.item.desktop-client.tagline': {
en: 'A native desktop experience built for the way you actually work.',
'zh-CN': 'A native desktop experience built for the way you actually work.'
},
'drops-landing.item.desktop-client.body': {
en: 'Every node, every model — faster, tighter, and always local.',
'zh-CN': 'Every node, every model — faster, tighter, and always local.'
},
'drops-landing.item.desktop-client.cta': {
en: 'TRY THE DESKTOP CLIENT',
'zh-CN': 'TRY THE DESKTOP CLIENT'
},
'drops-landing.item.oss-vram.title': {
en: 'OSS VRAM Improvements',
'zh-CN': 'OSS VRAM Improvements'
},
'drops-landing.item.oss-vram.tagline': {
en: 'Push more through your hardware.',
'zh-CN': 'Push more through your hardware.'
},
'drops-landing.item.oss-vram.body': {
en: 'Significant open-source VRAM optimizations so you can run larger models, deeper pipelines, and more complex workflows — without hitting the ceiling.',
'zh-CN':
'Significant open-source VRAM optimizations so you can run larger models, deeper pipelines, and more complex workflows — without hitting the ceiling.'
},
'drops-landing.item.comfy-mcp.title': {
en: 'Comfy MCP',
'zh-CN': 'Comfy MCP'
},
'drops-landing.item.comfy-mcp.tagline': {
en: 'Connect Comfy to any tool that speaks MCP.',
'zh-CN': 'Connect Comfy to any tool that speaks MCP.'
},
'drops-landing.item.comfy-mcp.body': {
en: 'Build pipelines that talk directly to your stack. Every connection is a node. Every parameter is yours to control.',
'zh-CN':
'Build pipelines that talk directly to your stack. Every connection is a node. Every parameter is yours to control.'
},
'drops-landing.item.comfy-cli.title': {
en: 'Comfy CLI',
'zh-CN': 'Comfy CLI'
},
'drops-landing.item.comfy-cli.tagline': {
en: 'Run, manage, and automate your workflows from the terminal.',
'zh-CN': 'Run, manage, and automate your workflows from the terminal.'
},
'drops-landing.item.comfy-cli.body': {
en: 'Full pipeline control without the GUI. Scriptable, composable, and built for teams who move fast.',
'zh-CN':
'Full pipeline control without the GUI. Scriptable, composable, and built for teams who move fast.'
},
'drops-landing.item.team-plans.title': {
en: 'Comfy Team Plans',
'zh-CN': 'Comfy Team Plans'
},
'drops-landing.item.team-plans.tagline': {
en: 'Professional-grade infrastructure for teams who build together.',
'zh-CN': 'Professional-grade infrastructure for teams who build together.'
},
'drops-landing.item.team-plans.body': {
en: 'Shared workflows, collaborative node graphs, and enterprise-ready controls — all in one place.',
'zh-CN':
'Shared workflows, collaborative node graphs, and enterprise-ready controls — all in one place.'
},
'drops-landing.item.register-for-live-demo.cta': {
en: 'REGISTER FOR THE LIVE DEMO',
'zh-CN': 'REGISTER FOR THE LIVE DEMO'
},
// Get Started in 3 Steps section
'drops-landing.getStarted.eyebrow': {
en: 'GET STARTED IN',
'zh-CN': 'GET STARTED IN'
},
'drops-landing.getStarted.headingAccent': {
en: '3 STEPS',
'zh-CN': '3 STEPS'
},
'drops-landing.getStarted.step.1.title': {
en: 'Download or Sign up',
'zh-CN': 'Download or Sign up'
},
'drops-landing.getStarted.step.1.body': {
en: 'Download Local or launch Cloud.',
'zh-CN': 'Download Local or launch Cloud.'
},
'drops-landing.getStarted.step.2.title': {
en: 'Load a workflow',
'zh-CN': 'Load a workflow'
},
'drops-landing.getStarted.step.2.body': {
en: 'Start from a community template or build your own from scratch.',
'zh-CN': 'Start from a community template or build your own from scratch.'
},
'drops-landing.getStarted.step.3.title': {
en: 'Generate & Iterate',
'zh-CN': 'Generate & Iterate'
},
'drops-landing.getStarted.step.3.body': {
en: "Run, adjust, and scale when you're ready.",
'zh-CN': "Run, adjust, and scale when you're ready."
},
// Fresh From Comfy Create section
'drops-landing.fresh.eyebrow': {
en: 'FRESH FROM',
'zh-CN': 'FRESH FROM'
},
'drops-landing.fresh.headingAccent': {
en: 'COMFY CREATE',
'zh-CN': 'COMFY CREATE'
},
'drops-landing.fresh.card1.title': {
en: 'All the latest drops',
'zh-CN': 'All the latest drops'
},
'drops-landing.fresh.card1.body': {
en: 'Catch up on every new node, model, and feature from Comfy Create 2026.',
'zh-CN':
'Catch up on every new node, model, and feature from Comfy Create 2026.'
},
'drops-landing.fresh.card1.cta': {
en: 'WATCH THE DROPS',
'zh-CN': 'WATCH THE DROPS'
},
'drops-landing.fresh.card2.title': {
en: 'From Comfy, to your world',
'zh-CN': 'From Comfy, to your world'
},
'drops-landing.fresh.card2.body': {
en: 'The workflows you love — now wearable, collectible, and designed for everyday inspiration.',
'zh-CN':
'The workflows you love — now wearable, collectible, and designed for everyday inspiration.'
},
'drops-landing.fresh.card2.cta': {
en: 'SHOP COMFY ORIGINALS',
'zh-CN': 'SHOP COMFY ORIGINALS'
},
// Footer CTA
'drops-landing.footerCta.line1': {
en: 'THE MOST POWERFUL',
'zh-CN': 'THE MOST POWERFUL'
},
'drops-landing.footerCta.line2': {
en: 'WORKFLOW ENGINE FOR VISUAL AI.',
'zh-CN': 'WORKFLOW ENGINE FOR VISUAL AI.'
}
} as const satisfies Record<string, Record<Locale, string>>

View File

@@ -0,0 +1,23 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import FooterCtaSection from '../../components/drops/FooterCtaSection.vue'
import FreshFromComfyCreateSection from '../../components/drops/FreshFromComfyCreateSection.vue'
import GetStartedSection from '../../components/drops/GetStartedSection.vue'
import HeroSection from '../../components/drops/HeroSection.vue'
import MoreDropsSection from '../../components/drops/MoreDropsSection.vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const locale: Locale = Astro.currentLocale === 'zh-CN' ? 'zh-CN' : 'en'
---
<BaseLayout
title={t('drops-landing.page.title', locale)}
description={t('drops-landing.page.description', locale)}
>
<HeroSection locale={locale} client:load />
<MoreDropsSection locale={locale} client:load />
<GetStartedSection locale={locale} />
<FreshFromComfyCreateSection locale={locale} client:load />
<FooterCtaSection locale={locale} />
</BaseLayout>