feat: add NightlySurveyPopover component for feature surveys (#9083)

## Summary

Adds NightlySurveyPopover component that displays a Typeform survey to
eligible nightly users after a configurable delay.

## Changes

- **What**: Vue component that uses `useSurveyEligibility` to show/hide
a survey popover with accept, dismiss, and opt-out actions. Loads
Typeform embed script dynamically with HTTPS and deduplication.

## Review Focus

- Typeform script injection security (HTTPS-only, load-once guard,
typeformId alphanumeric validation)
- Timeout lifecycle (clears pending timeout when eligibility changes)

## Part of Nightly Survey System

This is part 4 of a stacked PR chain:
1.  feat/feature-usage-tracker - useFeatureUsageTracker (merged in
#8189)
2.  feat/survey-eligibility - useSurveyEligibility (#8189, merged)
3.  feat/survey-config - surveyRegistry.ts (#8355, merged)
4. **feat/survey-popover** - NightlySurveyPopover.vue (this PR)
5. feat/survey-integration - NightlySurveyController.vue (#8480)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9083-feat-add-NightlySurveyPopover-component-for-feature-surveys-30f6d73d365081d1beb2f92555a4b2f4)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Christian Byrne
2026-02-22 20:20:09 -08:00
committed by GitHub
parent 1267c4b9b3
commit 8998d92e1b
3 changed files with 368 additions and 0 deletions

View File

@@ -2530,6 +2530,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>