feat: add NightlySurveyPopover component for feature surveys

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019c7db5-bd68-713b-bf60-4b1022483308
This commit is contained in:
bymyself
2026-01-23 20:53:00 -08:00
parent f707098f05
commit e25b7b8adf
3 changed files with 368 additions and 0 deletions

View File

@@ -2529,6 +2529,14 @@
"renderBypassState": "Render Bypass State",
"renderErrorState": "Render Error State"
},
"nightlySurvey": {
"title": "Help Us Improve",
"description": "You've been using this feature. Would you take a moment to share your feedback?",
"accept": "Sure, I'll help!",
"notNow": "Not Now",
"dontAskAgain": "Don't Ask Again",
"loadError": "Failed to load survey. Please try again later."
},
"cloudOnboarding": {
"skipToCloudApp": "Skip to the cloud app",
"survey": {

View File

@@ -0,0 +1,194 @@
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
const FEATURE_USAGE_KEY = 'Comfy.FeatureUsage'
const POPOVER_SELECTOR = '[data-testid="nightly-survey-popover"]'
const mockIsNightly = vi.hoisted(() => ({ value: true }))
const mockIsCloud = vi.hoisted(() => ({ value: false }))
const mockIsDesktop = vi.hoisted(() => ({ value: false }))
vi.mock('@/platform/distribution/types', () => ({
get isNightly() {
return mockIsNightly.value
},
get isCloud() {
return mockIsCloud.value
},
get isDesktop() {
return mockIsDesktop.value
}
}))
vi.mock('vue-i18n', () => ({
useI18n: vi.fn(() => ({
t: (key: string) => key
}))
}))
describe('NightlySurveyPopover', () => {
const defaultConfig = {
featureId: 'test-feature',
typeformId: 'abc123',
triggerThreshold: 3,
delayMs: 100,
enabled: true
}
function setFeatureUsage(featureId: string, useCount: number) {
const existing = JSON.parse(localStorage.getItem(FEATURE_USAGE_KEY) ?? '{}')
existing[featureId] = {
useCount,
firstUsed: Date.now() - 1000,
lastUsed: Date.now()
}
localStorage.setItem(FEATURE_USAGE_KEY, JSON.stringify(existing))
}
beforeEach(() => {
localStorage.clear()
vi.resetModules()
vi.useFakeTimers()
vi.setSystemTime(new Date('2024-06-15T12:00:00Z'))
mockIsNightly.value = true
mockIsCloud.value = false
mockIsDesktop.value = false
})
afterEach(() => {
localStorage.clear()
vi.useRealTimers()
document.body.innerHTML = ''
})
async function mountComponent(config = defaultConfig) {
const { default: NightlySurveyPopover } =
await import('./NightlySurveyPopover.vue')
return mount(NightlySurveyPopover, {
props: { config },
global: {
stubs: {
Teleport: true
}
},
attachTo: document.body
})
}
describe('visibility', () => {
it('shows popover after delay when eligible', async () => {
setFeatureUsage('test-feature', 5)
const wrapper = await mountComponent()
await nextTick()
expect(wrapper.find(POPOVER_SELECTOR).exists()).toBe(false)
await vi.advanceTimersByTimeAsync(100)
await nextTick()
expect(wrapper.find(POPOVER_SELECTOR).exists()).toBe(true)
})
it('does not show when not eligible', async () => {
setFeatureUsage('test-feature', 1)
const wrapper = await mountComponent()
await nextTick()
await vi.advanceTimersByTimeAsync(1000)
await nextTick()
expect(wrapper.find(POPOVER_SELECTOR).exists()).toBe(false)
})
it('does not show on cloud', async () => {
mockIsCloud.value = true
setFeatureUsage('test-feature', 5)
const wrapper = await mountComponent()
await nextTick()
await vi.advanceTimersByTimeAsync(1000)
await nextTick()
expect(wrapper.find(POPOVER_SELECTOR).exists()).toBe(false)
})
})
describe('user actions', () => {
it('emits shown event when displayed', async () => {
setFeatureUsage('test-feature', 5)
const wrapper = await mountComponent()
await vi.advanceTimersByTimeAsync(100)
await nextTick()
expect(wrapper.emitted('shown')).toHaveLength(1)
})
it('emits dismissed when close button clicked', async () => {
setFeatureUsage('test-feature', 5)
const wrapper = await mountComponent()
await vi.advanceTimersByTimeAsync(100)
await nextTick()
const closeButton = wrapper.find('[aria-label="g.close"]')
await closeButton.trigger('click')
expect(wrapper.emitted('dismissed')).toHaveLength(1)
})
it('emits optedOut when opt out button clicked', async () => {
setFeatureUsage('test-feature', 5)
const wrapper = await mountComponent()
await vi.advanceTimersByTimeAsync(100)
await nextTick()
const buttons = wrapper.findAll('button')
const optOutButton = buttons.find((b) =>
b.text().includes('nightlySurvey.dontAskAgain')
)
expect(optOutButton).toBeDefined()
await optOutButton!.trigger('click')
expect(wrapper.emitted('optedOut')).toHaveLength(1)
})
})
describe('config', () => {
it('uses custom delay from config', async () => {
setFeatureUsage('test-feature', 5)
const wrapper = await mountComponent({
...defaultConfig,
delayMs: 500
})
await nextTick()
await vi.advanceTimersByTimeAsync(400)
await nextTick()
expect(wrapper.find(POPOVER_SELECTOR).exists()).toBe(false)
await vi.advanceTimersByTimeAsync(100)
await nextTick()
expect(wrapper.find(POPOVER_SELECTOR).exists()).toBe(true)
})
it('does not show when config is disabled', async () => {
setFeatureUsage('test-feature', 5)
const wrapper = await mountComponent({
...defaultConfig,
enabled: false
})
await nextTick()
await vi.advanceTimersByTimeAsync(1000)
await nextTick()
expect(wrapper.find(POPOVER_SELECTOR).exists()).toBe(false)
})
})
})

View File

@@ -0,0 +1,166 @@
<script setup lang="ts">
import { whenever } from '@vueuse/core'
import { computed, onUnmounted, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import type { FeatureSurveyConfig } from './useSurveyEligibility'
import { useSurveyEligibility } from './useSurveyEligibility'
const { config } = defineProps<{
config: FeatureSurveyConfig
}>()
const emit = defineEmits<{
shown: []
dismissed: []
optedOut: []
}>()
const { t } = useI18n()
const { isEligible, delayMs, markSurveyShown, optOut } = useSurveyEligibility(
() => config
)
const TYPEFORM_SRC = 'https://embed.typeform.com/next/embed.js'
const isVisible = ref(false)
const typeformError = ref(false)
const typeformRef = useTemplateRef<HTMLDivElement>('typeformRef')
let showTimeout: ReturnType<typeof setTimeout> | null = null
const isValidTypeformId = computed(() =>
/^[A-Za-z0-9]+$/.test(config.typeformId)
)
const typeformId = computed(() =>
isValidTypeformId.value ? config.typeformId : ''
)
watch(
isEligible,
(eligible) => {
if (!eligible) {
if (showTimeout) {
clearTimeout(showTimeout)
showTimeout = null
}
return
}
if (isVisible.value || showTimeout) return
showTimeout = setTimeout(() => {
showTimeout = null
isVisible.value = true
emit('shown')
}, delayMs.value)
},
{ immediate: true }
)
onUnmounted(() => {
if (showTimeout) {
clearTimeout(showTimeout)
}
})
whenever(typeformRef, () => {
if (document.querySelector(`script[src="${TYPEFORM_SRC}"]`)) return
const scriptEl = document.createElement('script')
scriptEl.src = TYPEFORM_SRC
scriptEl.async = true
scriptEl.onerror = () => {
typeformError.value = true
}
document.head.appendChild(scriptEl)
})
function handleAccept() {
markSurveyShown()
}
function handleDismiss() {
isVisible.value = false
emit('dismissed')
}
function handleOptOut() {
optOut()
isVisible.value = false
emit('optedOut')
}
</script>
<template>
<Teleport to="body">
<Transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="translate-x-full opacity-0"
enter-to-class="translate-x-0 opacity-100"
leave-active-class="transition duration-300 ease-in"
leave-from-class="translate-x-0 opacity-100"
leave-to-class="translate-x-full opacity-0"
>
<div
v-if="isVisible"
data-testid="nightly-survey-popover"
class="fixed bottom-4 right-4 z-[10000] w-80 rounded-lg border border-border-subtle bg-base-background p-4 shadow-lg"
>
<div class="mb-3 flex items-start justify-between">
<h3 class="text-sm font-medium text-text-primary">
{{ t('nightlySurvey.title') }}
</h3>
<button
class="text-text-muted hover:text-text-primary"
:aria-label="t('g.close')"
@click="handleDismiss"
>
<i class="icon-[lucide--x] size-4" />
</button>
</div>
<p class="mb-4 text-sm text-text-secondary">
{{ t('nightlySurvey.description') }}
</p>
<div v-if="typeformError" class="mb-4 text-sm text-danger">
{{ t('nightlySurvey.loadError') }}
</div>
<div
v-show="isVisible && !typeformError && isValidTypeformId"
ref="typeformRef"
data-tf-auto-resize
:data-tf-widget="typeformId"
class="min-h-[300px]"
/>
<div class="mt-4 flex flex-col gap-2">
<Button variant="primary" class="w-full" @click="handleAccept">
{{ t('nightlySurvey.accept') }}
</Button>
<div class="flex gap-2">
<Button
variant="textonly"
class="flex-1 text-xs"
@click="handleDismiss"
>
{{ t('nightlySurvey.notNow') }}
</Button>
<Button
variant="muted-textonly"
class="flex-1 text-xs"
@click="handleOptOut"
>
{{ t('nightlySurvey.dontAskAgain') }}
</Button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>