feat: add NightlySurveyPopover component for feature surveys

Amp-Thread-ID: https://ampcode.com/threads/T-019bdda3-cd9e-73ff-b74e-02ceb885f330
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
bymyself
2026-01-23 20:53:00 -08:00
parent 3a5e003d32
commit 334e1b8083
3 changed files with 364 additions and 0 deletions

View File

@@ -2251,6 +2251,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,192 @@
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 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('[class*="fixed"]').exists()).toBe(false)
await vi.advanceTimersByTimeAsync(100)
await nextTick()
expect(wrapper.find('[class*="fixed"]').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('[class*="fixed"]').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('[class*="fixed"]').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')
)
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('[class*="fixed"]').exists()).toBe(false)
await vi.advanceTimersByTimeAsync(100)
await nextTick()
expect(wrapper.find('[class*="fixed"]').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('[class*="fixed"]').exists()).toBe(false)
})
})
})

View File

@@ -0,0 +1,164 @@
<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 isVisible = ref(false)
const isTypeformLoaded = ref(false)
const typeformError = ref(false)
const typeformRef = useTemplateRef('typeformRef')
let showTimeout: ReturnType<typeof setTimeout> | null = null
const typeformId = computed(() => config.typeformId)
watch(
isEligible,
(eligible) => {
if (eligible && !isVisible.value) {
showTimeout = setTimeout(() => {
isVisible.value = true
emit('shown')
}, delayMs.value)
}
},
{ immediate: true }
)
onUnmounted(() => {
if (showTimeout) {
clearTimeout(showTimeout)
}
})
whenever(typeformRef, () => {
const scriptEl = document.createElement('script')
scriptEl.src = '//embed.typeform.com/next/embed.js'
scriptEl.onload = () => {
isTypeformLoaded.value = true
}
scriptEl.onerror = () => {
typeformError.value = true
}
typeformRef.value?.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 name="slide-in">
<div
v-if="isVisible"
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"
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>
<style scoped>
.slide-in-enter-active,
.slide-in-leave-active {
transition:
transform 0.3s ease,
opacity 0.3s ease;
}
.slide-in-enter-from,
.slide-in-leave-to {
transform: translateX(100%);
opacity: 0;
}
.slide-in-enter-to,
.slide-in-leave-from {
transform: translateX(0);
opacity: 1;
}
</style>