feat: add usage onboarding survey overlay (#388)

* feat: add usage-survey feedback types and gating function

Claude-Session: https://claude.ai/code/session_01KAC9Lbx8AmebAnj9WQZXHp

* feat: add onboarding usage-survey i18n strings to all locales

Relabels three ambiguous feedback.usageTypes values (personal/team_internal/
business_workflow) and adds a new onboarding namespace (4 keys) across the
reference locale and all 20 translations, so the tree compiles at every
commit instead of only after both locale groups land.

Claude-Session: https://claude.ai/code/session_01KAC9Lbx8AmebAnj9WQZXHp

* feat: add UsageSurveyOverlay component

* feat: mount UsageSurveyOverlay inside AuthGuard

Claude-Session: https://claude.ai/code/session_01KAC9Lbx8AmebAnj9WQZXHp

* fix: use text-start instead of text-left for RTL support in UsageSurveyOverlay

* refactor: drop redundant usage-type field from the admin feedback dialog

Claude-Session: https://claude.ai/code/session_01KAC9Lbx8AmebAnj9WQZXHp

* feat: accept onboarding source and survey id in the feedback route

Claude-Session: https://claude.ai/code/session_01KAC9Lbx8AmebAnj9WQZXHp

* test: cover the onboarding source in the feedback route integration test

Claude-Session: https://claude.ai/code/session_01KAC9Lbx8AmebAnj9WQZXHp

* chore: remove orphaned usageTypeLabel i18n key

* refactor: derive feedback source/survey_id enums from a single source of truth

* perf: skip the settings fetch in UsageSurveyOverlay for non-admin users
This commit is contained in:
SnapOtter
2026-07-02 00:02:24 +08:00
committed by GitHub
parent 9052da27f3
commit a0d1c70172
31 changed files with 674 additions and 168 deletions
+20 -7
View File
@@ -7,14 +7,27 @@ import { analyticsEnabled, bakedEnabled } from "./analytics-gate.js";
let posthogClient: PostHog | null = null;
export const FEEDBACK_SOURCE_VALUES = [
"global",
"tool_result",
"failed_job",
"admin_installer",
"search_miss",
"onboarding",
] as const;
export const FEEDBACK_SURVEY_ID_VALUES = [
"global-feedback-v1",
"tool-result-v1",
"failed-job-v1",
"admin-install-v1",
"search-miss-v1",
"onboarding-usage-v1",
] as const;
export interface FeedbackEventProperties {
source: "global" | "tool_result" | "failed_job" | "admin_installer" | "search_miss";
survey_id?:
| "global-feedback-v1"
| "tool-result-v1"
| "failed-job-v1"
| "admin-install-v1"
| "search-miss-v1";
source: (typeof FEEDBACK_SOURCE_VALUES)[number];
survey_id?: (typeof FEEDBACK_SURVEY_ID_VALUES)[number];
prompt_variant?: string;
sentiment?: "great" | "okay" | "issue" | "missing" | "bug" | "idea" | "other";
feedback_type?: "bug" | "feature_request" | "confusing_ux" | "performance" | "other";
+8 -17
View File
@@ -1,23 +1,14 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { captureFeedback, type FeedbackEventProperties } from "../lib/analytics.js";
import {
captureFeedback,
FEEDBACK_SOURCE_VALUES,
FEEDBACK_SURVEY_ID_VALUES,
type FeedbackEventProperties,
} from "../lib/analytics.js";
import { analyticsEnabled } from "../lib/analytics-gate.js";
import { requireAuth } from "../plugins/auth.js";
const SOURCE_VALUES = [
"global",
"tool_result",
"failed_job",
"admin_installer",
"search_miss",
] as const;
const SURVEY_ID_VALUES = [
"global-feedback-v1",
"tool-result-v1",
"failed-job-v1",
"admin-install-v1",
"search-miss-v1",
] as const;
const SENTIMENT_VALUES = ["great", "okay", "issue", "missing", "bug", "idea", "other"] as const;
const FEEDBACK_TYPE_VALUES = [
"bug",
@@ -84,8 +75,8 @@ const optionalText = (max: number) =>
const feedbackBodySchema = z
.object({
source: z.enum(SOURCE_VALUES),
surveyId: z.enum(SURVEY_ID_VALUES).optional(),
source: z.enum(FEEDBACK_SOURCE_VALUES),
surveyId: z.enum(FEEDBACK_SURVEY_ID_VALUES).optional(),
promptVariant: z
.string()
.trim()
+2
View File
@@ -5,6 +5,7 @@ import { Toaster, toast } from "sonner";
import { ConnectionMonitor } from "./components/common/connection-monitor";
import { KeyboardShortcutProvider } from "./components/common/keyboard-shortcut-provider";
import { RouteAnnouncer } from "./components/common/route-announcer";
import { UsageSurveyOverlay } from "./components/onboarding/usage-survey-overlay";
import { I18nProvider } from "./contexts/i18n-context";
import { useAuth } from "./hooks/use-auth";
import { useMobile } from "./hooks/use-mobile";
@@ -195,6 +196,7 @@ export function App() {
<RouteAnnouncer />
<KeyboardShortcutProvider>
<AuthGuard>
<UsageSurveyOverlay />
<Suspense fallback={<PageLoader />}>
<Routes>
<Route path="/login" element={<LoginPage />} />
@@ -12,7 +12,6 @@ import {
type FeedbackSentiment,
type FeedbackSource,
type FeedbackType,
type FeedbackUsageType,
promptVariantForSource,
submitFeedback,
surveyIdForSource,
@@ -50,13 +49,6 @@ const INSTALL_METHODS: FeedbackInstallMethod[] = [
"cloud",
"other",
];
const USAGE_TYPES: FeedbackUsageType[] = [
"personal",
"team_internal",
"business_workflow",
"education",
"evaluating",
];
const FRICTION_AREAS: FeedbackFrictionArea[] = [
"smooth",
"docker",
@@ -99,7 +91,6 @@ export function FeedbackDialog({
const [contactName, setContactName] = useState("");
const [company, setCompany] = useState("");
const [installMethod, setInstallMethod] = useState<FeedbackInstallMethod>("docker_compose");
const [usageType, setUsageType] = useState<FeedbackUsageType>("evaluating");
const [frictionArea, setFrictionArea] = useState<FeedbackFrictionArea>("smooth");
const [importantAreas, setImportantAreas] = useState<FeedbackImportantArea[]>([]);
const [submitting, setSubmitting] = useState(false);
@@ -121,7 +112,6 @@ export function FeedbackDialog({
setContactName("");
setCompany("");
setInstallMethod("docker_compose");
setUsageType("evaluating");
setFrictionArea("smooth");
setImportantAreas([]);
setSubmitting(false);
@@ -183,7 +173,6 @@ export function FeedbackDialog({
...(isAdminInstall
? {
installMethod,
usageType,
frictionArea,
importantAreas,
}
@@ -278,8 +267,6 @@ export function FeedbackDialog({
<AdminInstallFields
installMethod={installMethod}
setInstallMethod={setInstallMethod}
usageType={usageType}
setUsageType={setUsageType}
frictionArea={frictionArea}
setFrictionArea={setFrictionArea}
importantAreas={importantAreas}
@@ -389,8 +376,6 @@ export function FeedbackDialog({
interface AdminInstallFieldsProps {
installMethod: FeedbackInstallMethod;
setInstallMethod: (value: FeedbackInstallMethod) => void;
usageType: FeedbackUsageType;
setUsageType: (value: FeedbackUsageType) => void;
frictionArea: FeedbackFrictionArea;
setFrictionArea: (value: FeedbackFrictionArea) => void;
importantAreas: FeedbackImportantArea[];
@@ -400,8 +385,6 @@ interface AdminInstallFieldsProps {
function AdminInstallFields({
installMethod,
setInstallMethod,
usageType,
setUsageType,
frictionArea,
setFrictionArea,
importantAreas,
@@ -410,41 +393,22 @@ function AdminInstallFields({
const { t } = useTranslation();
return (
<>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-foreground" htmlFor="feedback-install-method">
{t.feedback.installMethodLabel}
</label>
<select
id="feedback-install-method"
value={installMethod}
onChange={(event) => setInstallMethod(event.target.value as FeedbackInstallMethod)}
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground"
>
{INSTALL_METHODS.map((method) => (
<option key={method} value={method}>
{t.feedback.installMethods[method]}
</option>
))}
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground" htmlFor="feedback-usage-type">
{t.feedback.usageTypeLabel}
</label>
<select
id="feedback-usage-type"
value={usageType}
onChange={(event) => setUsageType(event.target.value as FeedbackUsageType)}
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground"
>
{USAGE_TYPES.map((type) => (
<option key={type} value={type}>
{t.feedback.usageTypes[type]}
</option>
))}
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground" htmlFor="feedback-install-method">
{t.feedback.installMethodLabel}
</label>
<select
id="feedback-install-method"
value={installMethod}
onChange={(event) => setInstallMethod(event.target.value as FeedbackInstallMethod)}
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground"
>
{INSTALL_METHODS.map((method) => (
<option key={method} value={method}>
{t.feedback.installMethods[method]}
</option>
))}
</select>
</div>
<div className="space-y-2">
@@ -0,0 +1,200 @@
import {
Building2,
FileText,
GraduationCap,
Image,
Layers,
Search,
Sparkles,
User,
Users,
Video,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { useAuth } from "@/hooks/use-auth";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { apiGet, apiPut } from "@/lib/api";
import {
type FeedbackImportantArea,
type FeedbackUsageType,
shouldShowUsageSurvey,
submitFeedback,
} from "@/lib/feedback";
import { cn } from "@/lib/utils";
import { useAnalyticsStore } from "@/stores/analytics-store";
const USAGE_TYPES: { value: FeedbackUsageType; Icon: typeof User; wide?: boolean }[] = [
{ value: "personal", Icon: User },
{ value: "team_internal", Icon: Users },
{ value: "business_workflow", Icon: Building2 },
{ value: "education", Icon: GraduationCap },
{ value: "evaluating", Icon: Search, wide: true },
];
const IMPORTANT_AREAS: { value: FeedbackImportantArea; Icon: typeof Image; wide?: boolean }[] = [
{ value: "images", Icon: Image },
{ value: "pdf_docs", Icon: FileText },
{ value: "video_audio", Icon: Video },
{ value: "batch_workflows", Icon: Layers },
{ value: "ai_tools", Icon: Sparkles, wide: true },
];
export function UsageSurveyOverlay() {
const { t } = useTranslation();
const { role } = useAuth();
const analyticsConfig = useAnalyticsStore((s) => s.config);
const analyticsConfigLoaded = useAnalyticsStore((s) => s.configLoaded);
const containerRef = useRef<HTMLDivElement>(null);
const [settings, setSettings] = useState<Record<string, string> | null>(null);
const [usageType, setUsageType] = useState<FeedbackUsageType | null>(null);
const [importantAreas, setImportantAreas] = useState<FeedbackImportantArea[]>([]);
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (role !== "admin") return;
apiGet<{ settings: Record<string, string> }>("/v1/settings")
.then((data) => setSettings(data.settings))
.catch(() => setSettings({}));
}, [role]);
const visible =
settings !== null &&
shouldShowUsageSurvey({
settings,
role,
analyticsConfigLoaded,
analyticsEnabled: Boolean(analyticsConfig?.enabled),
});
useFocusTrap(containerRef, visible);
function toggleArea(area: FeedbackImportantArea) {
setImportantAreas((current) =>
current.includes(area) ? current.filter((value) => value !== area) : [...current, area],
);
}
async function recordSettingsKey(key: string) {
const value = new Date().toISOString();
await apiPut("/v1/settings", { [key]: value });
setSettings((current) => ({ ...(current ?? {}), [key]: value }));
}
async function handleContinue() {
if (!usageType || submitting) return;
setSubmitting(true);
try {
await submitFeedback({
source: "onboarding",
surveyId: "onboarding-usage-v1",
promptVariant: "onboarding-overlay-v1",
usageType,
importantAreas,
});
await recordSettingsKey("onboarding.usageSurvey.answeredAt");
} catch {
// Submission failed (network/auth). Leave the overlay visible so the
// admin can retry instead of silently losing their answer.
} finally {
setSubmitting(false);
}
}
function handleDismiss() {
// Same reasoning as the handleContinue catch above: a failed write just
// means the overlay stays visible next time, which is an acceptable,
// low-stakes fallback.
void recordSettingsKey("onboarding.usageSurvey.dismissedAt").catch(() => {});
}
if (!visible) return null;
return (
<div
ref={containerRef}
role="dialog"
aria-modal="true"
aria-labelledby="usage-survey-title"
className="fixed inset-0 z-50 flex items-center justify-center bg-background p-4"
>
<div className="w-full max-w-md space-y-6">
<div className="flex flex-col items-center text-center gap-3">
<div className="h-11 w-11 rounded-full bg-primary flex items-center justify-center text-xl">
🦦
</div>
<h1 id="usage-survey-title" className="text-lg font-semibold text-foreground">
{t.onboarding.usageSurveyTitle}
</h1>
</div>
<div className="grid grid-cols-2 gap-2">
{USAGE_TYPES.map(({ value, Icon, wide }) => (
<button
key={value}
type="button"
onClick={() => setUsageType(value)}
className={cn(
"flex items-center gap-2 rounded-lg border px-3 py-2.5 text-sm font-medium text-start transition-colors",
usageType === value
? "border-primary bg-primary/10 text-primary"
: "border-border text-foreground hover:bg-muted",
wide && "col-span-2 justify-center",
)}
>
<Icon className="h-4 w-4 shrink-0" />
{t.feedback.usageTypes[value]}
</button>
))}
</div>
<div className="space-y-2">
<p className="text-sm font-medium text-foreground">
{t.onboarding.usageSurveyToolsLabel}{" "}
<span className="text-xs font-normal text-muted-foreground">
{t.onboarding.pickAnyHint}
</span>
</p>
<div className="grid grid-cols-2 gap-2">
{IMPORTANT_AREAS.map(({ value, Icon, wide }) => (
<button
key={value}
type="button"
onClick={() => toggleArea(value)}
className={cn(
"flex items-center gap-2 rounded-lg border px-3 py-2.5 text-sm font-medium text-start transition-colors",
importantAreas.includes(value)
? "border-primary bg-primary/10 text-primary"
: "border-border text-foreground hover:bg-muted",
wide && "col-span-2 justify-center",
)}
>
<Icon className="h-4 w-4 shrink-0" />
{t.feedback.importantAreas[value]}
</button>
))}
</div>
</div>
<div className="space-y-3">
<button
type="button"
onClick={handleContinue}
disabled={!usageType || submitting}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50"
>
{t.onboarding.continueLabel}
</button>
<button
type="button"
onClick={handleDismiss}
className="w-full text-center text-xs text-muted-foreground hover:text-foreground hover:underline"
>
{t.feedback.dontAskAgain}
</button>
</div>
</div>
</div>
);
}
+30 -3
View File
@@ -5,20 +5,23 @@ export type FeedbackSource =
| "tool_result"
| "failed_job"
| "admin_installer"
| "search_miss";
| "search_miss"
| "onboarding";
export type FeedbackSurveyId =
| "global-feedback-v1"
| "tool-result-v1"
| "failed-job-v1"
| "admin-install-v1"
| "search-miss-v1";
| "search-miss-v1"
| "onboarding-usage-v1";
export type FeedbackPromptVariant =
| "nav-v1"
| "inline-v1"
| "failed-button-v1"
| "settings-card-v1"
| "search-empty-v1"
| "search-results-v1";
| "search-results-v1"
| "onboarding-overlay-v1";
export type FeedbackSentiment = "great" | "okay" | "issue" | "missing" | "bug" | "idea" | "other";
export type FeedbackType = "bug" | "feature_request" | "confusing_ux" | "performance" | "other";
export type FeedbackInstallMethod = "docker" | "docker_compose" | "source" | "cloud" | "other";
@@ -100,6 +103,8 @@ export function surveyIdForSource(source: FeedbackSource): FeedbackSurveyId {
return "search-miss-v1";
case "global":
return "global-feedback-v1";
case "onboarding":
return "onboarding-usage-v1";
}
}
@@ -115,6 +120,8 @@ export function promptVariantForSource(source: FeedbackSource): FeedbackPromptVa
return "search-empty-v1";
case "global":
return "nav-v1";
case "onboarding":
return "onboarding-overlay-v1";
}
}
@@ -150,6 +157,26 @@ export function shouldShowInstallFeedbackCard({
return !Number.isFinite(parsedSnooze) || parsedSnooze <= now;
}
interface UsageSurveyVisibilityOptions {
settings: Record<string, string>;
role: string | null;
analyticsConfigLoaded: boolean;
analyticsEnabled: boolean;
}
export function shouldShowUsageSurvey({
settings,
role,
analyticsConfigLoaded,
analyticsEnabled,
}: UsageSurveyVisibilityOptions): boolean {
if (!analyticsConfigLoaded || !analyticsEnabled || role !== "admin") return false;
return (
!settings["onboarding.usageSurvey.answeredAt"] &&
!settings["onboarding.usageSurvey.dismissedAt"]
);
}
export async function submitFeedback(payload: FeedbackPayload): Promise<FeedbackResponse> {
return apiPost<FeedbackResponse>("/v1/feedback", payload);
}
+9 -4
View File
@@ -80,7 +80,6 @@ export const ar: TranslationKeys = {
adminCardDescription:
"A quick note from admins helps us improve Docker, source installs, and docs.",
installMethodLabel: "Install method",
usageTypeLabel: "Use case",
frictionAreaLabel: "Hardest setup area",
importantAreasLabel: "Most important areas",
sentiments: {
@@ -107,9 +106,9 @@ export const ar: TranslationKeys = {
other: "Other",
},
usageTypes: {
personal: "Personal",
team_internal: "Team/internal",
business_workflow: "Business workflow",
personal: "أنا فقط",
team_internal: "فريق صغير",
business_workflow: "شركة أو مؤسسة",
education: "Education",
evaluating: "Evaluating",
},
@@ -133,6 +132,12 @@ export const ar: TranslationKeys = {
ai_tools: "AI tools",
},
},
onboarding: {
usageSurveyTitle: "كيف تستخدم SnapOtter؟",
usageSurveyToolsLabel: "ما الأهم بالنسبة لك؟",
pickAnyHint: "(اختر ما تريد)",
continueLabel: "متابعة",
},
categories: {
essentials: "الأساسيات",
adjustments: "التعديلات",
+9 -4
View File
@@ -81,7 +81,6 @@ export const de: TranslationKeys = {
adminCardDescription:
"A quick note from admins helps us improve Docker, source installs, and docs.",
installMethodLabel: "Install method",
usageTypeLabel: "Use case",
frictionAreaLabel: "Hardest setup area",
importantAreasLabel: "Most important areas",
sentiments: {
@@ -108,9 +107,9 @@ export const de: TranslationKeys = {
other: "Other",
},
usageTypes: {
personal: "Personal",
team_internal: "Team/internal",
business_workflow: "Business workflow",
personal: "Nur ich",
team_internal: "Kleines Team",
business_workflow: "Unternehmen oder Organisation",
education: "Education",
evaluating: "Evaluating",
},
@@ -134,6 +133,12 @@ export const de: TranslationKeys = {
ai_tools: "AI tools",
},
},
onboarding: {
usageSurveyTitle: "Wie nutzt du SnapOtter?",
usageSurveyToolsLabel: "Was ist dir am wichtigsten?",
pickAnyHint: "(beliebig viele auswählen)",
continueLabel: "Weiter",
},
categories: {
essentials: "Grundlagen",
adjustments: "Anpassungen",
+9 -4
View File
@@ -78,7 +78,6 @@ export const en = {
adminCardDescription:
"A quick note from admins helps us improve Docker, source installs, and docs.",
installMethodLabel: "Install method",
usageTypeLabel: "Use case",
frictionAreaLabel: "Hardest setup area",
importantAreasLabel: "Most important areas",
sentiments: {
@@ -105,9 +104,9 @@ export const en = {
other: "Other",
},
usageTypes: {
personal: "Personal",
team_internal: "Team/internal",
business_workflow: "Business workflow",
personal: "Just me",
team_internal: "Small team",
business_workflow: "Company or organization",
education: "Education",
evaluating: "Evaluating",
},
@@ -131,6 +130,12 @@ export const en = {
ai_tools: "AI tools",
},
},
onboarding: {
usageSurveyTitle: "How are you using SnapOtter?",
usageSurveyToolsLabel: "What matters most to you?",
pickAnyHint: "(pick any)",
continueLabel: "Continue",
},
categories: {
// Image
essentials: "Essentials",
+9 -4
View File
@@ -80,7 +80,6 @@ export const es: TranslationKeys = {
adminCardDescription:
"A quick note from admins helps us improve Docker, source installs, and docs.",
installMethodLabel: "Install method",
usageTypeLabel: "Use case",
frictionAreaLabel: "Hardest setup area",
importantAreasLabel: "Most important areas",
sentiments: {
@@ -107,9 +106,9 @@ export const es: TranslationKeys = {
other: "Other",
},
usageTypes: {
personal: "Personal",
team_internal: "Team/internal",
business_workflow: "Business workflow",
personal: "Solo yo",
team_internal: "Equipo pequeño",
business_workflow: "Empresa u organización",
education: "Education",
evaluating: "Evaluating",
},
@@ -133,6 +132,12 @@ export const es: TranslationKeys = {
ai_tools: "AI tools",
},
},
onboarding: {
usageSurveyTitle: "¿Cómo usas SnapOtter?",
usageSurveyToolsLabel: "¿Qué es lo más importante para ti?",
pickAnyHint: "(elige las que quieras)",
continueLabel: "Continuar",
},
categories: {
essentials: "Esenciales",
adjustments: "Ajustes",
+9 -4
View File
@@ -81,7 +81,6 @@ export const fr: TranslationKeys = {
adminCardDescription:
"A quick note from admins helps us improve Docker, source installs, and docs.",
installMethodLabel: "Install method",
usageTypeLabel: "Use case",
frictionAreaLabel: "Hardest setup area",
importantAreasLabel: "Most important areas",
sentiments: {
@@ -108,9 +107,9 @@ export const fr: TranslationKeys = {
other: "Other",
},
usageTypes: {
personal: "Personal",
team_internal: "Team/internal",
business_workflow: "Business workflow",
personal: "Juste moi",
team_internal: "Petite équipe",
business_workflow: "Entreprise ou organisation",
education: "Education",
evaluating: "Evaluating",
},
@@ -134,6 +133,12 @@ export const fr: TranslationKeys = {
ai_tools: "AI tools",
},
},
onboarding: {
usageSurveyTitle: "Comment utilisez-vous SnapOtter ?",
usageSurveyToolsLabel: "Qu'est-ce qui compte le plus pour vous ?",
pickAnyHint: "(plusieurs choix possibles)",
continueLabel: "Continuer",
},
categories: {
essentials: "Essentiels",
adjustments: "Réglages",
+9 -4
View File
@@ -80,7 +80,6 @@ export const hi: TranslationKeys = {
adminCardDescription:
"A quick note from admins helps us improve Docker, source installs, and docs.",
installMethodLabel: "Install method",
usageTypeLabel: "Use case",
frictionAreaLabel: "Hardest setup area",
importantAreasLabel: "Most important areas",
sentiments: {
@@ -107,9 +106,9 @@ export const hi: TranslationKeys = {
other: "Other",
},
usageTypes: {
personal: "Personal",
team_internal: "Team/internal",
business_workflow: "Business workflow",
personal: "सिर्फ़ मैं",
team_internal: "छोटी टीम",
business_workflow: "कंपनी या संगठन",
education: "Education",
evaluating: "Evaluating",
},
@@ -133,6 +132,12 @@ export const hi: TranslationKeys = {
ai_tools: "AI tools",
},
},
onboarding: {
usageSurveyTitle: "आप SnapOtter का उपयोग कैसे कर रहे हैं?",
usageSurveyToolsLabel: "आपके लिए सबसे ज़्यादा महत्वपूर्ण क्या है?",
pickAnyHint: "(कोई भी चुनें)",
continueLabel: "जारी रखें",
},
categories: {
essentials: "आवश्यक टूल्स",
adjustments: "समायोजन",
+9 -4
View File
@@ -80,7 +80,6 @@ export const id: TranslationKeys = {
adminCardDescription:
"A quick note from admins helps us improve Docker, source installs, and docs.",
installMethodLabel: "Install method",
usageTypeLabel: "Use case",
frictionAreaLabel: "Hardest setup area",
importantAreasLabel: "Most important areas",
sentiments: {
@@ -107,9 +106,9 @@ export const id: TranslationKeys = {
other: "Other",
},
usageTypes: {
personal: "Personal",
team_internal: "Team/internal",
business_workflow: "Business workflow",
personal: "Hanya saya",
team_internal: "Tim kecil",
business_workflow: "Perusahaan atau organisasi",
education: "Education",
evaluating: "Evaluating",
},
@@ -133,6 +132,12 @@ export const id: TranslationKeys = {
ai_tools: "AI tools",
},
},
onboarding: {
usageSurveyTitle: "Bagaimana Anda menggunakan SnapOtter?",
usageSurveyToolsLabel: "Apa yang paling penting bagi Anda?",
pickAnyHint: "(pilih sebanyak yang Anda mau)",
continueLabel: "Lanjutkan",
},
categories: {
essentials: "Dasar",
adjustments: "Penyesuaian",
+9 -4
View File
@@ -81,7 +81,6 @@ export const it: TranslationKeys = {
adminCardDescription:
"A quick note from admins helps us improve Docker, source installs, and docs.",
installMethodLabel: "Install method",
usageTypeLabel: "Use case",
frictionAreaLabel: "Hardest setup area",
importantAreasLabel: "Most important areas",
sentiments: {
@@ -108,9 +107,9 @@ export const it: TranslationKeys = {
other: "Other",
},
usageTypes: {
personal: "Personal",
team_internal: "Team/internal",
business_workflow: "Business workflow",
personal: "Solo io",
team_internal: "Piccolo team",
business_workflow: "Azienda o organizzazione",
education: "Education",
evaluating: "Evaluating",
},
@@ -134,6 +133,12 @@ export const it: TranslationKeys = {
ai_tools: "AI tools",
},
},
onboarding: {
usageSurveyTitle: "Come usi SnapOtter?",
usageSurveyToolsLabel: "Cosa conta di più per te?",
pickAnyHint: "(scegli quante ne vuoi)",
continueLabel: "Continua",
},
categories: {
essentials: "Essenziali",
adjustments: "Regolazioni",
+9 -4
View File
@@ -81,7 +81,6 @@ export const ja: TranslationKeys = {
adminCardDescription:
"A quick note from admins helps us improve Docker, source installs, and docs.",
installMethodLabel: "Install method",
usageTypeLabel: "Use case",
frictionAreaLabel: "Hardest setup area",
importantAreasLabel: "Most important areas",
sentiments: {
@@ -108,9 +107,9 @@ export const ja: TranslationKeys = {
other: "Other",
},
usageTypes: {
personal: "Personal",
team_internal: "Team/internal",
business_workflow: "Business workflow",
personal: "自分だけ",
team_internal: "小規模なチーム",
business_workflow: "会社・組織",
education: "Education",
evaluating: "Evaluating",
},
@@ -134,6 +133,12 @@ export const ja: TranslationKeys = {
ai_tools: "AI tools",
},
},
onboarding: {
usageSurveyTitle: "SnapOtterをどのように使っていますか?",
usageSurveyToolsLabel: "あなたにとって最も重要なものは?",
pickAnyHint: "(いくつでも選択可)",
continueLabel: "続ける",
},
categories: {
essentials: "基本ツール",
adjustments: "調整",
+9 -4
View File
@@ -80,7 +80,6 @@ export const ko: TranslationKeys = {
adminCardDescription:
"A quick note from admins helps us improve Docker, source installs, and docs.",
installMethodLabel: "Install method",
usageTypeLabel: "Use case",
frictionAreaLabel: "Hardest setup area",
importantAreasLabel: "Most important areas",
sentiments: {
@@ -107,9 +106,9 @@ export const ko: TranslationKeys = {
other: "Other",
},
usageTypes: {
personal: "Personal",
team_internal: "Team/internal",
business_workflow: "Business workflow",
personal: "개인 사용",
team_internal: "소규모 팀",
business_workflow: "회사 또는 조직",
education: "Education",
evaluating: "Evaluating",
},
@@ -133,6 +132,12 @@ export const ko: TranslationKeys = {
ai_tools: "AI tools",
},
},
onboarding: {
usageSurveyTitle: "SnapOtter를 어떻게 사용하고 계신가요?",
usageSurveyToolsLabel: "가장 중요하게 생각하는 것은 무엇인가요?",
pickAnyHint: "(원하는 만큼 선택)",
continueLabel: "계속",
},
categories: {
essentials: "기본 도구",
adjustments: "조정",
+9 -4
View File
@@ -81,7 +81,6 @@ export const nl: TranslationKeys = {
adminCardDescription:
"A quick note from admins helps us improve Docker, source installs, and docs.",
installMethodLabel: "Install method",
usageTypeLabel: "Use case",
frictionAreaLabel: "Hardest setup area",
importantAreasLabel: "Most important areas",
sentiments: {
@@ -108,9 +107,9 @@ export const nl: TranslationKeys = {
other: "Other",
},
usageTypes: {
personal: "Personal",
team_internal: "Team/internal",
business_workflow: "Business workflow",
personal: "Alleen ik",
team_internal: "Klein team",
business_workflow: "Bedrijf of organisatie",
education: "Education",
evaluating: "Evaluating",
},
@@ -134,6 +133,12 @@ export const nl: TranslationKeys = {
ai_tools: "AI tools",
},
},
onboarding: {
usageSurveyTitle: "Hoe gebruik je SnapOtter?",
usageSurveyToolsLabel: "Wat is voor jou het belangrijkst?",
pickAnyHint: "(kies er zoveel als je wilt)",
continueLabel: "Doorgaan",
},
categories: {
essentials: "Basistools",
adjustments: "Aanpassingen",
+9 -4
View File
@@ -80,7 +80,6 @@ export const pl: TranslationKeys = {
adminCardDescription:
"A quick note from admins helps us improve Docker, source installs, and docs.",
installMethodLabel: "Install method",
usageTypeLabel: "Use case",
frictionAreaLabel: "Hardest setup area",
importantAreasLabel: "Most important areas",
sentiments: {
@@ -107,9 +106,9 @@ export const pl: TranslationKeys = {
other: "Other",
},
usageTypes: {
personal: "Personal",
team_internal: "Team/internal",
business_workflow: "Business workflow",
personal: "Tylko ja",
team_internal: "Mały zespół",
business_workflow: "Firma lub organizacja",
education: "Education",
evaluating: "Evaluating",
},
@@ -133,6 +132,12 @@ export const pl: TranslationKeys = {
ai_tools: "AI tools",
},
},
onboarding: {
usageSurveyTitle: "Jak korzystasz ze SnapOtter?",
usageSurveyToolsLabel: "Co jest dla Ciebie najważniejsze?",
pickAnyHint: "(wybierz dowolną liczbę)",
continueLabel: "Dalej",
},
categories: {
essentials: "Podstawowe",
adjustments: "Korekta",
+9 -4
View File
@@ -81,7 +81,6 @@ export const ptBR: TranslationKeys = {
adminCardDescription:
"A quick note from admins helps us improve Docker, source installs, and docs.",
installMethodLabel: "Install method",
usageTypeLabel: "Use case",
frictionAreaLabel: "Hardest setup area",
importantAreasLabel: "Most important areas",
sentiments: {
@@ -108,9 +107,9 @@ export const ptBR: TranslationKeys = {
other: "Other",
},
usageTypes: {
personal: "Personal",
team_internal: "Team/internal",
business_workflow: "Business workflow",
personal: "Só eu",
team_internal: "Equipe pequena",
business_workflow: "Empresa ou organização",
education: "Education",
evaluating: "Evaluating",
},
@@ -134,6 +133,12 @@ export const ptBR: TranslationKeys = {
ai_tools: "AI tools",
},
},
onboarding: {
usageSurveyTitle: "Como você usa o SnapOtter?",
usageSurveyToolsLabel: "O que é mais importante para você?",
pickAnyHint: "(escolha quantas quiser)",
continueLabel: "Continuar",
},
categories: {
essentials: "Essenciais",
adjustments: "Ajustes",
+9 -4
View File
@@ -80,7 +80,6 @@ export const ru: TranslationKeys = {
adminCardDescription:
"A quick note from admins helps us improve Docker, source installs, and docs.",
installMethodLabel: "Install method",
usageTypeLabel: "Use case",
frictionAreaLabel: "Hardest setup area",
importantAreasLabel: "Most important areas",
sentiments: {
@@ -107,9 +106,9 @@ export const ru: TranslationKeys = {
other: "Other",
},
usageTypes: {
personal: "Personal",
team_internal: "Team/internal",
business_workflow: "Business workflow",
personal: "Только я",
team_internal: "Небольшая команда",
business_workflow: "Компания или организация",
education: "Education",
evaluating: "Evaluating",
},
@@ -133,6 +132,12 @@ export const ru: TranslationKeys = {
ai_tools: "AI tools",
},
},
onboarding: {
usageSurveyTitle: "Как вы используете SnapOtter?",
usageSurveyToolsLabel: "Что для вас важнее всего?",
pickAnyHint: "(выберите любое количество)",
continueLabel: "Продолжить",
},
categories: {
essentials: "Основные",
adjustments: "Коррекция",
+9 -4
View File
@@ -81,7 +81,6 @@ export const sv: TranslationKeys = {
adminCardDescription:
"A quick note from admins helps us improve Docker, source installs, and docs.",
installMethodLabel: "Install method",
usageTypeLabel: "Use case",
frictionAreaLabel: "Hardest setup area",
importantAreasLabel: "Most important areas",
sentiments: {
@@ -108,9 +107,9 @@ export const sv: TranslationKeys = {
other: "Other",
},
usageTypes: {
personal: "Personal",
team_internal: "Team/internal",
business_workflow: "Business workflow",
personal: "Bara jag",
team_internal: "Litet team",
business_workflow: "Företag eller organisation",
education: "Education",
evaluating: "Evaluating",
},
@@ -134,6 +133,12 @@ export const sv: TranslationKeys = {
ai_tools: "AI tools",
},
},
onboarding: {
usageSurveyTitle: "Hur använder du SnapOtter?",
usageSurveyToolsLabel: "Vad betyder mest för dig?",
pickAnyHint: "(välj hur många du vill)",
continueLabel: "Fortsätt",
},
categories: {
essentials: "Grundläggande",
adjustments: "Justeringar",
+9 -4
View File
@@ -80,7 +80,6 @@ export const th: TranslationKeys = {
adminCardDescription:
"A quick note from admins helps us improve Docker, source installs, and docs.",
installMethodLabel: "Install method",
usageTypeLabel: "Use case",
frictionAreaLabel: "Hardest setup area",
importantAreasLabel: "Most important areas",
sentiments: {
@@ -107,9 +106,9 @@ export const th: TranslationKeys = {
other: "Other",
},
usageTypes: {
personal: "Personal",
team_internal: "Team/internal",
business_workflow: "Business workflow",
personal: "แค่ฉันคนเดียว",
team_internal: "ทีมขนาดเล็ก",
business_workflow: "บริษัทหรือองค์กร",
education: "Education",
evaluating: "Evaluating",
},
@@ -133,6 +132,12 @@ export const th: TranslationKeys = {
ai_tools: "AI tools",
},
},
onboarding: {
usageSurveyTitle: "คุณใช้ SnapOtter อย่างไร?",
usageSurveyToolsLabel: "อะไรสำคัญที่สุดสำหรับคุณ?",
pickAnyHint: "(เลือกได้หลายข้อ)",
continueLabel: "ดำเนินการต่อ",
},
categories: {
essentials: "พื้นฐาน",
adjustments: "การปรับแต่ง",
+9 -4
View File
@@ -80,7 +80,6 @@ export const tr: TranslationKeys = {
adminCardDescription:
"A quick note from admins helps us improve Docker, source installs, and docs.",
installMethodLabel: "Install method",
usageTypeLabel: "Use case",
frictionAreaLabel: "Hardest setup area",
importantAreasLabel: "Most important areas",
sentiments: {
@@ -107,9 +106,9 @@ export const tr: TranslationKeys = {
other: "Other",
},
usageTypes: {
personal: "Personal",
team_internal: "Team/internal",
business_workflow: "Business workflow",
personal: "Sadece ben",
team_internal: "Küçük ekip",
business_workflow: "Şirket veya kurum",
education: "Education",
evaluating: "Evaluating",
},
@@ -133,6 +132,12 @@ export const tr: TranslationKeys = {
ai_tools: "AI tools",
},
},
onboarding: {
usageSurveyTitle: "SnapOtter'ı nasıl kullanıyorsunuz?",
usageSurveyToolsLabel: "Sizin için en önemlisi ne?",
pickAnyHint: "(istediğiniz kadar seçin)",
continueLabel: "Devam et",
},
categories: {
essentials: "Temel Araçlar",
adjustments: "Ayarlamalar",
+9 -4
View File
@@ -80,7 +80,6 @@ export const uk: TranslationKeys = {
adminCardDescription:
"A quick note from admins helps us improve Docker, source installs, and docs.",
installMethodLabel: "Install method",
usageTypeLabel: "Use case",
frictionAreaLabel: "Hardest setup area",
importantAreasLabel: "Most important areas",
sentiments: {
@@ -107,9 +106,9 @@ export const uk: TranslationKeys = {
other: "Other",
},
usageTypes: {
personal: "Personal",
team_internal: "Team/internal",
business_workflow: "Business workflow",
personal: "Тільки я",
team_internal: "Невелика команда",
business_workflow: "Компанія або організація",
education: "Education",
evaluating: "Evaluating",
},
@@ -133,6 +132,12 @@ export const uk: TranslationKeys = {
ai_tools: "AI tools",
},
},
onboarding: {
usageSurveyTitle: "Як ви використовуєте SnapOtter?",
usageSurveyToolsLabel: "Що для вас найважливіше?",
pickAnyHint: "(виберіть будь-яку кількість)",
continueLabel: "Продовжити",
},
categories: {
essentials: "Основні",
adjustments: "Корекція",
+9 -4
View File
@@ -81,7 +81,6 @@ export const vi: TranslationKeys = {
adminCardDescription:
"A quick note from admins helps us improve Docker, source installs, and docs.",
installMethodLabel: "Install method",
usageTypeLabel: "Use case",
frictionAreaLabel: "Hardest setup area",
importantAreasLabel: "Most important areas",
sentiments: {
@@ -108,9 +107,9 @@ export const vi: TranslationKeys = {
other: "Other",
},
usageTypes: {
personal: "Personal",
team_internal: "Team/internal",
business_workflow: "Business workflow",
personal: "Chỉ mình tôi",
team_internal: "Nhóm nhỏ",
business_workflow: "Công ty hoặc tổ chức",
education: "Education",
evaluating: "Evaluating",
},
@@ -134,6 +133,12 @@ export const vi: TranslationKeys = {
ai_tools: "AI tools",
},
},
onboarding: {
usageSurveyTitle: "Bạn đang sử dụng SnapOtter như thế nào?",
usageSurveyToolsLabel: "Điều gì quan trọng nhất với bạn?",
pickAnyHint: "(chọn bao nhiêu tùy thích)",
continueLabel: "Tiếp tục",
},
categories: {
essentials: "Cơ bản",
adjustments: "Điều chỉnh",
+9 -4
View File
@@ -80,7 +80,6 @@ export const zhCN: TranslationKeys = {
adminCardDescription:
"A quick note from admins helps us improve Docker, source installs, and docs.",
installMethodLabel: "Install method",
usageTypeLabel: "Use case",
frictionAreaLabel: "Hardest setup area",
importantAreasLabel: "Most important areas",
sentiments: {
@@ -107,9 +106,9 @@ export const zhCN: TranslationKeys = {
other: "Other",
},
usageTypes: {
personal: "Personal",
team_internal: "Team/internal",
business_workflow: "Business workflow",
personal: "仅我自己",
team_internal: "小型团队",
business_workflow: "公司或组织",
education: "Education",
evaluating: "Evaluating",
},
@@ -133,6 +132,12 @@ export const zhCN: TranslationKeys = {
ai_tools: "AI tools",
},
},
onboarding: {
usageSurveyTitle: "您如何使用 SnapOtter?",
usageSurveyToolsLabel: "您最看重什么?",
pickAnyHint: "(可多选)",
continueLabel: "继续",
},
categories: {
essentials: "基础工具",
adjustments: "调整",
+9 -4
View File
@@ -80,7 +80,6 @@ export const zhTW: TranslationKeys = {
adminCardDescription:
"A quick note from admins helps us improve Docker, source installs, and docs.",
installMethodLabel: "Install method",
usageTypeLabel: "Use case",
frictionAreaLabel: "Hardest setup area",
importantAreasLabel: "Most important areas",
sentiments: {
@@ -107,9 +106,9 @@ export const zhTW: TranslationKeys = {
other: "Other",
},
usageTypes: {
personal: "Personal",
team_internal: "Team/internal",
business_workflow: "Business workflow",
personal: "僅我自己",
team_internal: "小型團隊",
business_workflow: "公司或組織",
education: "Education",
evaluating: "Evaluating",
},
@@ -133,6 +132,12 @@ export const zhTW: TranslationKeys = {
ai_tools: "AI tools",
},
},
onboarding: {
usageSurveyTitle: "您如何使用 SnapOtter?",
usageSurveyToolsLabel: "您最重視什麼?",
pickAnyHint: "(可複選)",
continueLabel: "繼續",
},
categories: {
essentials: "基本工具",
adjustments: "調整",
@@ -134,6 +134,38 @@ describe("POST /api/v1/feedback", () => {
);
});
it("accepts an onboarding usage-survey submission", async () => {
process.env.ANALYTICS_BAKED_OVERRIDE = "on";
await refreshAnalyticsGate();
const token = await loginAsAdmin(testApp.app);
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/feedback",
headers: { authorization: `Bearer ${token}` },
payload: {
source: "onboarding",
surveyId: "onboarding-usage-v1",
promptVariant: "onboarding-overlay-v1",
usageType: "team_internal",
importantAreas: ["images", "pdf_docs"],
},
});
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body)).toEqual({ ok: true, accepted: true });
expect(captureFeedback).toHaveBeenCalledWith(
expect.objectContaining({
source: "onboarding",
survey_id: "onboarding-usage-v1",
prompt_variant: "onboarding-overlay-v1",
usage_type: "team_internal",
important_areas: ["images", "pdf_docs"],
}),
undefined,
);
});
it("drops identifying contact fields when contact consent is not checked", async () => {
process.env.ANALYTICS_BAKED_OVERRIDE = "on";
await refreshAnalyticsGate();
-4
View File
@@ -26,9 +26,6 @@ describe("FeedbackDialog", () => {
fireEvent.change(screen.getByLabelText("Install method"), {
target: { value: "docker_compose" },
});
fireEvent.change(screen.getByLabelText("Use case"), {
target: { value: "team_internal" },
});
fireEvent.change(screen.getByLabelText("Hardest setup area"), {
target: { value: "environment_variables" },
});
@@ -52,7 +49,6 @@ describe("FeedbackDialog", () => {
contactName: undefined,
company: undefined,
installMethod: "docker_compose",
usageType: "team_internal",
frictionArea: "environment_variables",
importantAreas: ["pdf_docs", "batch_workflows"],
});
+61 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { shouldShowInstallFeedbackCard } from "@/lib/feedback";
import { shouldShowInstallFeedbackCard, shouldShowUsageSurvey } from "@/lib/feedback";
const NOW = new Date("2026-01-15T00:00:00Z").getTime();
@@ -90,3 +90,63 @@ describe("shouldShowInstallFeedbackCard", () => {
).toBe(true);
});
});
describe("shouldShowUsageSurvey", () => {
it("shows only for admins after analytics config is loaded and enabled", () => {
expect(
shouldShowUsageSurvey({
settings: {},
role: "admin",
analyticsConfigLoaded: true,
analyticsEnabled: true,
}),
).toBe(true);
expect(
shouldShowUsageSurvey({
settings: {},
role: "user",
analyticsConfigLoaded: true,
analyticsEnabled: true,
}),
).toBe(false);
expect(
shouldShowUsageSurvey({
settings: {},
role: "admin",
analyticsConfigLoaded: false,
analyticsEnabled: true,
}),
).toBe(false);
expect(
shouldShowUsageSurvey({
settings: {},
role: "admin",
analyticsConfigLoaded: true,
analyticsEnabled: false,
}),
).toBe(false);
});
it("stays hidden after answering or permanently dismissing", () => {
expect(
shouldShowUsageSurvey({
settings: { "onboarding.usageSurvey.answeredAt": "2026-01-14T00:00:00Z" },
role: "admin",
analyticsConfigLoaded: true,
analyticsEnabled: true,
}),
).toBe(false);
expect(
shouldShowUsageSurvey({
settings: { "onboarding.usageSurvey.dismissedAt": "2026-01-14T00:00:00Z" },
role: "admin",
analyticsConfigLoaded: true,
analyticsEnabled: true,
}),
).toBe(false);
});
});
@@ -0,0 +1,116 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
const submitFeedback = vi.hoisted(() => vi.fn().mockResolvedValue({ ok: true, accepted: true }));
const apiGet = vi.hoisted(() => vi.fn());
const apiPut = vi.hoisted(() => vi.fn().mockResolvedValue({}));
const useAuth = vi.hoisted(() => vi.fn());
vi.mock("@/lib/feedback", async (importOriginal) => {
const actual: Record<string, unknown> = await importOriginal();
return { ...actual, submitFeedback };
});
vi.mock("@/lib/api", async (importOriginal) => {
const actual: Record<string, unknown> = await importOriginal();
return { ...actual, apiGet, apiPut };
});
vi.mock("@/hooks/use-auth", () => ({ useAuth }));
vi.mock("@/stores/analytics-store", () => ({
useAnalyticsStore: (
selector: (state: { config: { enabled: boolean }; configLoaded: boolean }) => unknown,
) => selector({ config: { enabled: true }, configLoaded: true }),
}));
import { UsageSurveyOverlay } from "@/components/onboarding/usage-survey-overlay";
afterEach(() => {
cleanup();
submitFeedback.mockClear();
apiGet.mockClear();
apiPut.mockClear();
useAuth.mockReset();
});
describe("UsageSurveyOverlay", () => {
it("renders nothing for a non-admin", () => {
useAuth.mockReturnValue({ role: "user" });
render(<UsageSurveyOverlay />);
expect(apiGet).not.toHaveBeenCalled();
expect(screen.queryByText("How are you using SnapOtter?")).toBeNull();
});
it("renders nothing once already answered or dismissed", async () => {
useAuth.mockReturnValue({ role: "admin" });
apiGet.mockResolvedValue({
settings: { "onboarding.usageSurvey.dismissedAt": "2026-01-01T00:00:00Z" },
});
render(<UsageSurveyOverlay />);
await waitFor(() => expect(apiGet).toHaveBeenCalled());
expect(screen.queryByText("How are you using SnapOtter?")).toBeNull();
});
it("shows both questions for an admin instance that hasn't answered", async () => {
useAuth.mockReturnValue({ role: "admin" });
apiGet.mockResolvedValue({ settings: {} });
render(<UsageSurveyOverlay />);
expect(await screen.findByText("How are you using SnapOtter?")).toBeDefined();
expect(screen.getByText("What matters most to you?")).toBeDefined();
expect(screen.getByRole("button", { name: /Just me/ })).toBeDefined();
expect(screen.getByRole("button", { name: "Continue" })).toBeDisabled();
});
it("submits the selected answers and records the settings key", async () => {
useAuth.mockReturnValue({ role: "admin" });
apiGet.mockResolvedValue({ settings: {} });
render(<UsageSurveyOverlay />);
await screen.findByText("How are you using SnapOtter?");
fireEvent.click(screen.getByRole("button", { name: /Small team/ }));
fireEvent.click(screen.getByRole("button", { name: /Images/ }));
fireEvent.click(screen.getByRole("button", { name: /PDF\/docs/ }));
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
await waitFor(() => {
expect(submitFeedback).toHaveBeenCalledWith({
source: "onboarding",
surveyId: "onboarding-usage-v1",
promptVariant: "onboarding-overlay-v1",
usageType: "team_internal",
importantAreas: ["images", "pdf_docs"],
});
});
expect(apiPut).toHaveBeenCalledWith("/v1/settings", {
"onboarding.usageSurvey.answeredAt": expect.any(String),
});
});
it("dismissing writes the dismiss key without submitting feedback", async () => {
useAuth.mockReturnValue({ role: "admin" });
apiGet.mockResolvedValue({ settings: {} });
render(<UsageSurveyOverlay />);
await screen.findByText("How are you using SnapOtter?");
fireEvent.click(screen.getByRole("button", { name: "Don't ask again" }));
await waitFor(() => {
expect(apiPut).toHaveBeenCalledWith("/v1/settings", {
"onboarding.usageSurvey.dismissedAt": expect.any(String),
});
});
expect(submitFeedback).not.toHaveBeenCalled();
});
});