Compare commits
10 Commits
comfydesig
...
update-ing
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c864513a03 | ||
|
|
b3c34dd24c | ||
|
|
010389903d | ||
|
|
684b0b08b0 | ||
|
|
95b121bed9 | ||
|
|
baeb6df662 | ||
|
|
e58b231664 | ||
|
|
545b48ee5b | ||
|
|
22ea53fb56 | ||
|
|
9fe5dd51b8 |
4
.github/workflows/pr-cursor-review.yaml
vendored
@@ -29,7 +29,7 @@ jobs:
|
||||
# SHA-pinned per zizmor `unpinned-uses: hash-pin`. Bump this SHA to pick up
|
||||
# upstream changes; keep `workflows_ref` matching so prompts/scripts load
|
||||
# from the same commit as the workflow definition.
|
||||
uses: Comfy-Org/github-workflows/.github/workflows/cursor-review.yml@047ca48febe3a6647608ed2e0c4331b491cb9d6a # github-workflows#9
|
||||
uses: Comfy-Org/github-workflows/.github/workflows/cursor-review.yml@df507e6bae179c567ad3849370f99dae588985dc # github-workflows main (df507e6)
|
||||
with:
|
||||
# Overriding diff_excludes replaces the reusable default wholesale, so
|
||||
# this restates the generated/vendored defaults and adds this repo's heavy
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
:!**/*-snapshots/**
|
||||
:!src/workbench/extensions/manager/types/generatedManagerTypes.ts
|
||||
# Load the prompts/scripts from the same ref as `uses:`.
|
||||
workflows_ref: 047ca48febe3a6647608ed2e0c4331b491cb9d6a
|
||||
workflows_ref: df507e6bae179c567ad3849370f99dae588985dc
|
||||
secrets:
|
||||
CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}
|
||||
# Optional — enables start/complete Slack DMs to the triggerer.
|
||||
|
||||
@@ -40,7 +40,7 @@ test.describe('Cloud page @smoke', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('AIModelsSection heading and 5 model cards are visible', async ({
|
||||
test('AIModelsSection heading and 6 model cards are visible', async ({
|
||||
page
|
||||
}) => {
|
||||
const heading = page.getByRole('heading', { name: /leading AI models/i })
|
||||
@@ -49,7 +49,7 @@ test.describe('Cloud page @smoke', () => {
|
||||
const section = heading.locator('xpath=ancestor::section')
|
||||
const grid = section.locator('.grid')
|
||||
const modelCards = grid.locator('a[href="https://comfy.org/workflows"]')
|
||||
await expect(modelCards).toHaveCount(5)
|
||||
await expect(modelCards).toHaveCount(6)
|
||||
})
|
||||
|
||||
test('AIModelsSection CTA links to workflows', async ({ page }) => {
|
||||
|
||||
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 88 KiB |
@@ -33,7 +33,7 @@ const ctaButtons = [
|
||||
|
||||
<template>
|
||||
<nav
|
||||
class="fixed inset-x-0 top-0 z-50 flex items-center justify-between gap-4 bg-primary-comfy-ink px-6 py-5 lg:gap-4 lg:px-[clamp(0.25rem,4vw,5rem)] lg:py-8"
|
||||
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"
|
||||
>
|
||||
<a
|
||||
|
||||
@@ -30,7 +30,12 @@ const { title, description, cta, href, bg } = defineProps<{
|
||||
<p class="text-sm text-white/70">
|
||||
{{ description }}
|
||||
</p>
|
||||
<Button as="span" variant="default" size="sm" class="mt-4">
|
||||
<Button
|
||||
as="span"
|
||||
variant="default"
|
||||
size="sm"
|
||||
class="mt-4 h-auto whitespace-normal"
|
||||
>
|
||||
{{ cta }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
38
apps/website/src/components/ui/icon-button/IconButton.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import type { PrimitiveProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import type { IconButtonVariants } from '.'
|
||||
import { Primitive } from 'reka-ui'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { iconButtonVariants } from '.'
|
||||
|
||||
interface Props extends PrimitiveProps {
|
||||
variant?: IconButtonVariants['variant']
|
||||
size?: IconButtonVariants['size']
|
||||
class?: HTMLAttributes['class']
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const {
|
||||
as = 'button',
|
||||
asChild,
|
||||
variant,
|
||||
size,
|
||||
class: className,
|
||||
disabled
|
||||
} = defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
data-slot="icon-button"
|
||||
:data-variant="variant"
|
||||
:data-size="size"
|
||||
:as
|
||||
:as-child
|
||||
:disabled
|
||||
:class="cn(iconButtonVariants({ variant, size }), className)"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
28
apps/website/src/components/ui/icon-button/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { VariantProps } from 'class-variance-authority'
|
||||
import { cva } from 'class-variance-authority'
|
||||
|
||||
export const iconButtonVariants = cva(
|
||||
[
|
||||
'focus-visible:border-primary-comfy-yellow focus-visible:ring-primary-comfy-yellow/50 inline-flex shrink-0 cursor-pointer items-center justify-center rounded-2xl transition-all duration-200 outline-none focus-visible:ring-3 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0'
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
ghost:
|
||||
'text-primary-warm-white hover:text-primary-comfy-yellow bg-transparent',
|
||||
outline:
|
||||
'text-primary-comfy-yellow hover:bg-primary-comfy-yellow border-primary-comfy-yellow border-2 bg-primary-comfy-ink hover:text-primary-comfy-ink'
|
||||
},
|
||||
size: {
|
||||
sm: 'size-8',
|
||||
default: 'size-10',
|
||||
lg: 'size-14'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'ghost',
|
||||
size: 'default'
|
||||
}
|
||||
}
|
||||
)
|
||||
export type IconButtonVariants = VariantProps<typeof iconButtonVariants>
|
||||
75
apps/website/src/composables/useBannerDismissal.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import { BANNER_DISMISS_ATTR, BANNER_STORAGE_KEY } from '../utils/banner'
|
||||
|
||||
type ClosedBanners = Record<string, boolean>
|
||||
|
||||
function readClosedBanners(): ClosedBanners {
|
||||
try {
|
||||
const raw = localStorage.getItem(BANNER_STORAGE_KEY)
|
||||
return raw ? (JSON.parse(raw) as ClosedBanners) : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function writeClosedBanners(value: ClosedBanners): void {
|
||||
try {
|
||||
localStorage.setItem(BANNER_STORAGE_KEY, JSON.stringify(value))
|
||||
} catch {
|
||||
// Storage unavailable (private mode / quota) — dismissal just won't persist.
|
||||
}
|
||||
}
|
||||
|
||||
/** The stable part of a version key (everything before `_v<hash>`). */
|
||||
function versionPrefix(version: string): string {
|
||||
const idx = version.lastIndexOf('_v')
|
||||
return idx === -1 ? version : version.slice(0, idx)
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side dismissal persisted in localStorage, keyed by a content-aware
|
||||
* `version`. The banner renders visible in the static HTML (so non-dismissers
|
||||
* see no pop-in); an inline pre-hydration script hides an already-dismissed
|
||||
* banner before paint, and this composable then removes it from the DOM on mount.
|
||||
*/
|
||||
export function useBannerDismissal(version: string) {
|
||||
const isVisible = ref(true)
|
||||
|
||||
onMounted(() => {
|
||||
const stored = readClosedBanners()
|
||||
const prefix = versionPrefix(version)
|
||||
|
||||
// Prune stale versions of THIS banner+locale; keep other banners/locales
|
||||
// and the current version.
|
||||
const cleaned: ClosedBanners = Object.create(null) as ClosedBanners
|
||||
let pruned = false
|
||||
for (const key of Object.keys(stored)) {
|
||||
if (versionPrefix(key) !== prefix || key === version) {
|
||||
cleaned[key] = stored[key]
|
||||
} else {
|
||||
pruned = true
|
||||
}
|
||||
}
|
||||
if (pruned) writeClosedBanners(cleaned)
|
||||
|
||||
isVisible.value = !cleaned[version]
|
||||
})
|
||||
|
||||
function close(): void {
|
||||
isVisible.value = false
|
||||
const stored = readClosedBanners()
|
||||
stored[version] = true
|
||||
writeClosedBanners(stored)
|
||||
}
|
||||
|
||||
// Call once the close transition has finished. Sets the pre-paint hide signal
|
||||
// so the banner doesn't flash back in on a ClientRouter (view-transition)
|
||||
// navigation — where the inline <head> script does not re-run but <html>
|
||||
// persists. Deferred to after the animation so the leave transition can play.
|
||||
function persistHidden(): void {
|
||||
document.documentElement.setAttribute(BANNER_DISMISS_ATTR, '')
|
||||
}
|
||||
|
||||
return { isVisible, close, persistHidden }
|
||||
}
|
||||
84
apps/website/src/config/banner.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { ButtonVariants } from '../components/ui/button'
|
||||
import type { Locale, TranslationKey } from '../i18n/translations'
|
||||
|
||||
import { t } from '../i18n/translations'
|
||||
import { resolveRel } from '../utils/cta'
|
||||
|
||||
// The banner "CMS": a single typed config resolved through i18n at build time.
|
||||
// `isActive` is the master on/off switch (supersedes the old SHOW_ANNOUNCEMENT_BANNER).
|
||||
// NOTE: on this static site, `startsAt`/`endsAt` are evaluated at BUILD time — the
|
||||
// window gates on the last deploy, not the visitor's exact clock.
|
||||
|
||||
interface BannerLinkConfig {
|
||||
readonly href: string
|
||||
readonly titleKey: TranslationKey
|
||||
readonly target?: boolean
|
||||
readonly buttonVariant?: NonNullable<ButtonVariants['variant']>
|
||||
}
|
||||
|
||||
export interface BannerConfig {
|
||||
readonly id: string
|
||||
readonly isActive: boolean
|
||||
readonly startsAt?: string
|
||||
readonly endsAt?: string
|
||||
/** Empty/undefined = all locales. */
|
||||
readonly targetLocales?: readonly Locale[]
|
||||
/** v1 only supports 'sitewide'. */
|
||||
readonly targetSections?: readonly string[]
|
||||
readonly titleKey: TranslationKey
|
||||
readonly descriptionKey?: TranslationKey
|
||||
readonly link?: BannerLinkConfig
|
||||
}
|
||||
|
||||
interface BannerLinkData {
|
||||
readonly href: string
|
||||
readonly title: string
|
||||
readonly target?: '_blank'
|
||||
readonly rel?: string
|
||||
readonly buttonVariant?: NonNullable<ButtonVariants['variant']>
|
||||
}
|
||||
|
||||
export interface BannerData {
|
||||
readonly id: string
|
||||
readonly title: string
|
||||
readonly description?: string
|
||||
readonly link?: BannerLinkData
|
||||
}
|
||||
|
||||
export const bannerConfig: BannerConfig = {
|
||||
id: 'announcement',
|
||||
isActive: true,
|
||||
targetSections: ['sitewide'],
|
||||
titleKey: 'launches.banner.text',
|
||||
link: {
|
||||
href: '/mcp',
|
||||
titleKey: 'launches.banner.cta',
|
||||
buttonVariant: 'underlineLink'
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve a config's i18n keys into display strings for the given locale. */
|
||||
export function getBannerData(
|
||||
config: BannerConfig,
|
||||
locale: Locale
|
||||
): BannerData {
|
||||
const { link } = config
|
||||
const target = link?.target ? '_blank' : undefined
|
||||
|
||||
return {
|
||||
id: config.id,
|
||||
title: t(config.titleKey, locale),
|
||||
description: config.descriptionKey
|
||||
? t(config.descriptionKey, locale)
|
||||
: undefined,
|
||||
link: link
|
||||
? {
|
||||
href: link.href,
|
||||
title: t(link.titleKey, locale),
|
||||
target,
|
||||
rel: resolveRel({ target: target ?? '_self' }),
|
||||
buttonVariant: link.buttonVariant
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
@@ -3983,12 +3983,12 @@ const translations = {
|
||||
// Launches page (/launches) — subscribe banner
|
||||
// zh-CN strings pending native review (see apps/website/.scratch/drops-page/PRD.md)
|
||||
'launches.banner.text': {
|
||||
en: 'Join the live stream. Get answers in real time.',
|
||||
'zh-CN': '加入直播,实时获得解答。'
|
||||
en: 'Now turn your agent into a creative technologist.',
|
||||
'zh-CN': '现在,让你的智能体成为创意技术专家。'
|
||||
},
|
||||
'launches.banner.cta': {
|
||||
en: 'Join livestream',
|
||||
'zh-CN': '加入直播'
|
||||
en: 'Start Comfy MCP',
|
||||
'zh-CN': '启动 Comfy MCP'
|
||||
},
|
||||
|
||||
// Launches page (/launches) — closing CTA
|
||||
|
||||
@@ -5,6 +5,14 @@ import '../styles/global.css'
|
||||
import type { Locale } from '../i18n/translations'
|
||||
import SiteFooter from '../components/common/SiteFooter.vue'
|
||||
import HeaderMain from '../components/common/HeaderMain/HeaderMain.vue'
|
||||
import AnnouncementBanner from '../templates/drops/AnnouncementBanner.vue'
|
||||
import { bannerConfig, getBannerData } from '../config/banner'
|
||||
import {
|
||||
BANNER_DISMISS_ATTR,
|
||||
BANNER_STORAGE_KEY,
|
||||
createBannerVersion,
|
||||
evaluateBannerVisibility
|
||||
} from '../utils/banner'
|
||||
import { escapeJsonLd } from '../utils/escapeJsonLd'
|
||||
import { fetchGitHubStars, formatStarCount } from '../utils/github'
|
||||
|
||||
@@ -34,6 +42,15 @@ const locale: Locale = rawLocale === 'zh-CN' ? 'zh-CN' : 'en'
|
||||
const rawStars = await fetchGitHubStars('Comfy-Org', 'ComfyUI')
|
||||
const githubStars = rawStars ? formatStarCount(rawStars) : ''
|
||||
|
||||
// Announcement banner — build-time visibility gate + content-hash version key.
|
||||
const bannerData = getBannerData(bannerConfig, locale)
|
||||
const bannerVisible = evaluateBannerVisibility(bannerConfig, {
|
||||
currentLocale: locale,
|
||||
currentSection: 'sitewide',
|
||||
now: new Date(),
|
||||
})
|
||||
const bannerVersion = createBannerVersion(bannerData, locale)
|
||||
|
||||
const gtmId = 'GTM-NP9JM6K7'
|
||||
const gtmEnabled = import.meta.env.PROD
|
||||
|
||||
@@ -124,6 +141,25 @@ const websiteJsonLd = {
|
||||
|
||||
<ClientRouter />
|
||||
<slot name="head" />
|
||||
|
||||
<!-- Hide an already-dismissed announcement banner before first paint (no flash/shift). -->
|
||||
{bannerVisible && (
|
||||
<script
|
||||
is:inline
|
||||
define:vars={{
|
||||
bannerVersion,
|
||||
storageKey: BANNER_STORAGE_KEY,
|
||||
dismissAttr: BANNER_DISMISS_ATTR
|
||||
}}
|
||||
>
|
||||
try {
|
||||
const dismissed = JSON.parse(localStorage.getItem(storageKey) || '{}')
|
||||
if (dismissed[bannerVersion]) {
|
||||
document.documentElement.setAttribute(dismissAttr, '')
|
||||
}
|
||||
} catch (e) {}
|
||||
</script>
|
||||
)}
|
||||
</head>
|
||||
<body class="bg-primary-comfy-ink text-white font-formula antialiased overflow-x-clip">
|
||||
{gtmEnabled && (
|
||||
@@ -137,8 +173,16 @@ const websiteJsonLd = {
|
||||
</noscript>
|
||||
)}
|
||||
|
||||
{bannerVisible && (
|
||||
<AnnouncementBanner
|
||||
data={bannerData}
|
||||
version={bannerVersion}
|
||||
locale={locale}
|
||||
client:load
|
||||
/>
|
||||
)}
|
||||
<HeaderMain locale={locale} github-stars={githubStars} client:load />
|
||||
<main class="mt-20 lg:mt-32">
|
||||
<main>
|
||||
<slot />
|
||||
</main>
|
||||
<SiteFooter locale={locale} client:load />
|
||||
|
||||
@@ -3,7 +3,6 @@ import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import CtaSection from '../templates/drops/CtaSection.vue'
|
||||
import DropsSection from '../templates/drops/DropsSection.vue'
|
||||
import HeroSection from '../templates/drops/HeroSection.vue'
|
||||
import SubscribeBanner from '../templates/drops/SubscribeBanner.vue'
|
||||
import { t } from '../i18n/translations'
|
||||
|
||||
const locale = 'en' as const
|
||||
@@ -13,7 +12,6 @@ const locale = 'en' as const
|
||||
title={t('launches.page.title', locale)}
|
||||
description={t('launches.page.description', locale)}
|
||||
>
|
||||
<SubscribeBanner locale={locale} client:load />
|
||||
<HeroSection locale={locale} client:load />
|
||||
<DropsSection locale={locale} />
|
||||
<CtaSection locale={locale} />
|
||||
|
||||
@@ -3,7 +3,6 @@ import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import CtaSection from '../../templates/drops/CtaSection.vue'
|
||||
import DropsSection from '../../templates/drops/DropsSection.vue'
|
||||
import HeroSection from '../../templates/drops/HeroSection.vue'
|
||||
import SubscribeBanner from '../../templates/drops/SubscribeBanner.vue'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const locale = 'zh-CN' as const
|
||||
@@ -13,7 +12,6 @@ const locale = 'zh-CN' as const
|
||||
title={t('launches.page.title', locale)}
|
||||
description={t('launches.page.description', locale)}
|
||||
>
|
||||
<SubscribeBanner locale={locale} client:load />
|
||||
<HeroSection locale={locale} client:load />
|
||||
<DropsSection locale={locale} />
|
||||
<CtaSection locale={locale} />
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
--color-secondary-mauve: #4d3762;
|
||||
--color-destructive: #f44336;
|
||||
--color-primary-comfy-plum: #49378b;
|
||||
--color-secondary-deep-plum: #2b2040;
|
||||
--color-secondary-cool-gray: #3c3c3c;
|
||||
--color-illustration-forest: #20464c;
|
||||
--color-transparency-white-t4: rgb(255 255 255 / 0.04);
|
||||
@@ -93,6 +94,14 @@
|
||||
initial-value: 0deg;
|
||||
}
|
||||
|
||||
/* Pre-hydration hide for a dismissed announcement banner (set by an inline
|
||||
script in BaseLayout head) — prevents any flash before Vue hydrates.
|
||||
The [data-banner-dismissed] literal is BANNER_DISMISS_ATTR in utils/banner.ts;
|
||||
keep them in sync. */
|
||||
[data-banner-dismissed] [data-slot='announcement-banner'] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@keyframes border-angle-spin {
|
||||
to {
|
||||
--border-angle: 360deg;
|
||||
|
||||
107
apps/website/src/templates/drops/AnnouncementBanner.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<script setup lang="ts">
|
||||
import { ArrowRight, X } from '@lucide/vue'
|
||||
|
||||
import type { BannerData } from '../../config/banner'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import IconButton from '@/components/ui/icon-button/IconButton.vue'
|
||||
import { useBannerDismissal } from '../../composables/useBannerDismissal'
|
||||
|
||||
const {
|
||||
data,
|
||||
version,
|
||||
locale = 'en'
|
||||
} = defineProps<{
|
||||
data: BannerData
|
||||
version: string
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const { isVisible, close, persistHidden } = useBannerDismissal(version)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="banner-collapse" @after-leave="persistHidden">
|
||||
<div v-if="isVisible" class="banner-collapse grid">
|
||||
<div class="min-h-0 overflow-hidden">
|
||||
<div
|
||||
data-slot="announcement-banner"
|
||||
class="after:bg-transparency-white-t4 relative flex items-center gap-x-6 px-6 py-4 after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:h-px sm:px-3.5 sm:before:flex-1"
|
||||
style="
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-primary-comfy-plum) 0%,
|
||||
var(--color-secondary-deep-plum) 53.85%,
|
||||
var(--color-secondary-mauve) 100%
|
||||
);
|
||||
"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-x-8 gap-y-2">
|
||||
<p
|
||||
class="text-primary-warm-white ppformula-text-center text-sm md:text-base/6"
|
||||
>
|
||||
{{ data.title }}
|
||||
<span v-if="data.description" class="text-primary-warm-white/80">
|
||||
{{ data.description }}
|
||||
</span>
|
||||
</p>
|
||||
<Button
|
||||
v-if="data.link"
|
||||
as="a"
|
||||
:href="data.link.href"
|
||||
:target="data.link.target"
|
||||
:rel="data.link.rel"
|
||||
:variant="data.link.buttonVariant ?? 'underlineLink'"
|
||||
size="sm"
|
||||
>
|
||||
{{ data.link.title }}
|
||||
<template #append>
|
||||
<ArrowRight class="size-4" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-1 justify-end">
|
||||
<IconButton
|
||||
type="button"
|
||||
:aria-label="t('nav.close', locale)"
|
||||
@click="close"
|
||||
>
|
||||
<X class="size-5" aria-hidden="true" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Collapse the banner's height (grid 1fr → 0fr) so page content below slides
|
||||
up smoothly, with a fade. Enter is defined for symmetry; in practice only the
|
||||
leave (dismiss) runs, since the banner renders present in the static HTML. */
|
||||
.banner-collapse {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.banner-collapse-enter-active,
|
||||
.banner-collapse-leave-active {
|
||||
transition:
|
||||
grid-template-rows 300ms ease,
|
||||
opacity 250ms ease;
|
||||
}
|
||||
|
||||
.banner-collapse-enter-from,
|
||||
.banner-collapse-leave-to {
|
||||
grid-template-rows: 0fr;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.banner-collapse-enter-active,
|
||||
.banner-collapse-leave-active {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,61 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useTimeoutFn } from '@vueuse/core'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { resolveRel } from '../../utils/cta'
|
||||
import { livestream } from './livestream'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const signUpHref = `https://www.youtube.com/watch?v=${livestream.youtubeVideoId}`
|
||||
const signUpRel = resolveRel({ target: '_blank' })
|
||||
|
||||
// Hide once the livestream window closes — both for visitors arriving after
|
||||
// the event and for visitors whose tab is open when it ends.
|
||||
const endMs = new Date(livestream.endDateTime).getTime()
|
||||
const visible = ref(true)
|
||||
|
||||
// useTimeoutFn auto-clears on unmount. Arm it client-side only so SSR never
|
||||
// schedules a long-lived server timer.
|
||||
const { start } = useTimeoutFn(
|
||||
() => {
|
||||
visible.value = false
|
||||
},
|
||||
() => Math.max(0, endMs - Date.now()),
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (endMs - Date.now() <= 0) {
|
||||
visible.value = false
|
||||
} else {
|
||||
start()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="visible" class="px-4">
|
||||
<div
|
||||
class="bg-primary-comfy-plum max-w-8xl rounded-5xl text-primary-warm-white mx-auto flex w-full flex-col items-center justify-center gap-2 px-6 py-5 text-center text-sm sm:flex-row sm:gap-4"
|
||||
>
|
||||
<p class="ppformula-text-center">
|
||||
{{ t('launches.banner.text', locale) }}
|
||||
</p>
|
||||
<Button
|
||||
:href="signUpHref"
|
||||
as="a"
|
||||
variant="underlineLink"
|
||||
size="sm"
|
||||
target="_blank"
|
||||
:rel="signUpRel"
|
||||
>
|
||||
{{ t('launches.banner.cta', locale) }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
109
apps/website/src/utils/banner.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { EvaluableBanner } from './banner'
|
||||
|
||||
import { createBannerVersion, evaluateBannerVisibility } from './banner'
|
||||
|
||||
const base: EvaluableBanner = {
|
||||
isActive: true,
|
||||
targetSections: ['sitewide']
|
||||
}
|
||||
|
||||
const ctx = {
|
||||
currentLocale: 'en',
|
||||
currentSection: 'sitewide',
|
||||
now: new Date('2026-07-06T00:00:00Z')
|
||||
}
|
||||
|
||||
describe('evaluateBannerVisibility', () => {
|
||||
it('shows an active, untargeted, sitewide banner', () => {
|
||||
expect(evaluateBannerVisibility(base, ctx)).toBe(true)
|
||||
})
|
||||
|
||||
it('hides when inactive', () => {
|
||||
expect(evaluateBannerVisibility({ ...base, isActive: false }, ctx)).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('hides before startsAt and shows within the window', () => {
|
||||
expect(
|
||||
evaluateBannerVisibility(
|
||||
{ ...base, startsAt: '2026-07-10T00:00:00Z' },
|
||||
ctx
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
evaluateBannerVisibility(
|
||||
{ ...base, startsAt: '2026-07-01T00:00:00Z' },
|
||||
ctx
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('hides after endsAt', () => {
|
||||
expect(
|
||||
evaluateBannerVisibility({ ...base, endsAt: '2026-07-01T00:00:00Z' }, ctx)
|
||||
).toBe(false)
|
||||
expect(
|
||||
evaluateBannerVisibility({ ...base, endsAt: '2026-07-10T00:00:00Z' }, ctx)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('treats an empty targetLocales as "all locales"', () => {
|
||||
expect(evaluateBannerVisibility({ ...base, targetLocales: [] }, ctx)).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('hides when targetLocales excludes the current locale', () => {
|
||||
expect(
|
||||
evaluateBannerVisibility({ ...base, targetLocales: ['zh-CN'] }, ctx)
|
||||
).toBe(false)
|
||||
expect(
|
||||
evaluateBannerVisibility({ ...base, targetLocales: ['en', 'zh-CN'] }, ctx)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('hides when targetSections does not include the current section', () => {
|
||||
expect(
|
||||
evaluateBannerVisibility({ ...base, targetSections: ['checkout'] }, ctx)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('hides when targetSections is absent (nothing to match)', () => {
|
||||
expect(evaluateBannerVisibility({ isActive: true }, ctx)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createBannerVersion', () => {
|
||||
const content = {
|
||||
id: 'announcement',
|
||||
title: 'Join the live stream',
|
||||
link: { href: 'https://x', title: 'Join' }
|
||||
}
|
||||
|
||||
it('is deterministic for identical content', () => {
|
||||
expect(createBannerVersion(content, 'en')).toBe(
|
||||
createBannerVersion(content, 'en')
|
||||
)
|
||||
})
|
||||
|
||||
it('encodes the banner id and locale in the key', () => {
|
||||
expect(createBannerVersion(content, 'en')).toMatch(
|
||||
/^announcement_en_v-?\d+$/
|
||||
)
|
||||
})
|
||||
|
||||
it('changes when the copy changes', () => {
|
||||
expect(createBannerVersion(content, 'en')).not.toBe(
|
||||
createBannerVersion({ ...content, title: 'New copy' }, 'en')
|
||||
)
|
||||
})
|
||||
|
||||
it('differs per locale so one locale edit does not re-show another', () => {
|
||||
expect(createBannerVersion(content, 'en')).not.toBe(
|
||||
createBannerVersion(content, 'zh-CN')
|
||||
)
|
||||
})
|
||||
})
|
||||
87
apps/website/src/utils/banner.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
// Pure, framework-agnostic banner logic — no Vue/Astro/config imports so it stays
|
||||
// trivially unit-testable. Locale/section are plain strings on purpose.
|
||||
|
||||
// Shared dismissal storage contract. The pre-hydration script in BaseLayout.astro,
|
||||
// the useBannerDismissal composable, and the CSS selector in global.css must all
|
||||
// agree on these literals — keep them here as the single source of truth.
|
||||
export const BANNER_STORAGE_KEY = 'closedBanners'
|
||||
export const BANNER_DISMISS_ATTR = 'data-banner-dismissed'
|
||||
|
||||
export interface BannerVisibilityContext {
|
||||
currentLocale: string
|
||||
currentSection: string
|
||||
now: Date
|
||||
}
|
||||
|
||||
export interface EvaluableBanner {
|
||||
isActive: boolean
|
||||
startsAt?: string
|
||||
endsAt?: string
|
||||
targetLocales?: readonly string[]
|
||||
targetSections?: readonly string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Server/build-time visibility gate. Returns false on the FIRST failing check,
|
||||
* in order: active flag → start window → end window → locale targeting →
|
||||
* section targeting. An empty/absent `targetLocales` means "all locales".
|
||||
*/
|
||||
export function evaluateBannerVisibility(
|
||||
banner: EvaluableBanner,
|
||||
ctx: BannerVisibilityContext
|
||||
): boolean {
|
||||
if (!banner.isActive) return false
|
||||
if (
|
||||
banner.startsAt &&
|
||||
ctx.now.getTime() < new Date(banner.startsAt).getTime()
|
||||
)
|
||||
return false
|
||||
if (banner.endsAt && ctx.now.getTime() > new Date(banner.endsAt).getTime())
|
||||
return false
|
||||
|
||||
const targetLocales = banner.targetLocales ?? []
|
||||
if (targetLocales.length > 0 && !targetLocales.includes(ctx.currentLocale))
|
||||
return false
|
||||
|
||||
const targetSections = banner.targetSections ?? []
|
||||
if (!targetSections.includes(ctx.currentSection)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
interface BannerLinkContent {
|
||||
href: string
|
||||
title: string
|
||||
target?: string
|
||||
rel?: string
|
||||
buttonVariant?: string
|
||||
}
|
||||
|
||||
export interface BannerVersionContent {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
link?: BannerLinkContent
|
||||
}
|
||||
|
||||
/**
|
||||
* Content-aware version key. Editing the copy changes the hash, so a previously
|
||||
* dismissed banner re-appears. Keyed per-locale so a zh-CN edit doesn't re-show
|
||||
* the banner for en visitors. Format: `${content.id}_${locale}_v${hash}`.
|
||||
*/
|
||||
export function createBannerVersion(
|
||||
content: BannerVersionContent,
|
||||
locale: string
|
||||
): string {
|
||||
const contentString = JSON.stringify({
|
||||
locale,
|
||||
title: content.title,
|
||||
description: content.description,
|
||||
link: content.link
|
||||
})
|
||||
let hash = 0
|
||||
for (const char of contentString) {
|
||||
hash = Math.imul(hash, 31) + char.charCodeAt(0)
|
||||
}
|
||||
return `${content.id}_${locale}_v${hash}`
|
||||
}
|
||||
@@ -42,6 +42,11 @@ export type {
|
||||
CancelJobErrors,
|
||||
CancelJobResponse,
|
||||
CancelJobResponses,
|
||||
CancelJobsData,
|
||||
CancelJobsError,
|
||||
CancelJobsErrors,
|
||||
CancelJobsResponse,
|
||||
CancelJobsResponses,
|
||||
CancelSubscriptionData,
|
||||
CancelSubscriptionError,
|
||||
CancelSubscriptionErrors,
|
||||
@@ -79,11 +84,11 @@ export type {
|
||||
CreateAssetFromHashResponses,
|
||||
CreateAssetResponse,
|
||||
CreateAssetResponses,
|
||||
CreateDeletionRequestData,
|
||||
CreateDeletionRequestError,
|
||||
CreateDeletionRequestErrors,
|
||||
CreateDeletionRequestResponse,
|
||||
CreateDeletionRequestResponses,
|
||||
CreateDesktopLoginCodeData,
|
||||
CreateDesktopLoginCodeError,
|
||||
CreateDesktopLoginCodeErrors,
|
||||
CreateDesktopLoginCodeResponse,
|
||||
CreateDesktopLoginCodeResponses,
|
||||
CreateHubAssetUploadUrlData,
|
||||
CreateHubAssetUploadUrlError,
|
||||
CreateHubAssetUploadUrlErrors,
|
||||
@@ -155,9 +160,6 @@ export type {
|
||||
DeleteHubWorkflowErrors,
|
||||
DeleteHubWorkflowResponse,
|
||||
DeleteHubWorkflowResponses,
|
||||
DeleteMonitoringTasksSubpathData,
|
||||
DeleteMonitoringTasksSubpathErrors,
|
||||
DeleteMonitoringTasksSubpathResponses,
|
||||
DeleteSecretData,
|
||||
DeleteSecretError,
|
||||
DeleteSecretErrors,
|
||||
@@ -184,14 +186,23 @@ export type {
|
||||
DeleteWorkspaceErrors,
|
||||
DeleteWorkspaceResponse,
|
||||
DeleteWorkspaceResponses,
|
||||
DeletionRequest,
|
||||
DeletionStatus,
|
||||
DesktopLoginCodeCreateRequest,
|
||||
DesktopLoginCodeCreateResponse,
|
||||
DesktopLoginCodeExchangeRequest,
|
||||
DesktopLoginCodeExchangeResponse,
|
||||
DesktopLoginCodeRedeemRequest,
|
||||
DesktopLoginCodeRedeemResponse,
|
||||
DownloadExportData,
|
||||
DownloadExportError,
|
||||
DownloadExportErrors,
|
||||
DownloadExportResponse,
|
||||
DownloadExportResponses,
|
||||
ErrorResponse,
|
||||
ExchangeDesktopLoginCodeData,
|
||||
ExchangeDesktopLoginCodeError,
|
||||
ExchangeDesktopLoginCodeErrors,
|
||||
ExchangeDesktopLoginCodeResponse,
|
||||
ExchangeDesktopLoginCodeResponses,
|
||||
ExchangeTokenData,
|
||||
ExchangeTokenError,
|
||||
ExchangeTokenErrors,
|
||||
@@ -208,7 +219,6 @@ export type {
|
||||
ExportDownloadUrlResponse,
|
||||
FeedbackRequest,
|
||||
FeedbackResponse,
|
||||
FeedbackResponseWritable,
|
||||
ForkWorkflowData,
|
||||
ForkWorkflowError,
|
||||
ForkWorkflowErrors,
|
||||
@@ -230,6 +240,11 @@ export type {
|
||||
GetAssetByIdErrors,
|
||||
GetAssetByIdResponse,
|
||||
GetAssetByIdResponses,
|
||||
GetAssetContentData,
|
||||
GetAssetContentError,
|
||||
GetAssetContentErrors,
|
||||
GetAssetContentResponse,
|
||||
GetAssetContentResponses,
|
||||
GetAssetSeedStatusData,
|
||||
GetAssetSeedStatusResponse,
|
||||
GetAssetSeedStatusResponses,
|
||||
@@ -266,11 +281,6 @@ export type {
|
||||
GetCustomNodeProxyData,
|
||||
GetCustomNodeProxyErrors,
|
||||
GetCustomNodeProxyResponses,
|
||||
GetDeletionRequestData,
|
||||
GetDeletionRequestError,
|
||||
GetDeletionRequestErrors,
|
||||
GetDeletionRequestResponse,
|
||||
GetDeletionRequestResponses,
|
||||
GetEmbeddingsData,
|
||||
GetEmbeddingsResponse,
|
||||
GetEmbeddingsResponses,
|
||||
@@ -280,11 +290,6 @@ export type {
|
||||
GetFeaturesData,
|
||||
GetFeaturesResponse,
|
||||
GetFeaturesResponses,
|
||||
GetFilesData,
|
||||
GetFilesError,
|
||||
GetFilesErrors,
|
||||
GetFilesResponse,
|
||||
GetFilesResponses,
|
||||
GetGlobalSubgraphData,
|
||||
GetGlobalSubgraphError,
|
||||
GetGlobalSubgraphErrors,
|
||||
@@ -345,8 +350,6 @@ export type {
|
||||
GetJwksData,
|
||||
GetJwksResponse,
|
||||
GetJwksResponses,
|
||||
GetLegacyAssetContentData,
|
||||
GetLegacyAssetContentErrors,
|
||||
GetLegacyHistoryByIdData,
|
||||
GetLegacyHistoryByIdErrors,
|
||||
GetLegacyHistoryData,
|
||||
@@ -367,11 +370,6 @@ export type {
|
||||
GetLegacyUserdataV2Errors,
|
||||
GetLegacyViewMetadataData,
|
||||
GetLegacyViewMetadataErrors,
|
||||
GetLogsData,
|
||||
GetLogsError,
|
||||
GetLogsErrors,
|
||||
GetLogsResponse,
|
||||
GetLogsResponses,
|
||||
GetMaskLayersData,
|
||||
GetMaskLayersError,
|
||||
GetMaskLayersErrors,
|
||||
@@ -389,12 +387,6 @@ export type {
|
||||
GetModelsInFolderErrors,
|
||||
GetModelsInFolderResponse,
|
||||
GetModelsInFolderResponses,
|
||||
GetMonitoringTasksData,
|
||||
GetMonitoringTasksErrors,
|
||||
GetMonitoringTasksResponses,
|
||||
GetMonitoringTasksSubpathData,
|
||||
GetMonitoringTasksSubpathErrors,
|
||||
GetMonitoringTasksSubpathResponses,
|
||||
GetMyHubProfileData,
|
||||
GetMyHubProfileError,
|
||||
GetMyHubProfileErrors,
|
||||
@@ -438,12 +430,6 @@ export type {
|
||||
GetPaymentPortalErrors,
|
||||
GetPaymentPortalResponse,
|
||||
GetPaymentPortalResponses,
|
||||
GetPprofData,
|
||||
GetPprofProfileData,
|
||||
GetPprofProfileResponses,
|
||||
GetPprofResponses,
|
||||
GetPprofTraceData,
|
||||
GetPprofTraceResponses,
|
||||
GetPromptInfoData,
|
||||
GetPromptInfoError,
|
||||
GetPromptInfoErrors,
|
||||
@@ -577,11 +563,6 @@ export type {
|
||||
ImportPublishedAssetsResponse,
|
||||
ImportPublishedAssetsResponse2,
|
||||
ImportPublishedAssetsResponses,
|
||||
InsertDynamicConfigData,
|
||||
InsertDynamicConfigError,
|
||||
InsertDynamicConfigErrors,
|
||||
InsertDynamicConfigResponse,
|
||||
InsertDynamicConfigResponses,
|
||||
InterruptJobData,
|
||||
InterruptJobError,
|
||||
InterruptJobErrors,
|
||||
@@ -589,6 +570,8 @@ export type {
|
||||
JobCancelResponse,
|
||||
JobDetailResponse,
|
||||
JobEntry,
|
||||
JobsCancelRequest,
|
||||
JobsCancelResponse,
|
||||
JobsListResponse,
|
||||
JobStatusResponse,
|
||||
JwkKey,
|
||||
@@ -628,6 +611,11 @@ export type {
|
||||
ListJobsResponse,
|
||||
ListJobsResponses,
|
||||
ListMembersResponse,
|
||||
ListSecretProvidersData,
|
||||
ListSecretProvidersError,
|
||||
ListSecretProvidersErrors,
|
||||
ListSecretProvidersResponse,
|
||||
ListSecretProvidersResponses,
|
||||
ListSecretsData,
|
||||
ListSecretsError,
|
||||
ListSecretsErrors,
|
||||
@@ -671,7 +659,6 @@ export type {
|
||||
ListWorkspacesResponse,
|
||||
ListWorkspacesResponse2,
|
||||
ListWorkspacesResponses,
|
||||
LogsResponse,
|
||||
ManageHistoryData,
|
||||
ManageHistoryError,
|
||||
ManageHistoryErrors,
|
||||
@@ -702,8 +689,6 @@ export type {
|
||||
OAuthTokenError,
|
||||
OAuthTokenResponse,
|
||||
PaginationInfo,
|
||||
PartnerUsageRequest,
|
||||
PartnerUsageResponse,
|
||||
PaymentPortalRequest,
|
||||
PaymentPortalResponse,
|
||||
PendingInvite,
|
||||
@@ -719,9 +704,6 @@ export type {
|
||||
PostCustomNodeProxyData,
|
||||
PostCustomNodeProxyErrors,
|
||||
PostCustomNodeProxyResponses,
|
||||
PostMonitoringTasksSubpathData,
|
||||
PostMonitoringTasksSubpathErrors,
|
||||
PostMonitoringTasksSubpathResponses,
|
||||
PostOAuthAuthorizeData,
|
||||
PostOAuthAuthorizeError,
|
||||
PostOAuthAuthorizeErrors,
|
||||
@@ -737,8 +719,6 @@ export type {
|
||||
PostOAuthTokenErrors,
|
||||
PostOAuthTokenResponse,
|
||||
PostOAuthTokenResponses,
|
||||
PostPprofSymbolData,
|
||||
PostPprofSymbolResponses,
|
||||
PostUserdataFileData,
|
||||
PostUserdataFileError,
|
||||
PostUserdataFileErrors,
|
||||
@@ -775,6 +755,11 @@ export type {
|
||||
QueueInfo,
|
||||
QueueManageRequest,
|
||||
QueueManageResponse,
|
||||
RedeemDesktopLoginCodeData,
|
||||
RedeemDesktopLoginCodeError,
|
||||
RedeemDesktopLoginCodeErrors,
|
||||
RedeemDesktopLoginCodeResponse,
|
||||
RedeemDesktopLoginCodeResponses,
|
||||
RemoveAssetTagsData,
|
||||
RemoveAssetTagsError,
|
||||
RemoveAssetTagsErrors,
|
||||
@@ -785,11 +770,6 @@ export type {
|
||||
RemoveWorkspaceMemberErrors,
|
||||
RemoveWorkspaceMemberResponse,
|
||||
RemoveWorkspaceMemberResponses,
|
||||
ReportPartnerUsageData,
|
||||
ReportPartnerUsageError,
|
||||
ReportPartnerUsageErrors,
|
||||
ReportPartnerUsageResponse,
|
||||
ReportPartnerUsageResponses,
|
||||
ResubscribeData,
|
||||
ResubscribeError,
|
||||
ResubscribeErrors,
|
||||
@@ -808,17 +788,12 @@ export type {
|
||||
RevokeWorkspaceInviteResponse,
|
||||
RevokeWorkspaceInviteResponses,
|
||||
SecretListResponse,
|
||||
SecretProvider,
|
||||
SecretProvidersResponse,
|
||||
SecretResponse,
|
||||
SeedAssetsData,
|
||||
SeedAssetsResponse,
|
||||
SeedAssetsResponses,
|
||||
SetReviewStatusData,
|
||||
SetReviewStatusError,
|
||||
SetReviewStatusErrors,
|
||||
SetReviewStatusRequest,
|
||||
SetReviewStatusResponse,
|
||||
SetReviewStatusResponse2,
|
||||
SetReviewStatusResponses,
|
||||
SubmitFeedbackData,
|
||||
SubmitFeedbackError,
|
||||
SubmitFeedbackErrors,
|
||||
@@ -835,19 +810,16 @@ export type {
|
||||
SubscribeToLogsResponses,
|
||||
SubscriptionDuration,
|
||||
SubscriptionTier,
|
||||
SyncApiKeyData,
|
||||
SyncApiKeyError,
|
||||
SyncApiKeyErrors,
|
||||
SyncApiKeyRequest,
|
||||
SyncApiKeyResponse,
|
||||
SyncApiKeyResponse2,
|
||||
SyncApiKeyResponses,
|
||||
SystemStatsResponse,
|
||||
TagInfo,
|
||||
TagsModificationResponse,
|
||||
TaskEntry,
|
||||
TaskResponse,
|
||||
TasksListResponse,
|
||||
TeamCreditStop,
|
||||
TeamCreditStopPrice,
|
||||
TeamCreditStops,
|
||||
TeamCreditStopSummary,
|
||||
UpdateAssetData,
|
||||
UpdateAssetError,
|
||||
UpdateAssetErrors,
|
||||
@@ -859,12 +831,7 @@ export type {
|
||||
UpdateHubProfileRequest,
|
||||
UpdateHubProfileResponse,
|
||||
UpdateHubProfileResponses,
|
||||
UpdateHubWorkflowData,
|
||||
UpdateHubWorkflowError,
|
||||
UpdateHubWorkflowErrors,
|
||||
UpdateHubWorkflowRequest,
|
||||
UpdateHubWorkflowResponse,
|
||||
UpdateHubWorkflowResponses,
|
||||
UpdateMemberRoleRequest,
|
||||
UpdateMultipleSettingsData,
|
||||
UpdateMultipleSettingsError,
|
||||
UpdateMultipleSettingsErrors,
|
||||
@@ -881,11 +848,6 @@ export type {
|
||||
UpdateSettingByIdErrors,
|
||||
UpdateSettingByIdResponse,
|
||||
UpdateSettingByIdResponses,
|
||||
UpdateSubscriptionCacheData,
|
||||
UpdateSubscriptionCacheError,
|
||||
UpdateSubscriptionCacheErrors,
|
||||
UpdateSubscriptionCacheResponse,
|
||||
UpdateSubscriptionCacheResponses,
|
||||
UpdateWorkflowData,
|
||||
UpdateWorkflowError,
|
||||
UpdateWorkflowErrors,
|
||||
@@ -895,6 +857,11 @@ export type {
|
||||
UpdateWorkspaceData,
|
||||
UpdateWorkspaceError,
|
||||
UpdateWorkspaceErrors,
|
||||
UpdateWorkspaceMemberRoleData,
|
||||
UpdateWorkspaceMemberRoleError,
|
||||
UpdateWorkspaceMemberRoleErrors,
|
||||
UpdateWorkspaceMemberRoleResponse,
|
||||
UpdateWorkspaceMemberRoleResponses,
|
||||
UpdateWorkspaceRequest,
|
||||
UpdateWorkspaceResponse,
|
||||
UpdateWorkspaceResponses,
|
||||
@@ -912,13 +879,6 @@ export type {
|
||||
UserResponse,
|
||||
ValidationError,
|
||||
ValidationResult,
|
||||
VerifyApiKeyRequest,
|
||||
VerifyApiKeyResponse,
|
||||
VerifyWorkspaceApiKeyData,
|
||||
VerifyWorkspaceApiKeyError,
|
||||
VerifyWorkspaceApiKeyErrors,
|
||||
VerifyWorkspaceApiKeyResponse,
|
||||
VerifyWorkspaceApiKeyResponses,
|
||||
ViewFileData,
|
||||
ViewFileError,
|
||||
ViewFileErrors,
|
||||
|
||||
14621
packages/ingest-types/src/types.gen.ts
generated
6052
packages/ingest-types/src/zod.gen.ts
generated
@@ -4,5 +4,5 @@
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src/**/*", "*.config.ts"]
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src/**/*", "vitest.config.ts"]
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
|
||||
@@ -35,10 +35,10 @@
|
||||
:class="
|
||||
sidebarLocation === 'left'
|
||||
? cn(
|
||||
'side-bar-panel pointer-events-auto bg-comfy-menu-bg',
|
||||
'side-bar-panel pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden',
|
||||
sidebarPanelVisible && 'min-w-78'
|
||||
)
|
||||
: 'pointer-events-auto bg-comfy-menu-bg'
|
||||
: 'pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden'
|
||||
"
|
||||
:min-size="
|
||||
sidebarLocation === 'left' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
|
||||
@@ -82,7 +82,7 @@
|
||||
</SplitterPanel>
|
||||
<SplitterPanel
|
||||
v-show="bottomPanelVisible && !focusMode"
|
||||
class="bottom-panel pointer-events-auto max-w-full overflow-x-auto rounded-lg border border-(--p-panel-border-color) bg-comfy-menu-bg"
|
||||
class="bottom-panel pointer-events-auto max-w-full overflow-x-auto rounded-lg border border-(--p-panel-border-color) bg-comfy-menu-bg focus-visible:outline-hidden"
|
||||
>
|
||||
<slot name="bottom-panel" />
|
||||
</SplitterPanel>
|
||||
@@ -95,10 +95,10 @@
|
||||
:class="
|
||||
sidebarLocation === 'right'
|
||||
? cn(
|
||||
'side-bar-panel pointer-events-auto bg-comfy-menu-bg',
|
||||
'side-bar-panel pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden',
|
||||
sidebarPanelVisible && 'min-w-78'
|
||||
)
|
||||
: 'pointer-events-auto bg-comfy-menu-bg'
|
||||
: 'pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden'
|
||||
"
|
||||
:min-size="
|
||||
sidebarLocation === 'right' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
|
||||
|
||||
@@ -7,7 +7,7 @@ import Password from 'primevue/password'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, h, nextTick, ref } from 'vue'
|
||||
import { computed, defineComponent, h, nextTick, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
@@ -38,29 +38,45 @@ vi.mock('@/stores/authStore', () => ({
|
||||
}))
|
||||
|
||||
const mockTurnstileEnabled = ref(false)
|
||||
const mockTurnstileEnforced = ref(false)
|
||||
const mockTurnstileToken = ref('')
|
||||
const mockTurnstileUnavailable = ref(false)
|
||||
const mockReset = vi.fn()
|
||||
let emitTurnstileToken: ((token: string) => void) | undefined
|
||||
let emitTurnstileUnavailable: ((unavailable: boolean) => void) | undefined
|
||||
|
||||
// The reset-on-toggle behavior lives in useTurnstileGate itself (see
|
||||
// useTurnstile.test.ts); this fake just wires token/unavailable through to
|
||||
// `waiting` the same way so SignUpForm's submit gating can be exercised.
|
||||
vi.mock('@/composables/auth/useTurnstile', () => ({
|
||||
useTurnstile: () => ({
|
||||
enabled: mockTurnstileEnabled,
|
||||
enforced: mockTurnstileEnforced
|
||||
enabled: mockTurnstileEnabled
|
||||
}),
|
||||
useTurnstileGate: () => ({
|
||||
token: mockTurnstileToken,
|
||||
unavailable: mockTurnstileUnavailable,
|
||||
waiting: computed(
|
||||
() =>
|
||||
mockTurnstileEnabled.value &&
|
||||
!mockTurnstileToken.value &&
|
||||
!mockTurnstileUnavailable.value
|
||||
)
|
||||
})
|
||||
}))
|
||||
|
||||
// Stub the real widget (which loads the external Turnstile script) with one that
|
||||
// exposes a spyable reset() and lets a test drive the v-model token the way a
|
||||
// solved challenge would.
|
||||
// exposes a spyable reset() and lets a test drive the v-model token/unavailable
|
||||
// the way a solved challenge (or a broken/slow widget) would.
|
||||
vi.mock('./TurnstileWidget.vue', async () => {
|
||||
const { defineComponent: defineMock } = await import('vue')
|
||||
return {
|
||||
default: defineMock({
|
||||
name: 'TurnstileWidget',
|
||||
emits: ['update:token'],
|
||||
emits: ['update:token', 'update:unavailable'],
|
||||
setup(_, { expose, emit }) {
|
||||
expose({ reset: mockReset })
|
||||
emitTurnstileToken = (token: string) => emit('update:token', token)
|
||||
emitTurnstileUnavailable = (unavailable: boolean) =>
|
||||
emit('update:unavailable', unavailable)
|
||||
return () => null
|
||||
}
|
||||
})
|
||||
@@ -92,9 +108,11 @@ describe('SignUpForm', () => {
|
||||
beforeEach(() => {
|
||||
mockLoadingRef.value = false
|
||||
mockTurnstileEnabled.value = false
|
||||
mockTurnstileEnforced.value = false
|
||||
mockTurnstileToken.value = ''
|
||||
mockTurnstileUnavailable.value = false
|
||||
mockReset.mockClear()
|
||||
emitTurnstileToken = undefined
|
||||
emitTurnstileUnavailable = undefined
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -211,43 +229,22 @@ describe('SignUpForm', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Turnstile token hygiene', () => {
|
||||
it('clears the stale token when Turnstile becomes disabled', async () => {
|
||||
mockTurnstileEnabled.value = true
|
||||
mockTurnstileEnforced.value = true
|
||||
const { user } = renderComponent()
|
||||
await fillValidSignup(user)
|
||||
|
||||
emitTurnstileToken!('stale-token')
|
||||
await nextTick()
|
||||
expect(
|
||||
screen.getByRole('button', { name: signUpButton })
|
||||
).not.toBeDisabled()
|
||||
|
||||
mockTurnstileEnabled.value = false
|
||||
await nextTick()
|
||||
|
||||
// re-enable: the stale token must have been cleared so submit is blocked again
|
||||
mockTurnstileEnabled.value = true
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByRole('button', { name: signUpButton })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
// Regression coverage for the shadow-mode race: previously submit was only
|
||||
// gated in 'enforce' mode, so most real signups in 'shadow' mode raced
|
||||
// ahead of the async Cloudflare challenge and reached the backend with an
|
||||
// empty token. Gating now depends only on whether the widget is enabled
|
||||
// (shadow or enforce both render it), so both modes behave identically here.
|
||||
describe('Turnstile submit gating', () => {
|
||||
it('disables the submit button in enforce mode until a token is present', async () => {
|
||||
it('disables the submit button until a token is present', async () => {
|
||||
mockTurnstileEnabled.value = true
|
||||
mockTurnstileEnforced.value = true
|
||||
renderComponent()
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByRole('button', { name: signUpButton })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('does not emit submit in enforce mode while the token is empty', async () => {
|
||||
it('does not emit submit while the token is empty', async () => {
|
||||
mockTurnstileEnabled.value = true
|
||||
mockTurnstileEnforced.value = true
|
||||
const onSubmit = vi.fn()
|
||||
const { user } = renderComponent({ onSubmit })
|
||||
await fillValidSignup(user)
|
||||
@@ -257,9 +254,8 @@ describe('SignUpForm', () => {
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('emits submit with the token in enforce mode once the challenge is solved', async () => {
|
||||
it('emits submit with the token once the challenge is solved', async () => {
|
||||
mockTurnstileEnabled.value = true
|
||||
mockTurnstileEnforced.value = true
|
||||
const onSubmit = vi.fn()
|
||||
const { user } = renderComponent({ onSubmit })
|
||||
await fillValidSignup(user)
|
||||
@@ -271,13 +267,14 @@ describe('SignUpForm', () => {
|
||||
expect(onSubmit).toHaveBeenCalledWith(expectedValues, 'token-xyz')
|
||||
})
|
||||
|
||||
it('emits submit without a token in shadow mode (never blocks)', async () => {
|
||||
it('emits submit without a token once the widget reports itself unavailable (broken/slow load fallback)', async () => {
|
||||
mockTurnstileEnabled.value = true
|
||||
mockTurnstileEnforced.value = false
|
||||
const onSubmit = vi.fn()
|
||||
const { user } = renderComponent({ onSubmit })
|
||||
await fillValidSignup(user)
|
||||
|
||||
emitTurnstileUnavailable!(true)
|
||||
await nextTick()
|
||||
await user.click(screen.getByRole('button', { name: signUpButton }))
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith(expectedValues, undefined)
|
||||
|
||||
@@ -33,10 +33,11 @@
|
||||
v-if="turnstileEnabled"
|
||||
ref="turnstileWidget"
|
||||
v-model:token="turnstileToken"
|
||||
v-model:unavailable="turnstileUnavailable"
|
||||
/>
|
||||
|
||||
<small
|
||||
v-show="submitBlockedByTurnstile"
|
||||
v-show="waitingForTurnstile"
|
||||
id="comfy-org-sign-up-turnstile-hint"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
@@ -51,11 +52,9 @@
|
||||
v-else
|
||||
type="submit"
|
||||
class="mt-4 h-10 font-medium"
|
||||
:disabled="!$form.valid || submitBlockedByTurnstile"
|
||||
:disabled="!$form.valid || waitingForTurnstile"
|
||||
:aria-describedby="
|
||||
submitBlockedByTurnstile
|
||||
? 'comfy-org-sign-up-turnstile-hint'
|
||||
: undefined
|
||||
waitingForTurnstile ? 'comfy-org-sign-up-turnstile-hint' : undefined
|
||||
"
|
||||
>
|
||||
{{ t('auth.signup.signUpButton') }}
|
||||
@@ -70,11 +69,11 @@ import { zodResolver } from '@primevue/forms/resolvers/zod'
|
||||
import { useThrottleFn } from '@vueuse/core'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed, ref, useTemplateRef, watch } from 'vue'
|
||||
import { computed, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTurnstile } from '@/composables/auth/useTurnstile'
|
||||
import { useTurnstile, useTurnstileGate } from '@/composables/auth/useTurnstile'
|
||||
import { signUpSchema } from '@/schemas/signInSchema'
|
||||
import type { SignUpData } from '@/schemas/signInSchema'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
@@ -86,25 +85,21 @@ const { t } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
const loading = computed(() => authStore.loading)
|
||||
|
||||
const { enabled: turnstileEnabled, enforced: turnstileEnforced } =
|
||||
useTurnstile()
|
||||
const turnstileToken = ref('')
|
||||
const { enabled: turnstileEnabled } = useTurnstile()
|
||||
const {
|
||||
token: turnstileToken,
|
||||
unavailable: turnstileUnavailable,
|
||||
waiting: waitingForTurnstile
|
||||
} = useTurnstileGate(turnstileEnabled)
|
||||
const turnstileWidget =
|
||||
useTemplateRef<InstanceType<typeof TurnstileWidget>>('turnstileWidget')
|
||||
const submitBlockedByTurnstile = computed(
|
||||
() => turnstileEnforced.value && !turnstileToken.value
|
||||
)
|
||||
|
||||
watch(turnstileEnabled, (on) => {
|
||||
if (!on) turnstileToken.value = ''
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [values: SignUpData, turnstileToken?: string]
|
||||
}>()
|
||||
|
||||
const onSubmit = useThrottleFn((event: FormSubmitEvent) => {
|
||||
if (event.valid && !submitBlockedByTurnstile.value) {
|
||||
if (event.valid && !waitingForTurnstile.value) {
|
||||
emit(
|
||||
'submit',
|
||||
event.values as SignUpData,
|
||||
|
||||
@@ -261,4 +261,138 @@ describe('TurnstileWidget', () => {
|
||||
|
||||
expect(api.remove).toHaveBeenCalledWith('widget-id')
|
||||
})
|
||||
|
||||
// A widget that never resolves (broken script, ad-blocker, CDN outage, or a
|
||||
// hung challenge) must eventually tell the parent it cannot be relied on,
|
||||
// so submission can fall back instead of blocking a legitimate signup
|
||||
// forever.
|
||||
describe('unavailable fallback', () => {
|
||||
it('reports unavailable when the Turnstile script fails to load', async () => {
|
||||
mockLoadTurnstile.mockRejectedValue(new Error('script failed'))
|
||||
|
||||
const { emitted } = renderWidget()
|
||||
await flush()
|
||||
|
||||
expect(emitted()['update:unavailable']?.at(-1)).toEqual([true])
|
||||
})
|
||||
|
||||
it('reports unavailable on a challenge error', async () => {
|
||||
const { api, options } = fakeTurnstile()
|
||||
mockLoadTurnstile.mockResolvedValue(api)
|
||||
|
||||
const { emitted } = renderWidget()
|
||||
await flush()
|
||||
|
||||
options()!['error-callback']!()
|
||||
await flush()
|
||||
|
||||
expect(emitted()['update:unavailable']?.at(-1)).toEqual([true])
|
||||
})
|
||||
|
||||
it('clears the unavailable fallback once a token is solved', async () => {
|
||||
const { api, options } = fakeTurnstile()
|
||||
mockLoadTurnstile.mockResolvedValue(api)
|
||||
|
||||
const { emitted } = renderWidget()
|
||||
await flush()
|
||||
|
||||
options()!['error-callback']!()
|
||||
await flush()
|
||||
expect(emitted()['update:unavailable']?.at(-1)).toEqual([true])
|
||||
|
||||
options()!.callback!('token-abc')
|
||||
await flush()
|
||||
|
||||
expect(emitted()['update:unavailable']?.at(-1)).toEqual([false])
|
||||
})
|
||||
|
||||
it('falls back once the widget fails to resolve within the load timeout', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { api, options } = fakeTurnstile()
|
||||
mockLoadTurnstile.mockResolvedValue(api)
|
||||
|
||||
const { emitted } = renderWidget()
|
||||
// Let the onMounted hook's `await loadTurnstile()` microtask settle
|
||||
// and render() run, without yet advancing to the timeout itself.
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(options()).toBeDefined()
|
||||
expect(emitted()['update:unavailable']).toBeUndefined()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(9_000)
|
||||
|
||||
expect(emitted()['update:unavailable']?.at(-1)).toEqual([true])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not fall back once a token arrives before the load timeout', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { api, options } = fakeTurnstile()
|
||||
mockLoadTurnstile.mockResolvedValue(api)
|
||||
|
||||
const { emitted } = renderWidget()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
options()!.callback!('token-abc')
|
||||
await vi.advanceTimersByTimeAsync(9_000)
|
||||
|
||||
expect(emitted()['update:unavailable']).toBeUndefined()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('resets the widget to fetch a fresh challenge on token expiry', async () => {
|
||||
const { api, options } = fakeTurnstile()
|
||||
mockLoadTurnstile.mockResolvedValue(api)
|
||||
window.turnstile = api as unknown as NonNullable<Window['turnstile']>
|
||||
|
||||
renderWidget()
|
||||
await flush()
|
||||
|
||||
options()!.callback!('token-abc')
|
||||
options()!['expired-callback']!()
|
||||
await flush()
|
||||
|
||||
expect(api.reset).toHaveBeenCalledWith('widget-id')
|
||||
})
|
||||
|
||||
it('falls back if a post-solve expiry is not followed by a fresh token within the load timeout', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { api, options } = fakeTurnstile()
|
||||
mockLoadTurnstile.mockResolvedValue(api)
|
||||
window.turnstile = api as unknown as NonNullable<Window['turnstile']>
|
||||
|
||||
const { emitted } = renderWidget()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
// Establish a solved, available widget: an initial error marks it
|
||||
// unavailable, then solving a challenge clears that (the same
|
||||
// transition the existing "clears the unavailable fallback" test
|
||||
// verifies), so the expiry below is the only thing driving fallback.
|
||||
options()!['error-callback']!()
|
||||
options()!.callback!('token-abc')
|
||||
expect(emitted()['update:unavailable']?.at(-1)).toEqual([false])
|
||||
|
||||
// The token later expires (e.g. tab backgrounded past its ~300s
|
||||
// lifetime) without the widget itself erroring.
|
||||
options()!['expired-callback']!()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
// A fresh challenge was requested, but nothing solves it before the
|
||||
// re-armed load timeout elapses, so submission must eventually be
|
||||
// unblocked rather than staying stuck forever.
|
||||
expect(emitted()['update:unavailable']?.at(-1)).toEqual([false])
|
||||
await vi.advanceTimersByTimeAsync(9_000)
|
||||
|
||||
expect(emitted()['update:unavailable']?.at(-1)).toEqual([true])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTimeoutFn } from '@vueuse/core'
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -20,6 +21,14 @@ import { getTurnstileSiteKey } from '@/config/turnstile'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
|
||||
const token = defineModel<string>('token', { default: '' })
|
||||
/**
|
||||
* Set true whenever the widget cannot be relied on to ever produce a token:
|
||||
* the Cloudflare script failed to load, the rendered challenge errored out,
|
||||
* or it simply hasn't resolved within `TURNSTILE_LOAD_TIMEOUT_MS`. The parent
|
||||
* uses this to stop waiting on a token so a broken/slow widget (network
|
||||
* issue, ad-blocker, CDN outage) can never permanently block signup.
|
||||
*/
|
||||
const unavailable = defineModel<boolean>('unavailable', { default: false })
|
||||
|
||||
const { t } = useI18n()
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
@@ -28,6 +37,16 @@ const containerRef = ref<HTMLDivElement>()
|
||||
const errorMessage = ref('')
|
||||
let widgetId: string | undefined
|
||||
|
||||
/** How long to wait for the widget to resolve before falling back. */
|
||||
const TURNSTILE_LOAD_TIMEOUT_MS = 9_000
|
||||
const { start: armTimeout, stop: clearLoadTimeout } = useTimeoutFn(
|
||||
() => {
|
||||
unavailable.value = true
|
||||
},
|
||||
TURNSTILE_LOAD_TIMEOUT_MS,
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
const clearToken = () => {
|
||||
token.value = ''
|
||||
}
|
||||
@@ -46,12 +65,18 @@ const reset = () => {
|
||||
errorMessage.value = ''
|
||||
if (widgetId && window.turnstile) {
|
||||
window.turnstile.reset(widgetId)
|
||||
// A widget that renders can request a fresh challenge, so give it
|
||||
// another chance before falling back again.
|
||||
unavailable.value = false
|
||||
armTimeout()
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ reset })
|
||||
|
||||
onMounted(async () => {
|
||||
armTimeout()
|
||||
|
||||
try {
|
||||
const turnstile = await loadTurnstile()
|
||||
if (!containerRef.value) return
|
||||
@@ -64,23 +89,37 @@ onMounted(async () => {
|
||||
sitekey: getTurnstileSiteKey(),
|
||||
theme,
|
||||
callback: (newToken: string) => {
|
||||
clearLoadTimeout()
|
||||
errorMessage.value = ''
|
||||
unavailable.value = false
|
||||
token.value = newToken
|
||||
},
|
||||
'expired-callback': () => {
|
||||
clearToken()
|
||||
errorMessage.value = t('auth.turnstile.expired')
|
||||
if (widgetId && window.turnstile) {
|
||||
window.turnstile.reset(widgetId)
|
||||
// A solved token can expire on its own (e.g. the tab was
|
||||
// backgrounded past the token's ~300s lifetime) without the widget
|
||||
// ever erroring, so proactively request a fresh challenge and
|
||||
// re-arm the load timeout in case it doesn't resolve in time.
|
||||
armTimeout()
|
||||
}
|
||||
},
|
||||
'error-callback': () => {
|
||||
clearToken()
|
||||
clearLoadTimeout()
|
||||
console.warn('Turnstile challenge failed')
|
||||
errorMessage.value = t('auth.turnstile.failed')
|
||||
unavailable.value = true
|
||||
if (widgetId && window.turnstile) window.turnstile.reset(widgetId)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
clearLoadTimeout()
|
||||
console.warn('Turnstile failed to load', error)
|
||||
errorMessage.value = t('auth.turnstile.failed')
|
||||
unavailable.value = true
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import {
|
||||
isTurnstileEnabled,
|
||||
normalizeTurnstileMode,
|
||||
useTurnstile
|
||||
useTurnstile,
|
||||
useTurnstileGate
|
||||
} from '@/composables/auth/useTurnstile'
|
||||
import { getTurnstileSiteKey } from '@/config/turnstile'
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
@@ -137,3 +139,63 @@ describe('useTurnstile', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useTurnstileGate', () => {
|
||||
it('waits while enabled with no token yet', () => {
|
||||
const { waiting } = useTurnstileGate(ref(true))
|
||||
expect(waiting.value).toBe(true)
|
||||
})
|
||||
|
||||
it('never waits while disabled', () => {
|
||||
const { waiting } = useTurnstileGate(ref(false))
|
||||
expect(waiting.value).toBe(false)
|
||||
})
|
||||
|
||||
it('stops waiting once a token arrives', () => {
|
||||
const { token, waiting } = useTurnstileGate(ref(true))
|
||||
|
||||
token.value = 'token-abc'
|
||||
|
||||
expect(waiting.value).toBe(false)
|
||||
})
|
||||
|
||||
it('stops waiting once the widget reports itself unavailable', () => {
|
||||
const { unavailable, waiting } = useTurnstileGate(ref(true))
|
||||
|
||||
unavailable.value = true
|
||||
|
||||
expect(waiting.value).toBe(false)
|
||||
})
|
||||
|
||||
it('clears stale token/unavailable state when enabled turns off', async () => {
|
||||
const enabled = ref(true)
|
||||
const { token, unavailable } = useTurnstileGate(enabled)
|
||||
token.value = 'stale-token'
|
||||
unavailable.value = true
|
||||
|
||||
enabled.value = false
|
||||
await nextTick()
|
||||
|
||||
expect(token.value).toBe('')
|
||||
expect(unavailable.value).toBe(false)
|
||||
})
|
||||
|
||||
// Regression coverage: the reset used to only run on the enabled->disabled
|
||||
// transition, so state written while the widget was briefly disabled could
|
||||
// survive into the next enabled widget instance.
|
||||
it('clears stale token/unavailable state when enabled turns back on', async () => {
|
||||
const enabled = ref(true)
|
||||
const { token, unavailable } = useTurnstileGate(enabled)
|
||||
|
||||
enabled.value = false
|
||||
await nextTick()
|
||||
token.value = 'stale-token'
|
||||
unavailable.value = true
|
||||
|
||||
enabled.value = true
|
||||
await nextTick()
|
||||
|
||||
expect(token.value).toBe('')
|
||||
expect(unavailable.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { getTurnstileSiteKey } from '@/config/turnstile'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
@@ -42,3 +43,31 @@ export function useTurnstile() {
|
||||
|
||||
return { mode, siteKey, enabled, enforced }
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit-gating state for the signup form's Turnstile widget: a token/
|
||||
* unavailable pair, plus `waiting`, which is true while a real token is still
|
||||
* needed. Waits in both shadow and enforce mode (`enabled`), not just
|
||||
* `enforced`, so shadow mode's token can't race the async Cloudflare
|
||||
* challenge; falls back open once the widget reports `unavailable` so a
|
||||
* broken/slow load can never permanently block signup.
|
||||
*
|
||||
* `token`/`unavailable` reset on every `enabled` transition, in either
|
||||
* direction, so state from a previous widget instance can never leak into a
|
||||
* freshly (re-)rendered one.
|
||||
*/
|
||||
export function useTurnstileGate(enabled: Ref<boolean>) {
|
||||
const token = ref('')
|
||||
const unavailable = ref(false)
|
||||
|
||||
const waiting = computed(
|
||||
() => enabled.value && !token.value && !unavailable.value
|
||||
)
|
||||
|
||||
watch(enabled, () => {
|
||||
token.value = ''
|
||||
unavailable.value = false
|
||||
})
|
||||
|
||||
return { token, unavailable, waiting }
|
||||
}
|
||||
|
||||
@@ -195,6 +195,46 @@ describe('PostHogTelemetryProvider', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('platform axes (client / deployment)', () => {
|
||||
afterEach(() => {
|
||||
delete window.__comfyDesktop2
|
||||
})
|
||||
|
||||
it('registers client=web and deployment=cloud in a plain browser', async () => {
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.mockRegister).toHaveBeenCalledWith({
|
||||
client: 'web',
|
||||
deployment: 'cloud'
|
||||
})
|
||||
})
|
||||
|
||||
it('registers client=desktop when the desktop preload bridge is present', async () => {
|
||||
window.__comfyDesktop2 = {
|
||||
isRemote: () => false,
|
||||
Telemetry: { capture: vi.fn() }
|
||||
}
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.mockRegister).toHaveBeenCalledWith({
|
||||
client: 'desktop',
|
||||
deployment: 'cloud'
|
||||
})
|
||||
})
|
||||
|
||||
it('registers platform axes before flushing pre-init queued events', async () => {
|
||||
const provider = createProvider()
|
||||
provider.trackSignupOpened()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
const registerOrder = hoisted.mockRegister.mock.invocationCallOrder[0]
|
||||
const captureOrder = hoisted.mockCapture.mock.invocationCallOrder[0]
|
||||
expect(registerOrder).toBeLessThan(captureOrder)
|
||||
})
|
||||
})
|
||||
|
||||
describe('desktop entry capture', () => {
|
||||
function setLocation(search: string): void {
|
||||
Object.defineProperty(window.location, 'search', {
|
||||
@@ -208,12 +248,20 @@ describe('PostHogTelemetryProvider', () => {
|
||||
setLocation('')
|
||||
})
|
||||
|
||||
// The platform-axes register (client/deployment) always fires, so these
|
||||
// assert no register call carrying desktop-entry attribution props.
|
||||
function desktopEntryRegisterCalls(): unknown[][] {
|
||||
return hoisted.mockRegister.mock.calls.filter(
|
||||
([props]) => props && 'source_app' in (props as Record<string, unknown>)
|
||||
)
|
||||
}
|
||||
|
||||
it('does not register desktop props when utm_source is absent', async () => {
|
||||
setLocation('')
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.mockRegister).not.toHaveBeenCalled()
|
||||
expect(desktopEntryRegisterCalls()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not register desktop props when utm_source is not comfy.desktop', async () => {
|
||||
@@ -221,7 +269,7 @@ describe('PostHogTelemetryProvider', () => {
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.mockRegister).not.toHaveBeenCalled()
|
||||
expect(desktopEntryRegisterCalls()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('registers source_app and desktop_device_id when arriving from desktop', async () => {
|
||||
|
||||
@@ -143,6 +143,9 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
before_send: createPostHogBeforeSend()
|
||||
})
|
||||
this.isInitialized = true
|
||||
// Before flushEventQueue so pre-init events also carry the
|
||||
// platform super properties.
|
||||
this.registerPlatformProps()
|
||||
this.flushEventQueue()
|
||||
this.registerDesktopEntryProps()
|
||||
|
||||
@@ -285,6 +288,18 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
)
|
||||
}
|
||||
|
||||
private registerPlatformProps(): void {
|
||||
if (!this.posthog) return
|
||||
try {
|
||||
this.posthog.register({
|
||||
client: window.__comfyDesktop2 ? 'desktop' : 'web',
|
||||
deployment: 'cloud'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to register platform props:', error)
|
||||
}
|
||||
}
|
||||
|
||||
private registerDesktopEntryProps(): void {
|
||||
if (!this.posthog) return
|
||||
const props = readDesktopEntryProps()
|
||||
|
||||