mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-17 17:28:58 +00:00
Compare commits
3 Commits
feat/creat
...
updates_to
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8b0c0b58b | ||
|
|
d1d55585f9 | ||
|
|
98c654df20 |
@@ -4,9 +4,9 @@ import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
const TOP_LEVEL_LABELS = [
|
||||
'Products',
|
||||
'Pricing',
|
||||
'Community',
|
||||
'Company'
|
||||
'Resources',
|
||||
'Enterprise',
|
||||
'Pricing'
|
||||
] as const
|
||||
|
||||
test.describe('Desktop navigation @smoke', () => {
|
||||
@@ -14,163 +14,263 @@ test.describe('Desktop navigation @smoke', () => {
|
||||
await page.goto('/')
|
||||
})
|
||||
|
||||
test('logo links to homepage', async ({ page }) => {
|
||||
const logo = page.getByRole('link', { name: 'Comfy home' })
|
||||
await expect(logo).toBeVisible()
|
||||
await expect(logo).toHaveAttribute('href', '/')
|
||||
})
|
||||
|
||||
test('has all top-level nav items', async ({ page }) => {
|
||||
test('has the approved top-level order and working CTAs', async ({
|
||||
page
|
||||
}) => {
|
||||
const nav = page.getByRole('navigation', { name: 'Main navigation' })
|
||||
const desktopLinks = nav.getByTestId('desktop-nav-links')
|
||||
const labels = await desktopLinks
|
||||
.locator('[data-nav-placement="desktop-top"]')
|
||||
.allTextContents()
|
||||
|
||||
for (const label of TOP_LEVEL_LABELS) {
|
||||
await expect(
|
||||
desktopLinks.getByText(label, { exact: true }).first()
|
||||
).toBeVisible()
|
||||
}
|
||||
})
|
||||
expect(labels.map((label) => label.trim())).toEqual(TOP_LEVEL_LABELS)
|
||||
await expect(nav.getByRole('link', { name: 'Comfy home' })).toHaveAttribute(
|
||||
'href',
|
||||
'/'
|
||||
)
|
||||
|
||||
test('NEW badge shows on Products and Community only', async ({ page }) => {
|
||||
const nav = page.getByRole('navigation', { name: 'Main navigation' })
|
||||
const desktopLinks = nav.getByTestId('desktop-nav-links')
|
||||
|
||||
for (const label of ['Products', 'Community']) {
|
||||
await expect(
|
||||
desktopLinks
|
||||
.getByRole('button', { name: label })
|
||||
.getByText('NEW', { exact: true })
|
||||
).toBeVisible()
|
||||
}
|
||||
|
||||
await expect(
|
||||
desktopLinks.getByRole('button', { name: 'Company' }).getByText('NEW')
|
||||
).toHaveCount(0)
|
||||
await expect(
|
||||
desktopLinks.getByRole('link', { name: 'Pricing' }).getByText('NEW')
|
||||
).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('CTA buttons are visible', async ({ page }) => {
|
||||
const nav = page.getByRole('navigation', { name: 'Main navigation' })
|
||||
const desktopCTA = nav.getByTestId('desktop-nav-cta')
|
||||
await expect(
|
||||
desktopCTA.getByRole('link', { name: 'DOWNLOAD DESKTOP' })
|
||||
).toBeVisible()
|
||||
).toHaveAttribute('href', '/download')
|
||||
await expect(
|
||||
desktopCTA.getByRole('link', { name: 'LAUNCH CLOUD' })
|
||||
).toBeVisible()
|
||||
).toHaveAttribute('href', 'https://cloud.comfy.org')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Desktop dropdown @interaction', () => {
|
||||
test.describe('Desktop dropdowns @interaction', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
})
|
||||
|
||||
test('hovering PRODUCTS shows dropdown items', async ({ page }) => {
|
||||
test('Products contains only usable products', async ({ page }) => {
|
||||
const nav = page.getByRole('navigation', { name: 'Main navigation' })
|
||||
const desktopLinks = nav.getByTestId('desktop-nav-links')
|
||||
const productsButton = desktopLinks.getByRole('button', {
|
||||
name: 'Products'
|
||||
})
|
||||
await productsButton.hover()
|
||||
await desktopLinks.getByRole('button', { name: 'Products' }).hover()
|
||||
|
||||
const dropdown = nav.getByTestId('nav-dropdown')
|
||||
for (const item of [
|
||||
'Comfy Desktop',
|
||||
'Comfy Cloud',
|
||||
'Comfy API',
|
||||
'Comfy MCP'
|
||||
]) {
|
||||
await expect(
|
||||
desktopLinks.getByRole('link', { name: new RegExp(item) }).first()
|
||||
).toBeVisible()
|
||||
}
|
||||
|
||||
for (const removedItem of [
|
||||
'Features',
|
||||
'Docs',
|
||||
'Supported Models',
|
||||
'Comfy Enterprise'
|
||||
]) {
|
||||
await expect(dropdown.getByText(item)).toBeVisible()
|
||||
await expect(
|
||||
desktopLinks.getByText(removedItem, { exact: true })
|
||||
).toBeHidden()
|
||||
}
|
||||
})
|
||||
|
||||
test('moving mouse away closes dropdown', async ({ page }) => {
|
||||
const nav = page.getByRole('navigation', { name: 'Main navigation' })
|
||||
const desktopLinks = nav.getByTestId('desktop-nav-links')
|
||||
await desktopLinks.getByRole('button', { name: 'Products' }).hover()
|
||||
test('Resources exposes the approved groups and destinations', async ({
|
||||
page
|
||||
}) => {
|
||||
const desktopLinks = page
|
||||
.getByRole('navigation', { name: 'Main navigation' })
|
||||
.getByTestId('desktop-nav-links')
|
||||
await desktopLinks.getByRole('button', { name: 'Resources' }).hover()
|
||||
|
||||
const comfyLocal = nav.getByRole('link', { name: 'Comfy Desktop' }).first()
|
||||
await expect(comfyLocal).toBeVisible()
|
||||
for (const group of ['Learn', 'Discover', 'Stay current', 'Community']) {
|
||||
await expect(desktopLinks.getByText(group, { exact: true })).toBeVisible()
|
||||
}
|
||||
|
||||
for (const item of [
|
||||
'Docs',
|
||||
'Learning',
|
||||
'Blog',
|
||||
'Comfy Hub',
|
||||
'Gallery',
|
||||
'Supported Models',
|
||||
"What's New",
|
||||
'Customer Stories',
|
||||
'Discord',
|
||||
'GitHub',
|
||||
'YouTube',
|
||||
'Affiliate Program'
|
||||
]) {
|
||||
await expect(
|
||||
desktopLinks.getByRole('link', { name: new RegExp(item) }).first()
|
||||
).toBeVisible()
|
||||
}
|
||||
|
||||
await expect(
|
||||
desktopLinks.getByRole('link', { name: /What's New/ })
|
||||
).toHaveAttribute('href', '/launches')
|
||||
await expect(
|
||||
desktopLinks.getByRole('link', { name: /Docs/ })
|
||||
).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
})
|
||||
|
||||
test('Enterprise provides overview, proof, sales, and support', async ({
|
||||
page
|
||||
}) => {
|
||||
const desktopLinks = page
|
||||
.getByRole('navigation', { name: 'Main navigation' })
|
||||
.getByTestId('desktop-nav-links')
|
||||
await desktopLinks.getByRole('button', { name: 'Enterprise' }).hover()
|
||||
|
||||
await expect(
|
||||
desktopLinks.getByRole('link', { name: /Enterprise Overview/ }).first()
|
||||
).toHaveAttribute('href', '/cloud/enterprise')
|
||||
await expect(
|
||||
desktopLinks.getByRole('link', { name: /Customer Stories/ })
|
||||
).toHaveAttribute('href', '/customers')
|
||||
await expect(
|
||||
desktopLinks.getByRole('link', { name: /Contact Sales/ })
|
||||
).toHaveAttribute('href', '/contact')
|
||||
await expect(
|
||||
desktopLinks.getByRole('link', { name: /Support/ })
|
||||
).toHaveAttribute('href', 'https://support.comfy.org/hc/en-us')
|
||||
})
|
||||
|
||||
test('assigns a shared Customer Stories route to Enterprise', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/customers')
|
||||
const desktopLinks = page.getByTestId('desktop-nav-links')
|
||||
const resources = desktopLinks.getByRole('button', { name: 'Resources' })
|
||||
const enterprise = desktopLinks.getByRole('button', {
|
||||
name: 'Enterprise'
|
||||
})
|
||||
|
||||
await expect(resources).not.toHaveAttribute('data-active')
|
||||
await expect(enterprise).toHaveAttribute('data-active', '')
|
||||
})
|
||||
|
||||
test('supports keyboard open and Escape focus return', async ({ page }) => {
|
||||
const productsButton = page
|
||||
.getByTestId('desktop-nav-links')
|
||||
.getByRole('button', { name: 'Products' })
|
||||
|
||||
await productsButton.focus()
|
||||
await page.keyboard.press('Enter')
|
||||
await expect(productsButton).toHaveAttribute('aria-expanded', 'true')
|
||||
await expect(productsButton).toHaveAttribute('aria-controls', /.+/)
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(productsButton).toHaveAttribute('aria-expanded', 'false')
|
||||
await expect(productsButton).toBeFocused()
|
||||
})
|
||||
|
||||
test('closes when the pointer moves away', async ({ page }) => {
|
||||
const nav = page.getByRole('navigation', { name: 'Main navigation' })
|
||||
await nav
|
||||
.getByTestId('desktop-nav-links')
|
||||
.getByRole('button', { name: 'Products' })
|
||||
.hover()
|
||||
|
||||
const desktopLink = nav
|
||||
.getByTestId('desktop-nav-links')
|
||||
.getByRole('link', { name: /Comfy Desktop/ })
|
||||
await expect(desktopLink).toBeVisible()
|
||||
|
||||
const viewport = page.viewportSize()
|
||||
await page.mouse.move(10, (viewport?.height ?? 800) - 10)
|
||||
await expect(comfyLocal).toBeHidden()
|
||||
await expect(desktopLink).toBeHidden()
|
||||
})
|
||||
|
||||
test('Escape key closes dropdown', async ({ page }) => {
|
||||
const nav = page.getByRole('navigation', { name: 'Main navigation' })
|
||||
const desktopLinks = nav.getByTestId('desktop-nav-links')
|
||||
await desktopLinks.getByRole('button', { name: 'Products' }).hover()
|
||||
test('fits a laptop viewport and closes on outside click', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1024, height: 768 })
|
||||
const desktopLinks = page.getByTestId('desktop-nav-links')
|
||||
await desktopLinks.getByRole('button', { name: 'Resources' }).click()
|
||||
|
||||
const comfyLocal = nav.getByRole('link', { name: 'Comfy Desktop' }).first()
|
||||
await expect(comfyLocal).toBeVisible()
|
||||
const dropdown = desktopLinks
|
||||
.getByTestId('nav-dropdown')
|
||||
.filter({ hasText: 'Stay current' })
|
||||
await expect(dropdown).toBeVisible()
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(comfyLocal).toBeHidden()
|
||||
const bounds = await dropdown.boundingBox()
|
||||
expect(bounds).not.toBeNull()
|
||||
if (!bounds) return
|
||||
expect(bounds.x).toBeGreaterThanOrEqual(0)
|
||||
expect(bounds.x + bounds.width).toBeLessThanOrEqual(1024)
|
||||
expect(bounds.y).toBeGreaterThanOrEqual(0)
|
||||
expect(bounds.y + bounds.height).toBeLessThanOrEqual(768)
|
||||
|
||||
await page.locator('main').click({ position: { x: 10, y: 500 } })
|
||||
await expect(dropdown).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Mobile menu @mobile', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.getByRole('button', { name: 'Toggle menu' }).click()
|
||||
})
|
||||
|
||||
test('hamburger button is visible', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Toggle menu' })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('clicking hamburger opens mobile menu with nav items', async ({
|
||||
test('uses the desktop ordering and accessible accordions', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.getByRole('button', { name: 'Toggle menu' }).click()
|
||||
|
||||
const menu = page.getByRole('dialog')
|
||||
await expect(menu).toBeVisible()
|
||||
const labels = await menu
|
||||
.locator('[data-nav-placement="mobile-top"]')
|
||||
.allTextContents()
|
||||
expect(labels.map((label) => label.trim())).toEqual(TOP_LEVEL_LABELS)
|
||||
|
||||
for (const label of ['Products', 'Pricing', 'Community']) {
|
||||
await expect(menu.getByText(label, { exact: true }).first()).toBeVisible()
|
||||
for (const label of ['Products', 'Resources', 'Enterprise']) {
|
||||
const trigger = menu.getByRole('button', { name: label })
|
||||
await expect(trigger).toHaveAttribute('aria-expanded', 'false')
|
||||
await expect(trigger).toHaveAttribute('aria-controls', /.+/)
|
||||
}
|
||||
await expect(menu.getByRole('link', { name: 'Pricing' })).toHaveAttribute(
|
||||
'href',
|
||||
'/cloud/pricing'
|
||||
)
|
||||
})
|
||||
|
||||
test('NEW badge shows on Products and Community only', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Toggle menu' }).click()
|
||||
|
||||
test('preserves group hierarchy and closes the previous accordion', async ({
|
||||
page
|
||||
}) => {
|
||||
const menu = page.getByRole('dialog')
|
||||
const products = menu.getByRole('button', { name: 'Products' })
|
||||
const resources = menu.getByRole('button', { name: 'Resources' })
|
||||
|
||||
for (const label of ['Products', 'Community']) {
|
||||
await products.click()
|
||||
await expect(menu.getByRole('region', { name: 'Products' })).toBeVisible()
|
||||
await expect(menu.getByText('Create', { exact: true })).toBeVisible()
|
||||
await expect(menu.getByText('Build', { exact: true })).toBeVisible()
|
||||
await expect(menu.getByRole('link', { name: /Comfy MCP/ })).toBeVisible()
|
||||
|
||||
await resources.click()
|
||||
await expect(menu.getByRole('region', { name: 'Products' })).toBeHidden()
|
||||
const resourcesPanel = menu.getByRole('region', { name: 'Resources' })
|
||||
await expect(resourcesPanel).toBeVisible()
|
||||
for (const group of ['Learn', 'Discover', 'Stay current', 'Community']) {
|
||||
await expect(
|
||||
menu.getByRole('button', { name: label }).getByText('NEW', {
|
||||
exact: true
|
||||
})
|
||||
resourcesPanel.getByText(group, { exact: true })
|
||||
).toBeVisible()
|
||||
}
|
||||
|
||||
await expect(
|
||||
menu.getByRole('button', { name: 'Company' }).getByText('NEW')
|
||||
).toHaveCount(0)
|
||||
await expect(
|
||||
menu.getByRole('link', { name: 'Pricing' }).getByText('NEW')
|
||||
).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('clicking section with subitems drills down and back works', async ({
|
||||
test('Escape closes an accordion, then the menu, and restores focus', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.getByRole('button', { name: 'Toggle menu' }).click()
|
||||
|
||||
const menu = page.getByRole('dialog')
|
||||
await menu.getByRole('button', { name: 'Products' }).click()
|
||||
const enterprise = menu.getByRole('button', { name: 'Enterprise' })
|
||||
await enterprise.click()
|
||||
await menu.getByRole('link', { name: /Contact Sales/ }).focus()
|
||||
|
||||
await expect(menu.getByText('Comfy Desktop')).toBeVisible()
|
||||
await expect(menu.getByText('Comfy Cloud')).toBeVisible()
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(enterprise).toHaveAttribute('aria-expanded', 'false')
|
||||
await expect(enterprise).toBeFocused()
|
||||
|
||||
await menu.getByRole('button', { name: /BACK/i }).click()
|
||||
await expect(menu.getByRole('button', { name: 'Products' })).toBeVisible()
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(menu).toBeHidden()
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Toggle menu' })
|
||||
).toBeFocused()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -179,20 +279,28 @@ test.describe('Footer @smoke', () => {
|
||||
await page.goto('/')
|
||||
})
|
||||
|
||||
test('footer is visible with link sections', async ({ page }) => {
|
||||
test('preserves Company and legal destinations', async ({ page }) => {
|
||||
const footer = page.locator('footer')
|
||||
await expect(footer).toBeVisible()
|
||||
|
||||
for (const heading of ['Products', 'Resources', 'Company']) {
|
||||
await expect(
|
||||
footer.getByRole('heading', { name: heading }).first()
|
||||
).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('copyright text is visible', async ({ page }) => {
|
||||
await expect(
|
||||
page.locator('footer').getByText(/© \d{4} Comfy Org/)
|
||||
).toBeVisible()
|
||||
for (const [label, href] of [
|
||||
['About', '/about'],
|
||||
['Careers', '/careers'],
|
||||
['Contact', '/contact'],
|
||||
['Brand', '/brand'],
|
||||
['Terms of Service', '/terms-of-service'],
|
||||
['Enterprise MSA', '/enterprise-msa'],
|
||||
['Privacy Policy', '/privacy-policy'],
|
||||
['Press', 'mailto:press@comfy.org']
|
||||
]) {
|
||||
const link = footer.getByRole('link', { name: label }).first()
|
||||
await expect(link).toBeVisible()
|
||||
await expect(link).toHaveAttribute('href', href)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import type { Locale } from '../../../i18n/translations.ts'
|
||||
import { t } from '../../../i18n/translations.ts'
|
||||
import { externalLinks, getRoutes } from '../../../config/routes.ts'
|
||||
import { captureNavigationClick } from '../../../scripts/posthog'
|
||||
import GitHubStarBadge from '../GitHubStarBadge.vue'
|
||||
import HeaderMainDesktop from './HeaderMainDesktop.vue'
|
||||
import HeaderMainMobile from './HeaderMainMobile.vue'
|
||||
@@ -29,12 +30,21 @@ const ctaButtons = [
|
||||
primary: true
|
||||
}
|
||||
]
|
||||
|
||||
function handleNavigationClick(event: MouseEvent) {
|
||||
if (!(event.target instanceof Element)) return
|
||||
const trackedElement = event.target.closest<HTMLElement>('[data-nav-label]')
|
||||
const item = trackedElement?.dataset.navLabel
|
||||
if (!item) return
|
||||
captureNavigationClick(item, trackedElement.dataset.navPlacement ?? 'header')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav
|
||||
class="sticky top-0 z-50 flex items-center justify-between gap-4 bg-primary-comfy-ink px-6 py-5 lg:gap-4 lg:px-[clamp(0.25rem,4vw,5rem)] lg:py-8"
|
||||
aria-label="Main navigation"
|
||||
@click.capture="handleNavigationClick"
|
||||
>
|
||||
<a
|
||||
:href="routes.home"
|
||||
|
||||
@@ -6,6 +6,7 @@ import NavigationMenuLink from '@/components/ui/navigation-menu/NavigationMenuLi
|
||||
import NavigationMenuList from '@/components/ui/navigation-menu/NavigationMenuList.vue'
|
||||
import NavigationMenuTrigger from '@/components/ui/navigation-menu/NavigationMenuTrigger.vue'
|
||||
import { navigationMenuTriggerStyle } from '@/components/ui/navigation-menu/navigationMenuTriggerStyle'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import {
|
||||
isHrefActive,
|
||||
@@ -26,7 +27,11 @@ function isNavItemActive(navItem: NavItem, path: string): boolean {
|
||||
if (navItem.href) return isHrefActive(navItem.href, path)
|
||||
return (
|
||||
navItem.columns?.some((column) =>
|
||||
column.items.some((item) => isHrefActive(item.href, path))
|
||||
column.items.some(
|
||||
(item) =>
|
||||
item.contributesToParentActive !== false &&
|
||||
isHrefActive(item.href, path)
|
||||
)
|
||||
) ?? false
|
||||
)
|
||||
}
|
||||
@@ -42,6 +47,8 @@ function isNavItemActive(navItem: NavItem, path: string): boolean {
|
||||
<template v-if="navItem.columns?.length">
|
||||
<NavigationMenuTrigger
|
||||
:active="isNavItemActive(navItem, currentPath)"
|
||||
:data-nav-label="navItem.analyticsId"
|
||||
data-nav-placement="desktop-top"
|
||||
>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span class="ppformula-text-center">{{ navItem.label }}</span>
|
||||
@@ -49,18 +56,38 @@ function isNavItemActive(navItem: NavItem, path: string): boolean {
|
||||
</span>
|
||||
</NavigationMenuTrigger>
|
||||
<NavigationMenuContent class="w-auto" data-testid="nav-dropdown">
|
||||
<ul class="flex w-max gap-16">
|
||||
<ul
|
||||
:class="
|
||||
cn(
|
||||
'flex w-max max-w-dvw gap-10',
|
||||
navItem.layout === 'wide' && 'gap-16'
|
||||
)
|
||||
"
|
||||
>
|
||||
<NavFeaturedCard
|
||||
v-if="navItem.featured"
|
||||
:featured="navItem.featured"
|
||||
/>
|
||||
<NavColumn
|
||||
v-for="column in navItem.columns"
|
||||
:key="column.header"
|
||||
:column="column"
|
||||
:locale="locale"
|
||||
:current-path="currentPath"
|
||||
/>
|
||||
<li>
|
||||
<ul
|
||||
:class="
|
||||
cn(
|
||||
'grid grid-cols-2 gap-x-10 gap-y-8',
|
||||
navItem.layout === 'wide' && 'gap-x-16'
|
||||
)
|
||||
"
|
||||
>
|
||||
<NavColumn
|
||||
v-for="column in navItem.columns"
|
||||
:key="column.header"
|
||||
:column="column"
|
||||
:locale="locale"
|
||||
:current-path="currentPath"
|
||||
:parent-analytics-id="navItem.analyticsId"
|
||||
:wide="navItem.layout === 'wide'"
|
||||
/>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</NavigationMenuContent>
|
||||
</template>
|
||||
@@ -69,6 +96,8 @@ function isNavItemActive(navItem: NavItem, path: string): boolean {
|
||||
as-child
|
||||
:active="isNavItemActive(navItem, currentPath)"
|
||||
:class="navigationMenuTriggerStyle()"
|
||||
:data-nav-label="navItem.analyticsId"
|
||||
data-nav-placement="desktop-top"
|
||||
>
|
||||
<a :href="navItem.href" class="ppformula-text-center">{{
|
||||
navItem.label
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import BreadthumbIcon from '@/components/icons/BreadthumbIcon.vue'
|
||||
import { ChevronLeft, ChevronRight } from '@lucide/vue'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { getMainNavigation } from '../../../data/mainNavigation'
|
||||
import { getRoutes } from '../../../config/routes.ts'
|
||||
import { lockScroll, unlockScroll } from '../../../composables/scrollLock'
|
||||
import type { Locale } from '../../../i18n/translations.ts'
|
||||
import { t } from '../../../i18n/translations.ts'
|
||||
import NavLinkContent from './NavLinkContent.vue'
|
||||
import NewBadge from './NewBadge.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Sheet from '@/components/ui/sheet/Sheet.vue'
|
||||
import SheetContent from '@/components/ui/sheet/SheetContent.vue'
|
||||
import SheetDescription from '@/components/ui/sheet/SheetDescription.vue'
|
||||
import SheetHeader from '@/components/ui/sheet/SheetHeader.vue'
|
||||
import SheetTitle from '@/components/ui/sheet/SheetTitle.vue'
|
||||
import SheetTrigger from '@/components/ui/sheet/SheetTrigger.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { ChevronDown } from '@lucide/vue'
|
||||
import { onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import { getRoutes } from '../../../config/routes.ts'
|
||||
import { lockScroll, unlockScroll } from '../../../composables/scrollLock'
|
||||
import { getMainNavigation } from '../../../data/mainNavigation'
|
||||
import type { Locale } from '../../../i18n/translations.ts'
|
||||
import { t } from '../../../i18n/translations.ts'
|
||||
import NavLinkContent from './NavLinkContent.vue'
|
||||
import NewBadge from './NewBadge.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
const routes = getRoutes(locale)
|
||||
@@ -25,11 +26,20 @@ const mainNavigation = getMainNavigation(locale)
|
||||
const isOpen = ref(false)
|
||||
const activeSection = ref<string | null>(null)
|
||||
|
||||
const activeItem = computed(() =>
|
||||
mainNavigation.find(
|
||||
(item) => item.label === activeSection.value && item.columns
|
||||
)
|
||||
)
|
||||
function toggleSection(sectionId: string) {
|
||||
activeSection.value = activeSection.value === sectionId ? null : sectionId
|
||||
}
|
||||
|
||||
function closeSectionAndFocus(sectionId: string) {
|
||||
activeSection.value = null
|
||||
document.getElementById(`mobile-nav-${sectionId}-trigger`)?.focus()
|
||||
}
|
||||
|
||||
function handleSectionEscape(sectionId: string, event: KeyboardEvent) {
|
||||
if (activeSection.value !== sectionId) return
|
||||
event.stopPropagation()
|
||||
closeSectionAndFocus(sectionId)
|
||||
}
|
||||
|
||||
watch(isOpen, (open) => {
|
||||
if (open) {
|
||||
@@ -77,91 +87,92 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<div class="relative mt-4 flex-1 overflow-hidden">
|
||||
<!-- Top-level nav -->
|
||||
<nav
|
||||
:class="
|
||||
cn(
|
||||
'absolute inset-0 overflow-y-auto p-1',
|
||||
activeItem ? 'opacity-0' : ''
|
||||
)
|
||||
"
|
||||
class="absolute inset-0 overflow-y-auto p-1"
|
||||
:aria-label="t('nav.menu', locale)"
|
||||
:inert="activeItem ? true : undefined"
|
||||
>
|
||||
<ul class="flex flex-col gap-y-8">
|
||||
<ul class="flex flex-col gap-y-4">
|
||||
<li v-for="item in mainNavigation" :key="item.label">
|
||||
<template v-if="item.columns">
|
||||
<Button
|
||||
:id="`mobile-nav-${item.analyticsId}-trigger`"
|
||||
type="button"
|
||||
variant="navMuted"
|
||||
:class="
|
||||
cn(
|
||||
activeSection === item.analyticsId &&
|
||||
'text-primary-comfy-yellow'
|
||||
)
|
||||
"
|
||||
:aria-expanded="activeSection === item.analyticsId"
|
||||
:aria-controls="`mobile-nav-${item.analyticsId}-panel`"
|
||||
:data-nav-label="item.analyticsId"
|
||||
data-nav-placement="mobile-top"
|
||||
@click="toggleSection(item.analyticsId)"
|
||||
@keydown.esc="handleSectionEscape(item.analyticsId, $event)"
|
||||
>
|
||||
<span class="ppformula-text-center">{{ item.label }}</span>
|
||||
<NewBadge v-if="item.badge" :locale="locale" size="xxs" />
|
||||
<template #append>
|
||||
<ChevronDown
|
||||
class="size-6"
|
||||
:class="
|
||||
activeSection === item.analyticsId && 'rotate-180'
|
||||
"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
|
||||
<div
|
||||
v-show="activeSection === item.analyticsId"
|
||||
:id="`mobile-nav-${item.analyticsId}-panel`"
|
||||
role="region"
|
||||
:aria-labelledby="`mobile-nav-${item.analyticsId}-trigger`"
|
||||
class="mt-6 flex flex-col gap-y-10 pb-4 pl-4"
|
||||
@keydown.esc="handleSectionEscape(item.analyticsId, $event)"
|
||||
>
|
||||
<div
|
||||
v-for="column in item.columns"
|
||||
:key="column.header"
|
||||
class="flex flex-col gap-y-3"
|
||||
>
|
||||
<p
|
||||
class="text-primary-warm-gray text-base font-bold tracking-wider uppercase"
|
||||
>
|
||||
{{ column.header }}
|
||||
</p>
|
||||
<Button
|
||||
v-for="link in column.items"
|
||||
:key="link.label"
|
||||
:href="link.href"
|
||||
variant="nav"
|
||||
as="a"
|
||||
:target="link.external ? '_blank' : undefined"
|
||||
:rel="link.external ? 'noopener noreferrer' : undefined"
|
||||
:data-nav-label="link.analyticsId"
|
||||
:data-nav-placement="`mobile-${item.analyticsId}`"
|
||||
@click="isOpen = false"
|
||||
>
|
||||
<NavLinkContent :item="link" :locale="locale" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<Button
|
||||
:as="item.columns ? 'button' : 'a'"
|
||||
v-else
|
||||
:href="item.href"
|
||||
variant="navMuted"
|
||||
:type="item.columns ? 'button' : undefined"
|
||||
:href="item.columns ? undefined : item.href"
|
||||
@click="item.columns && (activeSection = item.label)"
|
||||
as="a"
|
||||
:data-nav-label="item.analyticsId"
|
||||
data-nav-placement="mobile-top"
|
||||
@click="isOpen = false"
|
||||
>
|
||||
<span class="ppformula-text-center">{{ item.label }}</span>
|
||||
<NewBadge v-if="item.badge" :locale="locale" size="xxs" />
|
||||
<template #append>
|
||||
<ChevronRight class="size-7" />
|
||||
</template>
|
||||
</Button>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<!-- Drill-down sub-panel -->
|
||||
<div
|
||||
class="absolute inset-0 bg-primary-comfy-ink transition-transform duration-300 ease-out"
|
||||
:class="
|
||||
activeItem
|
||||
? 'translate-x-0'
|
||||
: 'pointer-events-none translate-x-full'
|
||||
"
|
||||
:inert="activeItem ? undefined : true"
|
||||
:aria-hidden="!activeItem"
|
||||
>
|
||||
<div class="size-full overflow-y-auto py-8">
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
@click="activeSection = null"
|
||||
>
|
||||
<template #prepend>
|
||||
<ChevronLeft />
|
||||
</template>
|
||||
{{ t('nav.back', locale) }}
|
||||
</Button>
|
||||
|
||||
<div v-if="activeItem" class="mt-6 flex flex-col gap-y-12">
|
||||
<div
|
||||
v-for="column in activeItem.columns"
|
||||
:key="column.header"
|
||||
class="flex flex-col gap-y-3"
|
||||
>
|
||||
<p
|
||||
class="text-primary-warm-gray text-base font-bold tracking-wider uppercase"
|
||||
>
|
||||
{{ column.header }}
|
||||
</p>
|
||||
<Button
|
||||
v-for="link in column.items"
|
||||
:key="link.label"
|
||||
:href="link.href"
|
||||
variant="nav"
|
||||
as="a"
|
||||
:target="link.external ? '_blank' : undefined"
|
||||
:rel="link.external ? 'noopener noreferrer' : undefined"
|
||||
>
|
||||
<NavLinkContent :item="link" :locale="locale" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-x-0 top-0 h-8 bg-linear-to-b from-primary-comfy-ink to-transparent"
|
||||
/>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-x-0 bottom-0 h-8 bg-linear-to-t from-primary-comfy-ink to-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
@@ -5,12 +5,19 @@ import { isHrefActive } from '../../../composables/useCurrentPath'
|
||||
import type { NavColumn } from '../../../data/mainNavigation'
|
||||
import type { Locale } from '../../../i18n/translations'
|
||||
import NavLinkContent from './NavLinkContent.vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
defineProps<{ column: NavColumn; locale: Locale; currentPath: string }>()
|
||||
defineProps<{
|
||||
column: NavColumn
|
||||
locale: Locale
|
||||
currentPath: string
|
||||
parentAnalyticsId: string
|
||||
wide?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li class="flex flex-col space-y-4">
|
||||
<li :class="cn('flex w-44 flex-col space-y-4', wide && 'w-52')">
|
||||
<p class="font-formula text-primary-warm-gray pl-2 text-sm font-medium">
|
||||
{{ column.header }}
|
||||
</p>
|
||||
@@ -25,7 +32,8 @@ defineProps<{ column: NavColumn; locale: Locale; currentPath: string }>()
|
||||
:href="item.href"
|
||||
:target="item.external ? '_blank' : undefined"
|
||||
:rel="item.external ? 'noopener noreferrer' : undefined"
|
||||
class="whitespace-nowrap"
|
||||
:data-nav-label="item.analyticsId"
|
||||
:data-nav-placement="`desktop-${parentAnalyticsId}`"
|
||||
>
|
||||
<NavLinkContent :item="item" :locale="locale" />
|
||||
</a>
|
||||
|
||||
@@ -11,6 +11,8 @@ defineProps<{ featured: NavFeatured }>()
|
||||
<a
|
||||
:href="featured.cta.href"
|
||||
:aria-label="featured.cta.ariaLabel"
|
||||
:data-nav-label="featured.analyticsId"
|
||||
data-nav-placement="desktop-featured"
|
||||
class="group/pill-trigger relative block"
|
||||
>
|
||||
<img
|
||||
|
||||
@@ -10,11 +10,14 @@ defineProps<{ item: NavColumnItem; locale: Locale }>()
|
||||
|
||||
<template>
|
||||
<span class="flex items-center gap-2">
|
||||
<span class="ppformula-text-center">{{ item.label }}</span>
|
||||
<span class="ppformula-text-center whitespace-nowrap">{{
|
||||
item.label
|
||||
}}</span>
|
||||
<NewBadge v-if="item.badge" :locale="locale" size="xs" />
|
||||
<ArrowUpRight
|
||||
v-if="item.external"
|
||||
class="text-primary-comfy-yellow size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -85,6 +85,7 @@ const companyColumn: { title: string; links: FooterLink[] } = {
|
||||
links: [
|
||||
{ label: t('footer.about', locale), href: routes.about },
|
||||
{ label: t('nav.careers', locale), href: routes.careers },
|
||||
{ label: t('nav.contact', locale), href: routes.contact },
|
||||
{ label: t('nav.brand', locale), href: routes.brand },
|
||||
{ label: t('footer.termsOfService', locale), href: routes.termsOfService },
|
||||
{ label: t('footer.enterpriseMsa', locale), href: routes.enterpriseMsa },
|
||||
@@ -95,7 +96,6 @@ const companyColumn: { title: string; links: FooterLink[] } = {
|
||||
const contactColumn: { title: string; links: FooterLink[] } = {
|
||||
title: t('footer.contact', locale),
|
||||
links: [
|
||||
{ label: t('footer.sales', locale), href: routes.contact },
|
||||
{
|
||||
label: t('footer.support', locale),
|
||||
href: externalLinks.support,
|
||||
|
||||
78
apps/website/src/data/mainNavigation.test.ts
Normal file
78
apps/website/src/data/mainNavigation.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { NavItem } from './mainNavigation'
|
||||
import { getMainNavigation } from './mainNavigation'
|
||||
|
||||
function getColumns(item: NavItem) {
|
||||
if (!item.columns) throw new Error(`${item.label} must be a dropdown`)
|
||||
return item.columns
|
||||
}
|
||||
|
||||
describe('getMainNavigation', () => {
|
||||
const navigation = getMainNavigation('en')
|
||||
|
||||
it('uses the approved top-level order', () => {
|
||||
expect(navigation.map((item) => item.label)).toEqual([
|
||||
'Products',
|
||||
'Resources',
|
||||
'Enterprise',
|
||||
'Pricing'
|
||||
])
|
||||
})
|
||||
|
||||
it('limits Products to usable products grouped by Create and Build', () => {
|
||||
const columns = getColumns(navigation[0])
|
||||
|
||||
expect(columns.map((column) => column.header)).toEqual(['Create', 'Build'])
|
||||
expect(
|
||||
columns.flatMap((column) => column.items.map((item) => item.label))
|
||||
).toEqual(['Comfy Desktop', 'Comfy Cloud', 'Comfy API', 'Comfy MCP'])
|
||||
})
|
||||
|
||||
it('organizes Resources into Learn, Discover, Stay current, and Community', () => {
|
||||
const columns = getColumns(navigation[1])
|
||||
|
||||
expect(
|
||||
columns.map((column) => [
|
||||
column.header,
|
||||
column.items.map((item) => item.label)
|
||||
])
|
||||
).toEqual([
|
||||
['Learn', ['Learning', 'Docs', 'Blog', 'YouTube']],
|
||||
['Discover', ['Comfy Hub', 'Gallery', 'Supported Models']],
|
||||
['Stay current', ["What's New", 'Customer Stories']],
|
||||
['Community', ['Affiliate Program', 'Discord', 'GitHub']]
|
||||
])
|
||||
expect(columns[2].items[0].href).toBe('/launches')
|
||||
})
|
||||
|
||||
it('builds Enterprise as a compact buyer hub', () => {
|
||||
const columns = getColumns(navigation[2])
|
||||
|
||||
expect(
|
||||
columns.map((column) => [
|
||||
column.header,
|
||||
column.items.map((item) => item.label)
|
||||
])
|
||||
).toEqual([
|
||||
['Evaluate', ['Enterprise Overview', 'Customer Stories']],
|
||||
['Contact', ['Contact Sales', 'Support']]
|
||||
])
|
||||
expect(columns[0].items[0].href).toBe('/cloud/enterprise')
|
||||
expect(columns[1].items[0].href).toBe('/contact')
|
||||
})
|
||||
|
||||
it('includes Customer Stories in both discovery and buying journeys', () => {
|
||||
const customerStoryPlacements = navigation
|
||||
.flatMap((item) => item.columns ?? [])
|
||||
.flatMap((column) => column.items)
|
||||
.filter((item) => item.analyticsId === 'customer-stories')
|
||||
|
||||
expect(customerStoryPlacements).toHaveLength(2)
|
||||
expect(
|
||||
customerStoryPlacements.every((item) => item.href === '/customers')
|
||||
).toBe(true)
|
||||
expect(customerStoryPlacements[0].contributesToParentActive).toBe(false)
|
||||
expect(customerStoryPlacements[1].contributesToParentActive).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,8 @@ import { t } from '../i18n/translations'
|
||||
export type NavColumnItem = {
|
||||
label: string
|
||||
href: string
|
||||
analyticsId: string
|
||||
contributesToParentActive?: boolean
|
||||
badge?: 'new'
|
||||
external?: boolean
|
||||
}
|
||||
@@ -18,6 +20,7 @@ export type NavFeatured = {
|
||||
imageSrc: string
|
||||
imageAlt?: string
|
||||
title: string
|
||||
analyticsId: string
|
||||
cta: {
|
||||
label: string
|
||||
ariaLabel?: string
|
||||
@@ -28,13 +31,16 @@ export type NavFeatured = {
|
||||
export type NavItem =
|
||||
| {
|
||||
label: string
|
||||
analyticsId: string
|
||||
columns: NavColumn[]
|
||||
featured?: NavFeatured
|
||||
layout?: 'default' | 'wide'
|
||||
badge?: 'new'
|
||||
href?: never
|
||||
}
|
||||
| {
|
||||
label: string
|
||||
analyticsId: string
|
||||
href: string
|
||||
badge?: 'new'
|
||||
columns?: never
|
||||
@@ -46,11 +52,12 @@ export function getMainNavigation(locale: Locale): NavItem[] {
|
||||
return [
|
||||
{
|
||||
label: t('nav.products', locale),
|
||||
badge: 'new',
|
||||
analyticsId: 'products',
|
||||
featured: {
|
||||
imageSrc: 'https://media.comfy.org/website/nav/mcp-card.webp',
|
||||
imageAlt: t('nav.featuredProductsAlt', locale),
|
||||
title: t('nav.featuredProductsTitle', locale),
|
||||
analyticsId: 'comfy-mcp-featured',
|
||||
cta: {
|
||||
label: t('cta.getStarted', locale),
|
||||
ariaLabel: t('nav.featuredProductsCtaAria', locale),
|
||||
@@ -59,110 +66,140 @@ export function getMainNavigation(locale: Locale): NavItem[] {
|
||||
},
|
||||
columns: [
|
||||
{
|
||||
header: t('nav.products', locale),
|
||||
header: t('nav.colCreate', locale),
|
||||
items: [
|
||||
{ label: t('nav.comfyLocal', locale), href: routes.download },
|
||||
{ label: t('nav.comfyCloud', locale), href: routes.cloud },
|
||||
{
|
||||
label: t('nav.comfyApi', locale),
|
||||
href: routes.api,
|
||||
badge: 'new'
|
||||
label: t('nav.comfyLocal', locale),
|
||||
href: routes.download,
|
||||
analyticsId: 'comfy-desktop'
|
||||
},
|
||||
{
|
||||
label: t('nav.comfyEnterprise', locale),
|
||||
href: routes.cloudEnterprise
|
||||
label: t('nav.comfyCloud', locale),
|
||||
href: routes.cloud,
|
||||
analyticsId: 'comfy-cloud'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
header: t('nav.colFeatures', locale),
|
||||
header: t('nav.colBuild', locale),
|
||||
items: [
|
||||
{
|
||||
label: t('nav.comfyApi', locale),
|
||||
href: routes.api,
|
||||
analyticsId: 'comfy-api',
|
||||
badge: 'new'
|
||||
},
|
||||
{
|
||||
label: t('nav.mcpServer', locale),
|
||||
href: routes.mcp,
|
||||
analyticsId: 'comfy-mcp',
|
||||
badge: 'new'
|
||||
},
|
||||
// TODO: no page yet — re-enable when landing pages ship
|
||||
// { 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.supportedModels', locale), href: routes.models },
|
||||
{
|
||||
label: t('nav.docs', locale),
|
||||
href: externalLinks.docs,
|
||||
external: true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{ label: t('nav.pricing', locale), href: routes.cloudPricing },
|
||||
{
|
||||
label: t('nav.community', locale),
|
||||
badge: 'new',
|
||||
label: t('nav.resources', locale),
|
||||
analyticsId: 'resources',
|
||||
layout: 'wide',
|
||||
featured: {
|
||||
imageSrc: 'https://media.comfy.org/website/nav/featured-demo-card.jpg',
|
||||
imageAlt: t('nav.featuredCommunityAlt', locale),
|
||||
title: t('nav.featuredCommunityTitle', locale),
|
||||
imageAlt: t('nav.featuredResourcesAlt', locale),
|
||||
title: t('nav.featuredResourcesTitle', locale),
|
||||
analyticsId: 'featured-workflow',
|
||||
cta: {
|
||||
label: t('cta.watchDemo', locale),
|
||||
ariaLabel: t('nav.featuredCommunityCtaAria', locale),
|
||||
ariaLabel: t('nav.featuredResourcesCtaAria', locale),
|
||||
href: 'https://comfy.org/workflows/537cf7f1f745-537cf7f1f745/'
|
||||
}
|
||||
},
|
||||
columns: [
|
||||
{
|
||||
header: t('nav.colPrograms', locale),
|
||||
header: t('nav.colLearn', locale),
|
||||
items: [
|
||||
{ label: t('nav.comfyHub', locale), href: externalLinks.workflows },
|
||||
{ label: t('nav.gallery', locale), href: routes.gallery },
|
||||
{
|
||||
label: t('nav.affiliates', locale),
|
||||
href: routes.affiliates,
|
||||
badge: 'new'
|
||||
},
|
||||
{
|
||||
label: t('nav.learning', locale),
|
||||
href: routes.learning,
|
||||
analyticsId: 'learning',
|
||||
badge: 'new'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
header: t('nav.colConnect', locale),
|
||||
items: [
|
||||
},
|
||||
{
|
||||
label: t('nav.discord', locale),
|
||||
href: externalLinks.discord,
|
||||
label: t('nav.docs', locale),
|
||||
href: externalLinks.docs,
|
||||
analyticsId: 'docs',
|
||||
external: true
|
||||
},
|
||||
{
|
||||
label: t('nav.github', locale),
|
||||
href: externalLinks.github,
|
||||
label: t('nav.blogs', locale),
|
||||
href: externalLinks.blog,
|
||||
analyticsId: 'blog',
|
||||
external: true
|
||||
},
|
||||
{
|
||||
label: t('nav.youtube', locale),
|
||||
href: externalLinks.youtube,
|
||||
analyticsId: 'youtube',
|
||||
external: true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
header: t('nav.colDiscover', locale),
|
||||
items: [
|
||||
{
|
||||
label: t('nav.comfyHub', locale),
|
||||
href: externalLinks.workflows,
|
||||
analyticsId: 'comfy-hub'
|
||||
},
|
||||
{
|
||||
label: t('nav.gallery', locale),
|
||||
href: routes.gallery,
|
||||
analyticsId: 'gallery'
|
||||
},
|
||||
{
|
||||
label: t('nav.supportedModels', locale),
|
||||
href: routes.models,
|
||||
analyticsId: 'supported-models'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
header: t('nav.colStayCurrent', locale),
|
||||
items: [
|
||||
{
|
||||
label: t('nav.whatsNew', locale),
|
||||
href: routes.launches,
|
||||
analyticsId: 'whats-new',
|
||||
badge: 'new'
|
||||
},
|
||||
{
|
||||
label: t('nav.customerStories', locale),
|
||||
href: routes.customers,
|
||||
analyticsId: 'customer-stories',
|
||||
contributesToParentActive: false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
header: t('nav.community', locale),
|
||||
items: [
|
||||
{
|
||||
label: t('nav.affiliateProgram', locale),
|
||||
href: routes.affiliates,
|
||||
analyticsId: 'affiliate-program',
|
||||
badge: 'new'
|
||||
},
|
||||
{
|
||||
label: t('nav.discord', locale),
|
||||
href: externalLinks.discord,
|
||||
analyticsId: 'discord',
|
||||
external: true
|
||||
},
|
||||
{
|
||||
label: t('nav.reddit', locale),
|
||||
href: externalLinks.reddit,
|
||||
external: true
|
||||
},
|
||||
{
|
||||
label: t('nav.x', locale),
|
||||
href: externalLinks.x,
|
||||
external: true
|
||||
},
|
||||
{
|
||||
label: t('nav.instagram', locale),
|
||||
href: externalLinks.instagram,
|
||||
label: t('nav.github', locale),
|
||||
href: externalLinks.github,
|
||||
analyticsId: 'github',
|
||||
external: true
|
||||
}
|
||||
]
|
||||
@@ -170,43 +207,57 @@ export function getMainNavigation(locale: Locale): NavItem[] {
|
||||
]
|
||||
},
|
||||
{
|
||||
label: t('nav.company', locale),
|
||||
label: t('nav.enterprise', locale),
|
||||
analyticsId: 'enterprise',
|
||||
featured: {
|
||||
imageSrc: 'https://media.comfy.org/website/nav/customer-story-card.jpg',
|
||||
imageAlt: t('nav.featuredCompanyAlt', locale),
|
||||
title: t('nav.featuredCompanyTitle', locale),
|
||||
imageAlt: t('nav.featuredEnterpriseAlt', locale),
|
||||
title: t('nav.featuredEnterpriseTitle', locale),
|
||||
analyticsId: 'enterprise-featured',
|
||||
cta: {
|
||||
label: t('cta.watchNow', locale),
|
||||
ariaLabel: t('nav.featuredCompanyCtaAria', locale),
|
||||
href: '/customers#hero-video'
|
||||
label: t('nav.enterpriseOverview', locale),
|
||||
ariaLabel: t('nav.featuredEnterpriseCtaAria', locale),
|
||||
href: routes.cloudEnterprise
|
||||
}
|
||||
},
|
||||
columns: [
|
||||
{
|
||||
header: t('nav.company', locale),
|
||||
header: t('nav.colEvaluate', locale),
|
||||
items: [
|
||||
{ label: t('nav.aboutUs', locale), href: routes.about },
|
||||
{ label: t('nav.careers', locale), href: routes.careers },
|
||||
{ label: t('nav.contact', locale), href: routes.contact }
|
||||
{
|
||||
label: t('nav.enterpriseOverview', locale),
|
||||
href: routes.cloudEnterprise,
|
||||
analyticsId: 'enterprise-overview'
|
||||
},
|
||||
{
|
||||
label: t('nav.customerStories', locale),
|
||||
href: routes.customers,
|
||||
analyticsId: 'customer-stories'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
header: t('nav.colMore', locale),
|
||||
header: t('nav.colContact', locale),
|
||||
items: [
|
||||
{
|
||||
label: t('nav.customerStories', locale),
|
||||
href: routes.customers
|
||||
label: t('nav.contactSales', locale),
|
||||
href: routes.contact,
|
||||
analyticsId: 'contact-sales'
|
||||
},
|
||||
// TODO: no /brand page yet
|
||||
// { label: t('nav.brand', locale), href: '#' },
|
||||
{
|
||||
label: t('nav.blogs', locale),
|
||||
href: externalLinks.blog,
|
||||
label: t('nav.support', locale),
|
||||
href: externalLinks.support,
|
||||
analyticsId: 'support',
|
||||
external: true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: t('nav.pricing', locale),
|
||||
href: routes.cloudPricing,
|
||||
analyticsId: 'pricing'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2251,6 +2251,7 @@ const translations = {
|
||||
|
||||
// SiteNav
|
||||
'nav.products': { en: 'Products', 'zh-CN': '产品' },
|
||||
'nav.enterprise': { en: 'Enterprise', 'zh-CN': '企业版' },
|
||||
'nav.pricing': { en: 'Pricing', 'zh-CN': '价格' },
|
||||
'nav.community': { en: 'Community', 'zh-CN': '社区' },
|
||||
'nav.resources': { en: 'Resources', 'zh-CN': '资源' },
|
||||
@@ -2275,6 +2276,17 @@ const translations = {
|
||||
'nav.brand': { en: 'Brand', 'zh-CN': '品牌' },
|
||||
'nav.customerStories': { en: 'Customer Stories', 'zh-CN': '客户故事' },
|
||||
'nav.launches': { en: 'Launches', 'zh-CN': '发布' },
|
||||
'nav.whatsNew': { en: "What's New", 'zh-CN': '最新动态' },
|
||||
'nav.enterpriseOverview': {
|
||||
en: 'Enterprise Overview',
|
||||
'zh-CN': '企业版概览'
|
||||
},
|
||||
'nav.contactSales': { en: 'Contact Sales', 'zh-CN': '联系销售' },
|
||||
'nav.support': { en: 'Support', 'zh-CN': '支持' },
|
||||
'nav.affiliateProgram': {
|
||||
en: 'Affiliate Program',
|
||||
'zh-CN': '联盟计划'
|
||||
},
|
||||
'nav.downloadLocal': { en: 'DOWNLOAD DESKTOP', 'zh-CN': '下载桌面版' },
|
||||
'nav.launchCloud': { en: 'LAUNCH CLOUD', 'zh-CN': '启动云端' },
|
||||
'nav.ctaDesktopPrefix': { en: 'DOWNLOAD', 'zh-CN': '下载' },
|
||||
@@ -2301,6 +2313,13 @@ const translations = {
|
||||
// Column headers used in HeaderMainDesktop dropdowns
|
||||
'nav.mcpServer': { en: 'Comfy MCP', 'zh-CN': 'Comfy MCP' },
|
||||
'nav.supportedModels': { en: 'Supported Models', 'zh-CN': '支持的模型' },
|
||||
'nav.colCreate': { en: 'Create', 'zh-CN': '创作' },
|
||||
'nav.colBuild': { en: 'Build', 'zh-CN': '构建' },
|
||||
'nav.colLearn': { en: 'Learn', 'zh-CN': '学习' },
|
||||
'nav.colDiscover': { en: 'Discover', 'zh-CN': '探索' },
|
||||
'nav.colStayCurrent': { en: 'Stay current', 'zh-CN': '保持关注' },
|
||||
'nav.colEvaluate': { en: 'Evaluate', 'zh-CN': '评估' },
|
||||
'nav.colContact': { en: 'Contact', 'zh-CN': '联系我们' },
|
||||
'nav.colFeatures': { en: 'Features', 'zh-CN': '功能' },
|
||||
'nav.colPrograms': { en: 'Programs', 'zh-CN': '项目' },
|
||||
'nav.colConnect': { en: 'Connect', 'zh-CN': '联系' },
|
||||
@@ -2325,6 +2344,30 @@ const translations = {
|
||||
en: 'Get started with Comfy MCP',
|
||||
'zh-CN': '开始使用 Comfy MCP'
|
||||
},
|
||||
'nav.featuredResourcesTitle': {
|
||||
en: 'Sky Replacement',
|
||||
'zh-CN': '天空替换'
|
||||
},
|
||||
'nav.featuredResourcesAlt': {
|
||||
en: 'Sky Replacement workflow demo image',
|
||||
'zh-CN': '天空替换工作流演示图片'
|
||||
},
|
||||
'nav.featuredResourcesCtaAria': {
|
||||
en: 'Watch the Sky Replacement demo',
|
||||
'zh-CN': '观看天空替换演示'
|
||||
},
|
||||
'nav.featuredEnterpriseTitle': {
|
||||
en: 'Comfy Enterprise',
|
||||
'zh-CN': 'Comfy 企业版'
|
||||
},
|
||||
'nav.featuredEnterpriseAlt': {
|
||||
en: 'Comfy Enterprise customer story',
|
||||
'zh-CN': 'Comfy 企业版客户故事'
|
||||
},
|
||||
'nav.featuredEnterpriseCtaAria': {
|
||||
en: 'Explore Comfy Enterprise',
|
||||
'zh-CN': '探索 Comfy 企业版'
|
||||
},
|
||||
'nav.featuredCommunityTitle': {
|
||||
en: 'Sky Replacement',
|
||||
'zh-CN': '天空替换'
|
||||
|
||||
@@ -78,3 +78,24 @@ describe('captureDownloadClick', () => {
|
||||
expect(hoisted.mockCapture).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('captureNavigationClick', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('captures the navigation item and placement', async () => {
|
||||
const { initPostHog, captureNavigationClick } = await import('./posthog')
|
||||
initPostHog()
|
||||
captureNavigationClick('customer-stories', 'desktop-enterprise')
|
||||
|
||||
expect(hoisted.mockCapture).toHaveBeenCalledWith(
|
||||
'website:navigation_clicked',
|
||||
{
|
||||
item: 'customer-stories',
|
||||
placement: 'desktop-enterprise'
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -49,3 +49,12 @@ export function captureDownloadClick(platform: Platform) {
|
||||
console.error('PostHog download click capture failed', error)
|
||||
}
|
||||
}
|
||||
|
||||
export function captureNavigationClick(item: string, placement: string) {
|
||||
if (!initialized) return
|
||||
try {
|
||||
posthog.capture('website:navigation_clicked', { item, placement })
|
||||
} catch (error) {
|
||||
console.error('PostHog navigation click capture failed', error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,11 @@ export type ComfyDesktop2TelemetryProperties = Record<
|
||||
ComfyDesktop2TelemetryValue | ComfyDesktop2TelemetryValue[]
|
||||
>
|
||||
|
||||
export type ComfyDesktop2FirebaseAuthState =
|
||||
| { status: 'pending' }
|
||||
| { status: 'signed_out' }
|
||||
| { status: 'signed_in'; userId: string }
|
||||
|
||||
export interface ComfyDesktop2TerminalBridge {
|
||||
subscribe(installationId?: string): Promise<TerminalRestore>
|
||||
unsubscribe(installationId?: string): Promise<void>
|
||||
@@ -60,6 +65,7 @@ export interface ComfyDesktop2LogsBridge {
|
||||
|
||||
export interface ComfyDesktop2TelemetryBridge {
|
||||
capture(event: string, properties?: ComfyDesktop2TelemetryProperties): void
|
||||
reportFirebaseAuthState?(state: ComfyDesktop2FirebaseAuthState): void
|
||||
}
|
||||
|
||||
export interface ComfyDesktop2Bridge {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-desktop-bridge-types",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"description": "TypeScript definitions for the Comfy Desktop hosted frontend bridge",
|
||||
"homepage": "https://comfy.org",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
configValueOrDefault,
|
||||
remoteConfig
|
||||
} from '@/platform/remoteConfig/remoteConfig'
|
||||
import { syncHostUserIdWithFirebaseAuth } from '@/platform/telemetry/hostUserIdSync'
|
||||
import '@/lib/litegraph/public/css/litegraph.css'
|
||||
import router from '@/router'
|
||||
import { isDesktop, isNightly } from '@/platform/distribution/types'
|
||||
@@ -141,6 +142,10 @@ app
|
||||
modules: [VueFireAuth()]
|
||||
})
|
||||
|
||||
if (isCloud && hasHostTelemetryBridge) {
|
||||
syncHostUserIdWithFirebaseAuth()
|
||||
}
|
||||
|
||||
LGraph.proxyWidgetMigrationFlush = (hostNode, nodeData) =>
|
||||
flushProxyWidgetMigration({
|
||||
hostNode,
|
||||
|
||||
183
src/platform/telemetry/hostUserIdSync.test.ts
Normal file
183
src/platform/telemetry/hostUserIdSync.test.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as VueModule from 'vue'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
type MockAuthStore = {
|
||||
isInitialized: boolean
|
||||
currentUser: { uid: string } | null
|
||||
}
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
authStore: null as unknown as MockAuthStore
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/authStore', async () => {
|
||||
const { reactive } = await vi.importActual<typeof VueModule>('vue')
|
||||
hoisted.authStore = reactive<MockAuthStore>({
|
||||
isInitialized: false,
|
||||
currentUser: null
|
||||
})
|
||||
return { useAuthStore: () => hoisted.authStore }
|
||||
})
|
||||
|
||||
import { syncHostUserIdWithFirebaseAuth } from './hostUserIdSync'
|
||||
|
||||
const stopHandles: Array<() => void> = []
|
||||
|
||||
function installTelemetryBridge() {
|
||||
const reportFirebaseAuthState = vi.fn()
|
||||
window.__comfyDesktop2 = {
|
||||
isRemote: () => false,
|
||||
Telemetry: {
|
||||
capture: vi.fn(),
|
||||
reportFirebaseAuthState
|
||||
}
|
||||
}
|
||||
return { reportFirebaseAuthState }
|
||||
}
|
||||
|
||||
function startSync(): void {
|
||||
const stop = syncHostUserIdWithFirebaseAuth()
|
||||
if (stop) stopHandles.push(stop)
|
||||
}
|
||||
|
||||
describe('host user ID sync', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
hoisted.authStore.isInitialized = false
|
||||
hoisted.authStore.currentUser = null
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
while (stopHandles.length) stopHandles.pop()?.()
|
||||
delete window.__comfyDesktop2
|
||||
})
|
||||
|
||||
it('waits for Firebase auth initialization before reporting a user', async () => {
|
||||
const { reportFirebaseAuthState } = installTelemetryBridge()
|
||||
startSync()
|
||||
|
||||
expect(reportFirebaseAuthState).toHaveBeenCalledOnce()
|
||||
expect(reportFirebaseAuthState).toHaveBeenLastCalledWith({
|
||||
status: 'pending'
|
||||
})
|
||||
|
||||
hoisted.authStore.currentUser = { uid: 'firebase-user-a' }
|
||||
await nextTick()
|
||||
|
||||
expect(reportFirebaseAuthState).toHaveBeenCalledOnce()
|
||||
|
||||
hoisted.authStore.isInitialized = true
|
||||
await nextTick()
|
||||
|
||||
expect(reportFirebaseAuthState).toHaveBeenLastCalledWith({
|
||||
status: 'signed_in',
|
||||
userId: 'firebase-user-a'
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a restored Firebase session immediately', () => {
|
||||
const { reportFirebaseAuthState } = installTelemetryBridge()
|
||||
hoisted.authStore.currentUser = { uid: 'firebase-user-a' }
|
||||
hoisted.authStore.isInitialized = true
|
||||
|
||||
startSync()
|
||||
|
||||
expect(reportFirebaseAuthState.mock.calls).toEqual([
|
||||
[{ status: 'pending' }],
|
||||
[{ status: 'signed_in', userId: 'firebase-user-a' }]
|
||||
])
|
||||
})
|
||||
|
||||
it('reports an initially signed-out Firebase session', () => {
|
||||
const { reportFirebaseAuthState } = installTelemetryBridge()
|
||||
hoisted.authStore.isInitialized = true
|
||||
|
||||
startSync()
|
||||
|
||||
expect(reportFirebaseAuthState.mock.calls).toEqual([
|
||||
[{ status: 'pending' }],
|
||||
[{ status: 'signed_out' }]
|
||||
])
|
||||
})
|
||||
|
||||
it('reports signed out when Firebase finishes initialization', async () => {
|
||||
const { reportFirebaseAuthState } = installTelemetryBridge()
|
||||
startSync()
|
||||
|
||||
expect(reportFirebaseAuthState).toHaveBeenCalledOnce()
|
||||
|
||||
hoisted.authStore.isInitialized = true
|
||||
await nextTick()
|
||||
|
||||
expect(reportFirebaseAuthState).toHaveBeenLastCalledWith({
|
||||
status: 'signed_out'
|
||||
})
|
||||
})
|
||||
|
||||
it('reports account switches, logout, and subsequent login', async () => {
|
||||
const { reportFirebaseAuthState } = installTelemetryBridge()
|
||||
hoisted.authStore.currentUser = { uid: 'firebase-user-a' }
|
||||
hoisted.authStore.isInitialized = true
|
||||
startSync()
|
||||
|
||||
hoisted.authStore.currentUser = { uid: 'firebase-user-b' }
|
||||
await nextTick()
|
||||
|
||||
expect(reportFirebaseAuthState).toHaveBeenLastCalledWith({
|
||||
status: 'signed_in',
|
||||
userId: 'firebase-user-b'
|
||||
})
|
||||
|
||||
hoisted.authStore.currentUser = null
|
||||
await nextTick()
|
||||
|
||||
expect(reportFirebaseAuthState).toHaveBeenLastCalledWith({
|
||||
status: 'signed_out'
|
||||
})
|
||||
|
||||
hoisted.authStore.currentUser = { uid: 'firebase-user-c' }
|
||||
await nextTick()
|
||||
|
||||
expect(reportFirebaseAuthState.mock.calls).toEqual([
|
||||
[{ status: 'pending' }],
|
||||
[{ status: 'signed_in', userId: 'firebase-user-a' }],
|
||||
[{ status: 'signed_in', userId: 'firebase-user-b' }],
|
||||
[{ status: 'signed_out' }],
|
||||
[{ status: 'signed_in', userId: 'firebase-user-c' }]
|
||||
])
|
||||
})
|
||||
|
||||
it('does not report again when Firebase replaces the user object with the same UID', async () => {
|
||||
const { reportFirebaseAuthState } = installTelemetryBridge()
|
||||
hoisted.authStore.currentUser = { uid: 'firebase-user-a' }
|
||||
hoisted.authStore.isInitialized = true
|
||||
startSync()
|
||||
|
||||
hoisted.authStore.currentUser = { uid: 'firebase-user-a' }
|
||||
await nextTick()
|
||||
|
||||
expect(reportFirebaseAuthState.mock.calls).toEqual([
|
||||
[{ status: 'pending' }],
|
||||
[{ status: 'signed_in', userId: 'firebase-user-a' }]
|
||||
])
|
||||
})
|
||||
|
||||
it('does not let a host reporting failure interrupt Firebase state sync', async () => {
|
||||
const { reportFirebaseAuthState } = installTelemetryBridge()
|
||||
reportFirebaseAuthState.mockImplementationOnce(() => {
|
||||
throw new Error('host unavailable')
|
||||
})
|
||||
|
||||
expect(() => startSync()).not.toThrow()
|
||||
|
||||
hoisted.authStore.currentUser = { uid: 'firebase-user-a' }
|
||||
hoisted.authStore.isInitialized = true
|
||||
await nextTick()
|
||||
|
||||
expect(reportFirebaseAuthState).toHaveBeenLastCalledWith({
|
||||
status: 'signed_in',
|
||||
userId: 'firebase-user-a'
|
||||
})
|
||||
})
|
||||
})
|
||||
49
src/platform/telemetry/hostUserIdSync.ts
Normal file
49
src/platform/telemetry/hostUserIdSync.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { watch } from 'vue'
|
||||
import type { WatchStopHandle } from 'vue'
|
||||
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
function safelyReportFirebaseAuthState(report: () => void): void {
|
||||
try {
|
||||
report()
|
||||
} catch {
|
||||
// A host bridge failure must not block renderer startup or Firebase auth.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the Desktop main-process telemetry identity aligned with Firebase auth.
|
||||
* Must run after Pinia and VueFire are installed.
|
||||
*/
|
||||
export function syncHostUserIdWithFirebaseAuth(): WatchStopHandle | undefined {
|
||||
const telemetry = window.__comfyDesktop2?.Telemetry
|
||||
if (!telemetry) return
|
||||
|
||||
// Register this Cloud renderer before Firebase resolves. Desktop may host
|
||||
// multiple Cloud main frames whose isolated browser partitions have
|
||||
// different auth states, so main owns all cross-WebContents arbitration.
|
||||
safelyReportFirebaseAuthState(() =>
|
||||
telemetry.reportFirebaseAuthState?.({ status: 'pending' })
|
||||
)
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
return watch(
|
||||
() =>
|
||||
authStore.isInitialized
|
||||
? (authStore.currentUser?.uid ?? null)
|
||||
: undefined,
|
||||
(userId) => {
|
||||
if (userId === undefined) return
|
||||
|
||||
safelyReportFirebaseAuthState(() =>
|
||||
telemetry.reportFirebaseAuthState?.(
|
||||
userId === null
|
||||
? { status: 'signed_out' }
|
||||
: { status: 'signed_in', userId }
|
||||
)
|
||||
)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
const hoisted = vi.hoisted(() => {
|
||||
const analytics = {
|
||||
identify: vi.fn().mockResolvedValue(undefined),
|
||||
page: vi.fn(),
|
||||
track: vi.fn().mockResolvedValue(undefined),
|
||||
reset: vi.fn(),
|
||||
register: vi.fn().mockResolvedValue(undefined)
|
||||
@@ -108,6 +109,89 @@ describe('CustomerIoTelemetryProvider', () => {
|
||||
expect(hoisted.analytics.register).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports the current page after registering the in-app plugin', async () => {
|
||||
const provider = createProvider()
|
||||
provider.trackPageView('workflow_editor', {
|
||||
path: 'https://cloud.comfy.org/'
|
||||
})
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.analytics.page).toHaveBeenCalledOnce()
|
||||
expect(hoisted.analytics.page).toHaveBeenCalledWith()
|
||||
expect(hoisted.analytics.register.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
hoisted.analytics.page.mock.invocationCallOrder[0]
|
||||
)
|
||||
})
|
||||
|
||||
it('queues page views until the in-app plugin is registered', async () => {
|
||||
let resolveRegistration: (() => void) | undefined
|
||||
const registration = new Promise<void>((resolve) => {
|
||||
resolveRegistration = resolve
|
||||
})
|
||||
hoisted.analytics.register.mockReturnValue(registration)
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackPageView('workflow_editor', {
|
||||
path: 'https://cloud.comfy.org/'
|
||||
})
|
||||
expect(hoisted.analytics.page).not.toHaveBeenCalled()
|
||||
|
||||
resolveRegistration?.()
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.page).toHaveBeenCalledOnce()
|
||||
)
|
||||
})
|
||||
|
||||
it('reports client-side route changes', async () => {
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.analytics.page).not.toHaveBeenCalled()
|
||||
|
||||
provider.trackPageView('workflow_editor', {
|
||||
path: 'https://cloud.comfy.org/'
|
||||
})
|
||||
|
||||
expect(hoisted.analytics.page).toHaveBeenCalledOnce()
|
||||
expect(hoisted.analytics.page).toHaveBeenCalledWith()
|
||||
})
|
||||
|
||||
it('continues tracking events and page views when the in-app plugin fails to register', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const registrationError = new Error('in-app setup failed')
|
||||
hoisted.analytics.register.mockRejectedValue(registrationError)
|
||||
const provider = createProvider()
|
||||
provider.trackWorkflowExecution()
|
||||
provider.trackPageView('workflow_editor', {
|
||||
path: 'https://cloud.comfy.org/'
|
||||
})
|
||||
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.analytics.track).toHaveBeenCalledWith(
|
||||
'execution_start',
|
||||
SOURCE
|
||||
)
|
||||
expect(hoisted.analytics.page).toHaveBeenCalledOnce()
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
'Failed to initialize Customer.io in-app plugin:',
|
||||
registrationError
|
||||
)
|
||||
|
||||
provider.trackAddApiCreditButtonClicked()
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.track).toHaveBeenCalledWith(
|
||||
'app:add_api_credit_button_clicked',
|
||||
SOURCE
|
||||
)
|
||||
)
|
||||
provider.trackPageView('settings', {
|
||||
path: 'https://cloud.comfy.org/settings'
|
||||
})
|
||||
expect(hoisted.analytics.page).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not initialize without a write key', async () => {
|
||||
const provider = createProvider({ customer_io: { site_id: SITE_ID } })
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
@@ -8,6 +8,7 @@ import { TelemetryEvents } from '../../types'
|
||||
import type {
|
||||
AuthMetadata,
|
||||
ExecutionSuccessMetadata,
|
||||
PageViewMetadata,
|
||||
ShareFlowMetadata,
|
||||
SubscriptionMetadata,
|
||||
TelemetryEventProperties,
|
||||
@@ -41,7 +42,9 @@ interface CustomerIoIdentity {
|
||||
export class CustomerIoTelemetryProvider implements TelemetryProvider {
|
||||
private analytics: AnalyticsBrowser | null = null
|
||||
private isEnabled = true
|
||||
private isPageViewTrackingReady = false
|
||||
private eventQueue: QueuedEvent[] = []
|
||||
private pageViewQueued = false
|
||||
private identifiedUser: CustomerIoIdentity | null = null
|
||||
private sessionIdentity: CustomerIoIdentity | null = null
|
||||
private operationQueue: Promise<void> = Promise.resolve()
|
||||
@@ -61,7 +64,7 @@ export class CustomerIoTelemetryProvider implements TelemetryProvider {
|
||||
void import('@customerio/cdp-analytics-browser')
|
||||
.then(({ AnalyticsBrowser, InAppPlugin }) => {
|
||||
const analytics = AnalyticsBrowser.load({ writeKey })
|
||||
void analytics.register(
|
||||
const inAppRegistration = analytics.register(
|
||||
InAppPlugin({
|
||||
siteId,
|
||||
events: null,
|
||||
@@ -97,6 +100,17 @@ export class CustomerIoTelemetryProvider implements TelemetryProvider {
|
||||
})
|
||||
|
||||
void this.flushQueue()
|
||||
void inAppRegistration
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
'Failed to initialize Customer.io in-app plugin:',
|
||||
error
|
||||
)
|
||||
})
|
||||
.finally(() => {
|
||||
this.isPageViewTrackingReady = true
|
||||
this.flushPageView()
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to load Customer.io:', error)
|
||||
@@ -198,6 +212,29 @@ export class CustomerIoTelemetryProvider implements TelemetryProvider {
|
||||
})
|
||||
}
|
||||
|
||||
private sendPageView(): void {
|
||||
void this.analytics?.page()?.catch((error) => {
|
||||
console.error('Failed to track Customer.io page view:', error)
|
||||
})
|
||||
}
|
||||
|
||||
private flushPageView(): void {
|
||||
if (!this.isPageViewTrackingReady || !this.pageViewQueued) {
|
||||
return
|
||||
}
|
||||
this.pageViewQueued = false
|
||||
this.sendPageView()
|
||||
}
|
||||
|
||||
trackPageView(_pageName: string, _properties?: PageViewMetadata): void {
|
||||
if (!this.isEnabled) return
|
||||
if (!this.isPageViewTrackingReady) {
|
||||
this.pageViewQueued = true
|
||||
return
|
||||
}
|
||||
this.sendPageView()
|
||||
}
|
||||
|
||||
trackAuth(metadata: AuthMetadata): void {
|
||||
const identity = metadata.user_id
|
||||
? {
|
||||
|
||||
Reference in New Issue
Block a user