feat(feedback): gate onboarding survey on first processing, add prompt lifecycle events (#615)

Defers the onboarding usage survey to the instance's first successful processing (the worker writes a one-time onboarding.firstProcessedAt marker and the overlay gates on it), so it reaches engaged users instead of first-landing visitors.

Replaces the two questions telemetry already answers (modality preference from tool_used, install method from instance_started) with what it can't infer: prior tool, self-host motivation, and discovery source.

Adds feedback_prompt_shown and feedback_prompt_dismissed on all five feedback surfaces (usage survey, per-job prompt, admin install card, global nav dialog, search-miss) so skip and completion rates are measurable, not just submissions. New survey strings translated into all 20 non-English locales.
This commit is contained in:
SnapOtter
2026-07-21 16:31:30 +00:00
committed by GitHub
parent b20bca3c3c
commit 129e42b95c
41 changed files with 950 additions and 209 deletions
+3 -1
View File
@@ -41,7 +41,7 @@ Both ride `POST /api/v1/feedback` and go through `cleanFeedbackProperties()`, a
| Event | Fires when | Key properties | | Event | Fires when | Key properties |
| --- | --- | --- | | --- | --- | --- |
| `feedback_submitted` | A user submits genuine feedback (nav button, tool result, failed job, search miss, admin installer card) | `source`, `sentiment`, `feedback_type`, `message`, `survey_id`, `prompt_variant`, `tool_id`, `search_query`, `job_status`, `error_category`, `contact_ok`, and, only with consent, `contact_email` / `contact_name` / `company` | | `feedback_submitted` | A user submits genuine feedback (nav button, tool result, failed job, search miss, admin installer card) | `source`, `sentiment`, `feedback_type`, `message`, `survey_id`, `prompt_variant`, `tool_id`, `search_query`, `job_status`, `error_category`, `contact_ok`, and, only with consent, `contact_email` / `contact_name` / `company` |
| `onboarding_survey_submitted` | A user completes the onboarding usage survey (`source: onboarding`) | `usage_type`, `important_areas`, `install_method`, `friction_area`, `survey_id`, `prompt_variant` | | `onboarding_survey_submitted` | A user completes the onboarding usage survey (`source: onboarding`) | `usage_type`, `prior_tool`, `selfhost_motivation`, `discovery_source`, `survey_id`, `prompt_variant` |
The onboarding survey is a profiling questionnaire, not feedback, so it gets its own event. Splitting the two keeps onboarding responses from swamping feedback metrics. The onboarding survey is a profiling questionnaire, not feedback, so it gets its own event. Splitting the two keeps onboarding responses from swamping feedback metrics.
@@ -68,6 +68,8 @@ Emitted from `apps/web` through `track()`; properties are filtered by the `ALLOW
| `pipeline_saved` | A pipeline is saved | `step_count` | | `pipeline_saved` | A pipeline is saved | `step_count` |
| `pipeline_template_selected` | A pipeline template is picked | `template_id` | | `pipeline_template_selected` | A pipeline template is picked | `template_id` |
| `sponsor_clicked` | The sponsor link is clicked | none | | `sponsor_clicked` | The sponsor link is clicked | none |
| `feedback_prompt_shown` | A feedback surface becomes visible (usage survey, per-job prompt, admin install card, nav dialog, search miss) | `source`, `survey_id`, `prompt_variant` |
| `feedback_prompt_dismissed` | A feedback surface is dismissed without submitting | `source`, `survey_id`, `prompt_variant`, `dismiss_kind` (`close`, `dont_ask_again`, or `snooze`) |
### SDK-generated events ### SDK-generated events
+9
View File
@@ -31,6 +31,7 @@ import {
getBundleForTool, getBundleForTool,
getOptionalBundleForTool, getOptionalBundleForTool,
isToolInputError, isToolInputError,
ONBOARDING_FIRST_PROCESSED_KEY,
type PipelineExecutedProperties, type PipelineExecutedProperties,
TOOLS, TOOLS,
} from "@snapotter/shared"; } from "@snapotter/shared";
@@ -53,6 +54,7 @@ import {
} from "../lib/object-storage.js"; } from "../lib/object-storage.js";
import { OCR_MAX_ENCODED_INPUT_BYTES } from "../lib/ocr-limits.js"; import { OCR_MAX_ENCODED_INPUT_BYTES } from "../lib/ocr-limits.js";
import { SCRUB_PDF_PRODUCER_TOOLS, scrubPdfProducer } from "../lib/pdf-producer.js"; import { SCRUB_PDF_PRODUCER_TOOLS, scrubPdfProducer } from "../lib/pdf-producer.js";
import { setSettingIfAbsent } from "../lib/settings-helpers.js";
import { timeoutMessage } from "../lib/timeout.js"; import { timeoutMessage } from "../lib/timeout.js";
import { InputValidationError } from "../modality/contract.js"; import { InputValidationError } from "../modality/contract.js";
import { import {
@@ -493,6 +495,13 @@ async function processToolJob(job: Job<ToolJobData>): Promise<ToolJobResult> {
}, },
data.analyticsDistinctId, data.analyticsDistinctId,
); );
// Mark the instance's first successful processing so the onboarding
// survey only appears once the admin has produced a real result
// (shouldShowUsageSurvey gate in web feedback.ts). First-write-wins, so
// the timestamp reflects the genuine first job and later jobs no-op.
void setSettingIfAbsent(ONBOARDING_FIRST_PROCESSED_KEY, new Date().toISOString()).catch(
() => {},
);
} }
// Record queue wait time and completion on the OTel span // Record queue wait time and completion on the OTel span
+11
View File
@@ -2,10 +2,13 @@ import {
ANALYTICS_BAKED, ANALYTICS_BAKED,
ANALYTICS_EVENTS, ANALYTICS_EVENTS,
APP_VERSION, APP_VERSION,
type FeedbackDiscoverySource,
type FeedbackErrorCategory, type FeedbackErrorCategory,
type FeedbackFrictionArea, type FeedbackFrictionArea,
type FeedbackImportantArea, type FeedbackImportantArea,
type FeedbackInstallMethod, type FeedbackInstallMethod,
type FeedbackPriorTool,
type FeedbackSelfHostMotivation,
type FeedbackSentiment, type FeedbackSentiment,
type FeedbackSource, type FeedbackSource,
type FeedbackSurveyId, type FeedbackSurveyId,
@@ -38,6 +41,11 @@ export interface FeedbackEventProperties {
usage_type?: FeedbackUsageType; usage_type?: FeedbackUsageType;
important_areas?: FeedbackImportantArea[]; important_areas?: FeedbackImportantArea[];
friction_area?: FeedbackFrictionArea; friction_area?: FeedbackFrictionArea;
// Onboarding survey (telemetry-blind) answers: what they used before, why they
// self-host, and how they found SnapOtter. See analytics/feedback.ts.
prior_tool?: FeedbackPriorTool;
selfhost_motivation?: FeedbackSelfHostMotivation;
discovery_source?: FeedbackDiscoverySource;
error_category?: FeedbackErrorCategory; error_category?: FeedbackErrorCategory;
} }
@@ -138,6 +146,9 @@ function cleanFeedbackProperties(properties: FeedbackEventProperties): Record<st
copyString("install_method"); copyString("install_method");
copyString("usage_type"); copyString("usage_type");
copyString("friction_area"); copyString("friction_area");
copyString("prior_tool");
copyString("selfhost_motivation");
copyString("discovery_source");
copyString("error_category"); copyString("error_category");
if (properties.important_areas?.length) { if (properties.important_areas?.length) {
+10
View File
@@ -15,6 +15,16 @@ export async function upsertSetting(key: string, value: string): Promise<void> {
}); });
} }
/**
* Insert a setting only if the key does not already exist (first-write-wins).
* Uses ON CONFLICT DO NOTHING, so repeated calls are cheap no-ops after the
* first and the original value is preserved. Used for one-time markers like the
* instance's first successful processing that gate the onboarding survey.
*/
export async function setSettingIfAbsent(key: string, value: string): Promise<void> {
await db.insert(schema.settings).values({ key, value }).onConflictDoNothing();
}
/** /**
* Read a numeric setting from the DB `settings` table. * Read a numeric setting from the DB `settings` table.
* Returns `defaultValue` when the key is missing, non-numeric, or on DB error. * Returns `defaultValue` when the key is missing, non-numeric, or on DB error.
+9
View File
@@ -1,8 +1,11 @@
import { import {
FEEDBACK_DISCOVERY_SOURCE_VALUES,
FEEDBACK_ERROR_CATEGORY_VALUES, FEEDBACK_ERROR_CATEGORY_VALUES,
FEEDBACK_FRICTION_AREA_VALUES, FEEDBACK_FRICTION_AREA_VALUES,
FEEDBACK_IMPORTANT_AREA_VALUES, FEEDBACK_IMPORTANT_AREA_VALUES,
FEEDBACK_INSTALL_METHOD_VALUES, FEEDBACK_INSTALL_METHOD_VALUES,
FEEDBACK_PRIOR_TOOL_VALUES,
FEEDBACK_SELFHOST_MOTIVATION_VALUES,
FEEDBACK_SENTIMENT_VALUES, FEEDBACK_SENTIMENT_VALUES,
FEEDBACK_SOURCE_VALUES, FEEDBACK_SOURCE_VALUES,
FEEDBACK_SURVEY_ID_VALUES, FEEDBACK_SURVEY_ID_VALUES,
@@ -58,6 +61,9 @@ const feedbackBodySchema = z
usageType: z.enum(FEEDBACK_USAGE_TYPE_VALUES).optional(), usageType: z.enum(FEEDBACK_USAGE_TYPE_VALUES).optional(),
importantAreas: z.array(z.enum(FEEDBACK_IMPORTANT_AREA_VALUES)).max(5).optional(), importantAreas: z.array(z.enum(FEEDBACK_IMPORTANT_AREA_VALUES)).max(5).optional(),
frictionArea: z.enum(FEEDBACK_FRICTION_AREA_VALUES).optional(), frictionArea: z.enum(FEEDBACK_FRICTION_AREA_VALUES).optional(),
priorTool: z.enum(FEEDBACK_PRIOR_TOOL_VALUES).optional(),
selfHostMotivation: z.enum(FEEDBACK_SELFHOST_MOTIVATION_VALUES).optional(),
discoverySource: z.enum(FEEDBACK_DISCOVERY_SOURCE_VALUES).optional(),
errorCategory: z.enum(FEEDBACK_ERROR_CATEGORY_VALUES).optional(), errorCategory: z.enum(FEEDBACK_ERROR_CATEGORY_VALUES).optional(),
}) })
.superRefine((value, ctx) => { .superRefine((value, ctx) => {
@@ -97,6 +103,9 @@ function toPostHogProperties(body: z.infer<typeof feedbackBodySchema>): Feedback
usage_type: body.usageType, usage_type: body.usageType,
important_areas: body.importantAreas, important_areas: body.importantAreas,
friction_area: body.frictionArea, friction_area: body.frictionArea,
prior_tool: body.priorTool,
selfhost_motivation: body.selfHostMotivation,
discovery_source: body.discoverySource,
error_category: body.errorCategory, error_category: body.errorCategory,
}; };
} }
@@ -1,5 +1,7 @@
import { MessageSquare } from "lucide-react"; import { MessageSquare } from "lucide-react";
import { useEffect, useRef } from "react";
import { useTranslation } from "@/contexts/i18n-context"; import { useTranslation } from "@/contexts/i18n-context";
import { trackFeedbackPromptDismissed, trackFeedbackPromptShown } from "@/lib/feedback";
interface AdminInstallFeedbackCardProps { interface AdminInstallFeedbackCardProps {
visible: boolean; visible: boolean;
@@ -15,6 +17,17 @@ export function AdminInstallFeedbackCard({
onDismissForever, onDismissForever,
}: AdminInstallFeedbackCardProps) { }: AdminInstallFeedbackCardProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const shownTrackedRef = useRef(false);
// Impression once the card first renders, so the settings-page install
// feedback has a shown-vs-acted denominator like the other surfaces.
useEffect(() => {
if (visible && !shownTrackedRef.current) {
shownTrackedRef.current = true;
trackFeedbackPromptShown("admin_installer");
}
}, [visible]);
if (!visible) return null; if (!visible) return null;
return ( return (
@@ -36,14 +49,20 @@ export function AdminInstallFeedbackCard({
</button> </button>
<button <button
type="button" type="button"
onClick={onRemindLater} onClick={() => {
trackFeedbackPromptDismissed("admin_installer", "snooze");
onRemindLater();
}}
className="rounded-md border border-border px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:bg-background hover:text-foreground" className="rounded-md border border-border px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:bg-background hover:text-foreground"
> >
{t.feedback.remindLater} {t.feedback.remindLater}
</button> </button>
<button <button
type="button" type="button"
onClick={onDismissForever} onClick={() => {
trackFeedbackPromptDismissed("admin_installer", "dont_ask_again");
onDismissForever();
}}
className="rounded-md border border-border px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:bg-background hover:text-foreground" className="rounded-md border border-border px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:bg-background hover:text-foreground"
> >
{t.feedback.dontAskAgain} {t.feedback.dontAskAgain}
@@ -7,6 +7,8 @@ import {
promptVariantForSource, promptVariantForSource,
submitFeedback, submitFeedback,
surveyIdForSource, surveyIdForSource,
trackFeedbackPromptDismissed,
trackFeedbackPromptShown,
} from "@/lib/feedback"; } from "@/lib/feedback";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useAnalyticsStore } from "@/stores/analytics-store"; import { useAnalyticsStore } from "@/stores/analytics-store";
@@ -96,19 +98,22 @@ export function ToolFeedbackPrompt({
const [dialogSentiment, setDialogSentiment] = useState<FeedbackSentiment | undefined>(); const [dialogSentiment, setDialogSentiment] = useState<FeedbackSentiment | undefined>();
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [thanks, setThanks] = useState(false); const [thanks, setThanks] = useState(false);
const source = jobStatus === "failed" ? "failed_job" : "tool_result";
useEffect(() => { useEffect(() => {
if (!analyticsLoaded || !analyticsConfig?.enabled) return; if (!analyticsLoaded || !analyticsConfig?.enabled) return;
const show = shouldShowPrompt(toolId); const show = shouldShowPrompt(toolId);
if (show) markPromptShown(); if (show) {
markPromptShown();
trackFeedbackPromptShown(source);
}
setVisible(show); setVisible(show);
}, [analyticsLoaded, analyticsConfig?.enabled, toolId]); }, [analyticsLoaded, analyticsConfig?.enabled, toolId, source]);
if (!analyticsLoaded || !analyticsConfig?.enabled) return null; if (!analyticsLoaded || !analyticsConfig?.enabled) return null;
if (!visible && !thanks && !dialogOpen) return null; if (!visible && !thanks && !dialogOpen) return null;
async function handleQuickSentiment(sentiment: FeedbackSentiment) { async function handleQuickSentiment(sentiment: FeedbackSentiment) {
const source = jobStatus === "failed" ? "failed_job" : "tool_result";
if (sentiment === "great") { if (sentiment === "great") {
markPromptHandled(toolId); markPromptHandled(toolId);
setVisible(false); setVisible(false);
@@ -136,11 +141,13 @@ export function ToolFeedbackPrompt({
function handleDismiss() { function handleDismiss() {
markPromptHandled(toolId); markPromptHandled(toolId);
trackFeedbackPromptDismissed(source, "close");
setVisible(false); setVisible(false);
} }
function handleDontAskAgain() { function handleDontAskAgain() {
disablePrompts(); disablePrompts();
trackFeedbackPromptDismissed(source, "dont_ask_again");
setVisible(false); setVisible(false);
} }
+21 -3
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useMobile } from "@/hooks/use-mobile"; import { useMobile } from "@/hooks/use-mobile";
import { trackFeedbackPromptDismissed, trackFeedbackPromptShown } from "@/lib/feedback";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useConnectionStore } from "@/stores/connection-store"; import { useConnectionStore } from "@/stores/connection-store";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
@@ -20,6 +21,9 @@ export function AppLayout({ children, breadcrumb, navVariant }: AppLayoutProps)
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
const [helpOpen, setHelpOpen] = useState(false); const [helpOpen, setHelpOpen] = useState(false);
const [feedbackOpen, setFeedbackOpen] = useState(false); const [feedbackOpen, setFeedbackOpen] = useState(false);
// Distinguishes an abandoned feedback dialog (dismissed) from one that was
// submitted, so the dismissed event does not fire after a real submission.
const feedbackSubmittedRef = useRef(false);
const isMobile = useMobile(); const isMobile = useMobile();
const connectionStatus = useConnectionStore((s) => s.status); const connectionStatus = useConnectionStore((s) => s.status);
const bannerVisible = connectionStatus !== "connected"; const bannerVisible = connectionStatus !== "connected";
@@ -51,7 +55,11 @@ export function AppLayout({ children, breadcrumb, navVariant }: AppLayoutProps)
variant={navVariant} variant={navVariant}
breadcrumb={breadcrumb} breadcrumb={breadcrumb}
onHelpClick={() => setHelpOpen(true)} onHelpClick={() => setHelpOpen(true)}
onFeedbackClick={() => setFeedbackOpen(true)} onFeedbackClick={() => {
feedbackSubmittedRef.current = false;
trackFeedbackPromptShown("global");
setFeedbackOpen(true);
}}
onSettingsClick={() => setSettingsOpen(true)} onSettingsClick={() => setSettingsOpen(true)}
/> />
@@ -74,7 +82,17 @@ export function AppLayout({ children, breadcrumb, navVariant }: AppLayoutProps)
<HelpDialog open={helpOpen} onClose={() => setHelpOpen(false)} /> <HelpDialog open={helpOpen} onClose={() => setHelpOpen(false)} />
{/* Feedback dialog */} {/* Feedback dialog */}
<FeedbackDialog open={feedbackOpen} source="global" onClose={() => setFeedbackOpen(false)} /> <FeedbackDialog
open={feedbackOpen}
source="global"
onSubmitted={() => {
feedbackSubmittedRef.current = true;
}}
onClose={() => {
if (!feedbackSubmittedRef.current) trackFeedbackPromptDismissed("global", "close");
setFeedbackOpen(false);
}}
/>
{/* Global AI install progress */} {/* Global AI install progress */}
<AiInstallIndicator /> <AiInstallIndicator />
@@ -1,16 +1,9 @@
import { FEEDBACK_FRICTION_AREA_VALUES, FEEDBACK_INSTALL_METHOD_VALUES } from "@snapotter/shared";
import { import {
Building2, FEEDBACK_DISCOVERY_SOURCE_VALUES,
FileText, FEEDBACK_PRIOR_TOOL_VALUES,
GraduationCap, FEEDBACK_SELFHOST_MOTIVATION_VALUES,
Image, } from "@snapotter/shared";
Layers, import { Building2, GraduationCap, Search, User, Users } from "lucide-react";
Search,
Sparkles,
User,
Users,
Video,
} from "lucide-react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import { useTranslation } from "@/contexts/i18n-context"; import { useTranslation } from "@/contexts/i18n-context";
@@ -19,14 +12,16 @@ import { useFocusTrap } from "@/hooks/use-focus-trap";
import { apiGet, apiPut } from "@/lib/api"; import { apiGet, apiPut } from "@/lib/api";
import { AUTH_GUARD_UNGATED_PATHS } from "@/lib/auth-routes"; import { AUTH_GUARD_UNGATED_PATHS } from "@/lib/auth-routes";
import { import {
type FeedbackFrictionArea, type FeedbackDiscoverySource,
type FeedbackImportantArea, type FeedbackPriorTool,
type FeedbackInstallMethod, type FeedbackSelfHostMotivation,
type FeedbackUsageType, type FeedbackUsageType,
promptVariantForSource, promptVariantForSource,
shouldShowUsageSurvey, shouldShowUsageSurvey,
submitFeedback, submitFeedback,
surveyIdForSource, surveyIdForSource,
trackFeedbackPromptDismissed,
trackFeedbackPromptShown,
} from "@/lib/feedback"; } from "@/lib/feedback";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { withTimeout } from "@/lib/with-timeout"; import { withTimeout } from "@/lib/with-timeout";
@@ -45,14 +40,6 @@ const USAGE_TYPES: { value: FeedbackUsageType; Icon: typeof User; wide?: boolean
{ value: "evaluating", Icon: Search, wide: true }, { 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() { export function UsageSurveyOverlay() {
const { t } = useTranslation(); const { t } = useTranslation();
const { role, mustChangePassword } = useAuth(); const { role, mustChangePassword } = useAuth();
@@ -63,15 +50,20 @@ export function UsageSurveyOverlay() {
const [settings, setSettings] = useState<Record<string, string> | null>(null); const [settings, setSettings] = useState<Record<string, string> | null>(null);
const [usageType, setUsageType] = useState<FeedbackUsageType | null>(null); const [usageType, setUsageType] = useState<FeedbackUsageType | null>(null);
const [importantAreas, setImportantAreas] = useState<FeedbackImportantArea[]>([]); // The survey asks only what telemetry cannot infer. Modality preference is
// Install method and friction area are optional: null until the admin picks one, // already visible in tool_used, and install method in instance_started, so
// so we never record an unanswered dropdown as a real value. // instead we ask what they came from, why they self-host, and how they found
const [installMethod, setInstallMethod] = useState<FeedbackInstallMethod | null>(null); // us. Prior tool and motivation are optional; discovery source is optional too.
const [frictionArea, setFrictionArea] = useState<FeedbackFrictionArea | null>(null); const [priorTool, setPriorTool] = useState<FeedbackPriorTool | null>(null);
const [selfHostMotivation, setSelfHostMotivation] = useState<FeedbackSelfHostMotivation | null>(
null,
);
const [discoverySource, setDiscoverySource] = useState<FeedbackDiscoverySource | null>(null);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [dismissing, setDismissing] = useState(false); const [dismissing, setDismissing] = useState(false);
const busy = submitting || dismissing; const busy = submitting || dismissing;
const submittedAnswerKeyRef = useRef<string | null>(null); const submittedAnswerKeyRef = useRef<string | null>(null);
const shownTrackedRef = useRef(false);
const eligibleAuthState = role === "admin" && !mustChangePassword; const eligibleAuthState = role === "admin" && !mustChangePassword;
const eligibleRoute = !AUTH_GUARD_UNGATED_PATHS.has(location.pathname); const eligibleRoute = !AUTH_GUARD_UNGATED_PATHS.has(location.pathname);
@@ -102,11 +94,15 @@ export function UsageSurveyOverlay() {
useFocusTrap(containerRef, visible); useFocusTrap(containerRef, visible);
function toggleArea(area: FeedbackImportantArea) { // Record the impression once the overlay first becomes visible, so the survey's
setImportantAreas((current) => // completion and skip rates have a denominator (submissions alone can't measure
current.includes(area) ? current.filter((value) => value !== area) : [...current, area], // how many admins saw it and walked away).
); useEffect(() => {
if (visible && !shownTrackedRef.current) {
shownTrackedRef.current = true;
trackFeedbackPromptShown("onboarding");
} }
}, [visible]);
async function recordSettingsKey(key: string) { async function recordSettingsKey(key: string) {
const value = new Date().toISOString(); const value = new Date().toISOString();
@@ -117,12 +113,7 @@ export function UsageSurveyOverlay() {
async function handleContinue() { async function handleContinue() {
if (!usageType || busy) return; if (!usageType || busy) return;
setSubmitting(true); setSubmitting(true);
const answerKey = JSON.stringify({ const answerKey = JSON.stringify({ usageType, priorTool, selfHostMotivation, discoverySource });
usageType,
importantAreas: [...importantAreas].sort(),
installMethod,
frictionArea,
});
try { try {
if (submittedAnswerKeyRef.current !== answerKey) { if (submittedAnswerKeyRef.current !== answerKey) {
await withTimeout( await withTimeout(
@@ -131,9 +122,9 @@ export function UsageSurveyOverlay() {
surveyId: surveyIdForSource("onboarding"), surveyId: surveyIdForSource("onboarding"),
promptVariant: promptVariantForSource("onboarding"), promptVariant: promptVariantForSource("onboarding"),
usageType, usageType,
importantAreas, ...(priorTool ? { priorTool } : {}),
...(installMethod ? { installMethod } : {}), ...(selfHostMotivation ? { selfHostMotivation } : {}),
...(frictionArea ? { frictionArea } : {}), ...(discoverySource ? { discoverySource } : {}),
}), }),
WRITE_TIMEOUT_MS, WRITE_TIMEOUT_MS,
); );
@@ -155,6 +146,7 @@ export function UsageSurveyOverlay() {
async function handleDismiss() { async function handleDismiss() {
if (busy) return; if (busy) return;
setDismissing(true); setDismissing(true);
trackFeedbackPromptDismissed("onboarding", "dont_ask_again");
try { try {
await recordSettingsKey("onboarding.usageSurvey.dismissedAt"); await recordSettingsKey("onboarding.usageSurvey.dismissedAt");
} catch { } catch {
@@ -217,64 +209,62 @@ export function UsageSurveyOverlay() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<p id="usage-survey-tools-label" className="text-sm font-medium text-foreground"> <p id="usage-survey-prior-label" className="text-sm font-medium text-foreground">
{t.onboarding.usageSurveyToolsLabel}{" "} {t.onboarding.priorToolLabel}
<span className="text-xs font-normal text-muted-foreground">
{t.onboarding.pickAnyHint}
</span>
</p> </p>
{/* biome-ignore lint/a11y/useSemanticElements: plain group wrapper for toggle buttons, a fieldset would disrupt the grid layout */}
<div <div
role="group" role="radiogroup"
aria-labelledby="usage-survey-tools-label" aria-labelledby="usage-survey-prior-label"
className="grid grid-cols-2 gap-2" className="grid grid-cols-1 gap-2"
> >
{IMPORTANT_AREAS.map(({ value, Icon, wide }) => ( {FEEDBACK_PRIOR_TOOL_VALUES.map((value) => (
// biome-ignore lint/a11y/useSemanticElements: styled button acting as an ARIA radio, not a native input
<button <button
key={value} key={value}
type="button" type="button"
aria-pressed={importantAreas.includes(value)} role="radio"
onClick={() => toggleArea(value)} aria-checked={priorTool === value}
onClick={() => setPriorTool((current) => (current === value ? null : value))}
className={cn( className={cn(
"flex items-center gap-2 rounded-lg border px-3 py-2.5 text-sm font-medium text-start transition-colors", "rounded-lg border px-3 py-2.5 text-sm font-medium text-start transition-colors",
importantAreas.includes(value) priorTool === value
? "border-primary bg-primary/10 text-primary-ink" ? "border-primary bg-primary/10 text-primary-ink"
: "border-border text-foreground hover:bg-muted", : "border-border text-foreground hover:bg-muted",
wide && "col-span-2 justify-center",
)} )}
> >
<Icon aria-hidden="true" className="h-4 w-4 shrink-0" /> {t.feedback.priorTools[value]}
{t.feedback.importantAreas[value]}
</button> </button>
))} ))}
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<p id="usage-survey-install-label" className="text-sm font-medium text-foreground"> <p id="usage-survey-motivation-label" className="text-sm font-medium text-foreground">
{t.feedback.installMethodLabel} {t.onboarding.selfHostMotivationLabel}
</p> </p>
<div <div
role="radiogroup" role="radiogroup"
aria-labelledby="usage-survey-install-label" aria-labelledby="usage-survey-motivation-label"
className="grid grid-cols-2 gap-2" className="grid grid-cols-1 gap-2"
> >
{FEEDBACK_INSTALL_METHOD_VALUES.map((value) => ( {FEEDBACK_SELFHOST_MOTIVATION_VALUES.map((value) => (
// biome-ignore lint/a11y/useSemanticElements: styled button acting as an ARIA radio, not a native input // biome-ignore lint/a11y/useSemanticElements: styled button acting as an ARIA radio, not a native input
<button <button
key={value} key={value}
type="button" type="button"
role="radio" role="radio"
aria-checked={installMethod === value} aria-checked={selfHostMotivation === value}
onClick={() => setInstallMethod((current) => (current === value ? null : value))} onClick={() =>
setSelfHostMotivation((current) => (current === value ? null : value))
}
className={cn( className={cn(
"rounded-lg border px-3 py-2.5 text-sm font-medium text-start transition-colors", "rounded-lg border px-3 py-2.5 text-sm font-medium text-start transition-colors",
installMethod === value selfHostMotivation === value
? "border-primary bg-primary/10 text-primary-ink" ? "border-primary bg-primary/10 text-primary-ink"
: "border-border text-foreground hover:bg-muted", : "border-border text-foreground hover:bg-muted",
)} )}
> >
{t.feedback.installMethods[value]} {t.feedback.selfHostMotivations[value]}
</button> </button>
))} ))}
</div> </div>
@@ -282,23 +272,26 @@ export function UsageSurveyOverlay() {
<div className="space-y-2"> <div className="space-y-2">
<label <label
htmlFor="usage-survey-friction-area" htmlFor="usage-survey-discovery-source"
className="block text-sm font-medium text-foreground" className="block text-sm font-medium text-foreground"
> >
{t.feedback.frictionAreaLabel} {t.onboarding.discoverySourceLabel}{" "}
<span className="text-xs font-normal text-muted-foreground">
{t.onboarding.optionalHint}
</span>
</label> </label>
<select <select
id="usage-survey-friction-area" id="usage-survey-discovery-source"
value={frictionArea ?? ""} value={discoverySource ?? ""}
onChange={(event) => onChange={(event) =>
setFrictionArea((event.target.value || null) as FeedbackFrictionArea | null) setDiscoverySource((event.target.value || null) as FeedbackDiscoverySource | null)
} }
className="w-full rounded-lg border border-border bg-background px-3 py-2.5 text-sm text-foreground" className="w-full rounded-lg border border-border bg-background px-3 py-2.5 text-sm text-foreground"
> >
<option value="" /> <option value="" />
{FEEDBACK_FRICTION_AREA_VALUES.map((value) => ( {FEEDBACK_DISCOVERY_SOURCE_VALUES.map((value) => (
<option key={value} value={value}> <option key={value} value={value}>
{t.feedback.frictionAreas[value]} {t.feedback.discoverySources[value]}
</option> </option>
))} ))}
</select> </select>
+2
View File
@@ -25,6 +25,8 @@ const ALLOWED: Record<string, ReadonlySet<string>> = {
pipeline_step_added: new Set(["tool_id"]), pipeline_step_added: new Set(["tool_id"]),
pipeline_saved: new Set(["step_count"]), pipeline_saved: new Set(["step_count"]),
pipeline_template_selected: new Set(["template_id"]), pipeline_template_selected: new Set(["template_id"]),
feedback_prompt_shown: new Set(["source", "survey_id", "prompt_variant"]),
feedback_prompt_dismissed: new Set(["source", "survey_id", "prompt_variant", "dismiss_kind"]),
}; };
function sanitize(event: string, properties?: Record<string, unknown>): Record<string, unknown> { function sanitize(event: string, properties?: Record<string, unknown>): Record<string, unknown> {
+45
View File
@@ -1,21 +1,29 @@
import type { import type {
FeedbackDiscoverySource,
FeedbackErrorCategory, FeedbackErrorCategory,
FeedbackFrictionArea, FeedbackFrictionArea,
FeedbackImportantArea, FeedbackImportantArea,
FeedbackInstallMethod, FeedbackInstallMethod,
FeedbackPriorTool,
FeedbackSelfHostMotivation,
FeedbackSentiment, FeedbackSentiment,
FeedbackSource, FeedbackSource,
FeedbackSurveyId, FeedbackSurveyId,
FeedbackType, FeedbackType,
FeedbackUsageType, FeedbackUsageType,
} from "@snapotter/shared"; } from "@snapotter/shared";
import { ANALYTICS_EVENTS, ONBOARDING_FIRST_PROCESSED_KEY } from "@snapotter/shared";
import { track } from "@/lib/analytics";
import { apiPost } from "@/lib/api"; import { apiPost } from "@/lib/api";
export type { export type {
FeedbackDiscoverySource,
FeedbackErrorCategory, FeedbackErrorCategory,
FeedbackFrictionArea, FeedbackFrictionArea,
FeedbackImportantArea, FeedbackImportantArea,
FeedbackInstallMethod, FeedbackInstallMethod,
FeedbackPriorTool,
FeedbackSelfHostMotivation,
FeedbackSentiment, FeedbackSentiment,
FeedbackSource, FeedbackSource,
FeedbackSurveyId, FeedbackSurveyId,
@@ -50,6 +58,9 @@ export interface FeedbackPayload {
usageType?: FeedbackUsageType; usageType?: FeedbackUsageType;
importantAreas?: FeedbackImportantArea[]; importantAreas?: FeedbackImportantArea[];
frictionArea?: FeedbackFrictionArea; frictionArea?: FeedbackFrictionArea;
priorTool?: FeedbackPriorTool;
selfHostMotivation?: FeedbackSelfHostMotivation;
discoverySource?: FeedbackDiscoverySource;
errorCategory?: FeedbackErrorCategory; errorCategory?: FeedbackErrorCategory;
} }
@@ -100,6 +111,35 @@ export function promptVariantForSource(source: FeedbackSource): FeedbackPromptVa
} }
} }
/** How a feedback prompt was dismissed, for the feedback_prompt_dismissed event. */
export type FeedbackDismissKind = "close" | "dont_ask_again" | "snooze";
/**
* Fire when a feedback surface becomes visible. Paired with
* trackFeedbackPromptDismissed and the server-side submit event, this gives skip
* and completion rates a denominator instead of counting only submissions.
*/
export function trackFeedbackPromptShown(source: FeedbackSource): void {
track(ANALYTICS_EVENTS.FEEDBACK_PROMPT_SHOWN, {
source,
survey_id: surveyIdForSource(source),
prompt_variant: promptVariantForSource(source),
});
}
/** Fire when a feedback surface is dismissed without submitting. */
export function trackFeedbackPromptDismissed(
source: FeedbackSource,
dismissKind: FeedbackDismissKind,
): void {
track(ANALYTICS_EVENTS.FEEDBACK_PROMPT_DISMISSED, {
source,
survey_id: surveyIdForSource(source),
prompt_variant: promptVariantForSource(source),
dismiss_kind: dismissKind,
});
}
export function classifyFeedbackError(message: string | null | undefined): FeedbackErrorCategory { export function classifyFeedbackError(message: string | null | undefined): FeedbackErrorCategory {
const value = (message ?? "").toLowerCase(); const value = (message ?? "").toLowerCase();
if (!value) return "unknown"; if (!value) return "unknown";
@@ -146,6 +186,11 @@ export function shouldShowUsageSurvey({
analyticsEnabled, analyticsEnabled,
}: UsageSurveyVisibilityOptions): boolean { }: UsageSurveyVisibilityOptions): boolean {
if (!analyticsConfigLoaded || !analyticsEnabled || role !== "admin") return false; if (!analyticsConfigLoaded || !analyticsEnabled || role !== "admin") return false;
// Hold the survey until the instance has completed its first processing (the
// worker writes this marker on the first successful job). Asking on an empty
// first-landing app yields answers from users who haven't used the product;
// waiting for one real result reaches an engaged admin instead.
if (!settings[ONBOARDING_FIRST_PROCESSED_KEY]) return false;
return ( return (
!settings["onboarding.usageSurvey.answeredAt"] && !settings["onboarding.usageSurvey.answeredAt"] &&
!settings["onboarding.usageSurvey.dismissedAt"] !settings["onboarding.usageSurvey.dismissedAt"]
+15 -1
View File
@@ -11,6 +11,7 @@ import { useFuseSearch } from "@/hooks/use-fuse-search.js";
import { usePageTitle } from "@/hooks/use-page-title.js"; import { usePageTitle } from "@/hooks/use-page-title.js";
import { useRecentTools } from "@/hooks/use-recent-tools.js"; import { useRecentTools } from "@/hooks/use-recent-tools.js";
import type { FeedbackPromptVariant } from "@/lib/feedback.js"; import type { FeedbackPromptVariant } from "@/lib/feedback.js";
import { trackFeedbackPromptDismissed, trackFeedbackPromptShown } from "@/lib/feedback.js";
import { format } from "@/lib/format.js"; import { format } from "@/lib/format.js";
import { ICON_MAP } from "@/lib/icon-map.js"; import { ICON_MAP } from "@/lib/icon-map.js";
import { getCategoryName, getToolName } from "@/lib/tool-i18n.js"; import { getCategoryName, getToolName } from "@/lib/tool-i18n.js";
@@ -55,9 +56,14 @@ export function HomePage() {
const analyticsOn = analyticsConfigLoaded && analyticsConfig?.enabled === true; const analyticsOn = analyticsConfigLoaded && analyticsConfig?.enabled === true;
const [requestOpen, setRequestOpen] = useState(false); const [requestOpen, setRequestOpen] = useState(false);
const [requestVariant, setRequestVariant] = useState<FeedbackPromptVariant>("search-empty-v1"); const [requestVariant, setRequestVariant] = useState<FeedbackPromptVariant>("search-empty-v1");
// Tracks whether the request dialog was submitted, so closing it counts as a
// dismiss only when the admin abandoned it.
const requestSubmittedRef = useRef(false);
const openRequest = useCallback((variant: FeedbackPromptVariant) => { const openRequest = useCallback((variant: FeedbackPromptVariant) => {
requestSubmittedRef.current = false;
setRequestVariant(variant); setRequestVariant(variant);
trackFeedbackPromptShown("search_miss");
setRequestOpen(true); setRequestOpen(true);
}, []); }, []);
@@ -200,7 +206,15 @@ export function HomePage() {
source="search_miss" source="search_miss"
searchQuery={search} searchQuery={search}
promptVariant={requestVariant} promptVariant={requestVariant}
onClose={() => setRequestOpen(false)} onSubmitted={() => {
requestSubmittedRef.current = true;
}}
onClose={() => {
if (!requestSubmittedRef.current) {
trackFeedbackPromptDismissed("search_miss", "close");
}
setRequestOpen(false);
}}
/> />
</div> </div>
</div> </div>
+5
View File
@@ -27,6 +27,11 @@ export const ANALYTICS_EVENTS = {
PIPELINE_TEMPLATE_SELECTED: "pipeline_template_selected", PIPELINE_TEMPLATE_SELECTED: "pipeline_template_selected",
AUTH_LOGIN: "auth_login", AUTH_LOGIN: "auth_login",
AUTH_LOGIN_FAILED: "auth_login_failed", AUTH_LOGIN_FAILED: "auth_login_failed",
// Feedback prompt lifecycle (client-side). Fired when any feedback surface is
// shown to a user and when it is dismissed without submitting, so completion
// and skip rates become measurable instead of counting only submissions.
FEEDBACK_PROMPT_SHOWN: "feedback_prompt_shown",
FEEDBACK_PROMPT_DISMISSED: "feedback_prompt_dismissed",
} as const; } as const;
export type AnalyticsEvent = (typeof ANALYTICS_EVENTS)[keyof typeof ANALYTICS_EVENTS]; export type AnalyticsEvent = (typeof ANALYTICS_EVENTS)[keyof typeof ANALYTICS_EVENTS];
+43
View File
@@ -24,6 +24,14 @@ export const FEEDBACK_SURVEY_ID_VALUES = [
] as const; ] as const;
export type FeedbackSurveyId = (typeof FEEDBACK_SURVEY_ID_VALUES)[number]; export type FeedbackSurveyId = (typeof FEEDBACK_SURVEY_ID_VALUES)[number];
/**
* Settings key marking the instance's first successful processing. The worker
* writes it on the first completed job; the web app gates the onboarding survey
* on it so the survey only reaches instances that have produced a real result,
* not first-landing visitors. Shared so the worker and web can never drift.
*/
export const ONBOARDING_FIRST_PROCESSED_KEY = "onboarding.firstProcessedAt";
export const FEEDBACK_SENTIMENT_VALUES = [ export const FEEDBACK_SENTIMENT_VALUES = [
"great", "great",
"okay", "okay",
@@ -62,6 +70,41 @@ export const FEEDBACK_USAGE_TYPE_VALUES = [
] as const; ] as const;
export type FeedbackUsageType = (typeof FEEDBACK_USAGE_TYPE_VALUES)[number]; export type FeedbackUsageType = (typeof FEEDBACK_USAGE_TYPE_VALUES)[number];
// The onboarding survey asks only what telemetry cannot infer. Modality
// preference (tool_used by category) and install method (instance_started's
// deploy_mode) are already captured by behavior, so the survey spends its
// questions on identity, prior tool, motivation, and acquisition channel.
/** "What were you using before SnapOtter?" — competitor / replacement signal. */
export const FEEDBACK_PRIOR_TOOL_VALUES = [
"online_tools",
"desktop_apps",
"command_line",
"self_hosted",
"nothing",
] as const;
export type FeedbackPriorTool = (typeof FEEDBACK_PRIOR_TOOL_VALUES)[number];
/** "Why self-host it?" — positioning / motivation signal. */
export const FEEDBACK_SELFHOST_MOTIVATION_VALUES = [
"privacy_control",
"upload_limits",
"cost",
"offline",
"trying",
] as const;
export type FeedbackSelfHostMotivation = (typeof FEEDBACK_SELFHOST_MOTIVATION_VALUES)[number];
/** "How did you hear about us?" — acquisition channel, invisible to analytics. */
export const FEEDBACK_DISCOVERY_SOURCE_VALUES = [
"github",
"reddit_hn",
"search",
"word_of_mouth",
"other",
] as const;
export type FeedbackDiscoverySource = (typeof FEEDBACK_DISCOVERY_SOURCE_VALUES)[number];
export const FEEDBACK_IMPORTANT_AREA_VALUES = [ export const FEEDBACK_IMPORTANT_AREA_VALUES = [
"images", "images",
"pdf_docs", "pdf_docs",
+25 -2
View File
@@ -145,11 +145,34 @@ export const ar: TranslationKeys = {
batch_workflows: "سير العمل بالدفعات", batch_workflows: "سير العمل بالدفعات",
ai_tools: "أدوات الذكاء الاصطناعي", ai_tools: "أدوات الذكاء الاصطناعي",
}, },
priorTools: {
online_tools: "أدوات عبر الإنترنت",
desktop_apps: "تطبيقات سطح المكتب (Photoshop، GIMP)",
command_line: "سطر الأوامر (ffmpeg، ImageMagick)",
self_hosted: "أداة أخرى ذاتية الاستضافة",
nothing: "لا شيء، هذه تجربة جديدة",
},
selfHostMotivations: {
privacy_control: "الخصوصية والتحكم في البيانات",
upload_limits: "حدود الرفع والعلامات المائية",
cost: "التكلفة",
offline: "بلا اتصال أو شبكة معزولة",
trying: "مجرد تجربة",
},
discoverySources: {
github: "GitHub",
reddit_hn: "Reddit أو Hacker News",
search: "محرك بحث",
word_of_mouth: "صديق أو زميل",
other: "أخرى",
},
}, },
onboarding: { onboarding: {
usageSurveyTitle: "كيف تستخدم SnapOtter؟", usageSurveyTitle: "كيف تستخدم SnapOtter؟",
usageSurveyToolsLabel: "ما الأهم بالنسبة لك؟", priorToolLabel: "ماذا كنت تستخدم من قبل؟",
pickAnyHint: "(اختر ما تريد)", selfHostMotivationLabel: "لماذا الاستضافة الذاتية؟",
discoverySourceLabel: "كيف سمعت عنا؟",
optionalHint: "(اختياري)",
continueLabel: "متابعة", continueLabel: "متابعة",
}, },
categories: { categories: {
+25 -2
View File
@@ -149,11 +149,34 @@ export const de: TranslationKeys = {
batch_workflows: "Stapel-Workflows", batch_workflows: "Stapel-Workflows",
ai_tools: "AI-Tools", ai_tools: "AI-Tools",
}, },
priorTools: {
online_tools: "Online-Tools",
desktop_apps: "Desktop-Programme (Photoshop, GIMP)",
command_line: "Kommandozeile (ffmpeg, ImageMagick)",
self_hosted: "Ein anderes selbstgehostetes Tool",
nothing: "Nichts, das ist neu für mich",
},
selfHostMotivations: {
privacy_control: "Datenschutz und Datenkontrolle",
upload_limits: "Upload-Limits und Wasserzeichen",
cost: "Kosten",
offline: "Offline oder abgeschottet",
trying: "Ich probiere es nur aus",
},
discoverySources: {
github: "GitHub",
reddit_hn: "Reddit oder Hacker News",
search: "Suchmaschine",
word_of_mouth: "Freund oder Kollege",
other: "Sonstiges",
},
}, },
onboarding: { onboarding: {
usageSurveyTitle: "Wie nutzt du SnapOtter?", usageSurveyTitle: "Wie nutzt du SnapOtter?",
usageSurveyToolsLabel: "Was ist dir am wichtigsten?", priorToolLabel: "Was hast du vorher verwendet?",
pickAnyHint: "(beliebig viele auswählen)", selfHostMotivationLabel: "Warum selbst hosten?",
discoverySourceLabel: "Wie hast du von uns erfahren?",
optionalHint: "(optional)",
continueLabel: "Weiter", continueLabel: "Weiter",
}, },
categories: { categories: {
+25 -2
View File
@@ -143,11 +143,34 @@ export const en = {
batch_workflows: "Batch workflows", batch_workflows: "Batch workflows",
ai_tools: "AI tools", ai_tools: "AI tools",
}, },
priorTools: {
online_tools: "Online tools",
desktop_apps: "Desktop apps (Photoshop, GIMP)",
command_line: "Command line (ffmpeg, ImageMagick)",
self_hosted: "Another self-hosted tool",
nothing: "Nothing, this is new",
},
selfHostMotivations: {
privacy_control: "Privacy and data control",
upload_limits: "Upload limits and watermarks",
cost: "Cost",
offline: "Offline or air-gapped",
trying: "Just trying it out",
},
discoverySources: {
github: "GitHub",
reddit_hn: "Reddit or Hacker News",
search: "Search engine",
word_of_mouth: "Friend or colleague",
other: "Other",
},
}, },
onboarding: { onboarding: {
usageSurveyTitle: "How are you using SnapOtter?", usageSurveyTitle: "How are you using SnapOtter?",
usageSurveyToolsLabel: "What matters most to you?", priorToolLabel: "What were you using before?",
pickAnyHint: "(pick any)", selfHostMotivationLabel: "Why self-host it?",
discoverySourceLabel: "How did you hear about us?",
optionalHint: "(optional)",
continueLabel: "Continue", continueLabel: "Continue",
}, },
categories: { categories: {
+25 -2
View File
@@ -147,11 +147,34 @@ export const es: TranslationKeys = {
batch_workflows: "Flujos por lotes", batch_workflows: "Flujos por lotes",
ai_tools: "Herramientas de IA", ai_tools: "Herramientas de IA",
}, },
priorTools: {
online_tools: "Herramientas en línea",
desktop_apps: "Programas de escritorio (Photoshop, GIMP)",
command_line: "Línea de comandos (ffmpeg, ImageMagick)",
self_hosted: "Otra herramienta autoalojada",
nothing: "Nada, esto es nuevo para mí",
},
selfHostMotivations: {
privacy_control: "Privacidad y control de datos",
upload_limits: "Límites de subida y marcas de agua",
cost: "Coste",
offline: "Sin conexión o aislado",
trying: "Solo lo estoy probando",
},
discoverySources: {
github: "GitHub",
reddit_hn: "Reddit o Hacker News",
search: "Motor de búsqueda",
word_of_mouth: "Amigo o colega",
other: "Otro",
},
}, },
onboarding: { onboarding: {
usageSurveyTitle: "¿Cómo usas SnapOtter?", usageSurveyTitle: "¿Cómo usas SnapOtter?",
usageSurveyToolsLabel: "¿Qué es lo más importante para ti?", priorToolLabel: "¿Qué usabas antes?",
pickAnyHint: "(elige las que quieras)", selfHostMotivationLabel: "¿Por qué autoalojarlo?",
discoverySourceLabel: "¿Cómo nos conociste?",
optionalHint: "(opcional)",
continueLabel: "Continuar", continueLabel: "Continuar",
}, },
categories: { categories: {
+25 -2
View File
@@ -149,11 +149,34 @@ export const fr: TranslationKeys = {
batch_workflows: "Traitements par lots", batch_workflows: "Traitements par lots",
ai_tools: "Outils AI", ai_tools: "Outils AI",
}, },
priorTools: {
online_tools: "Outils en ligne",
desktop_apps: "Applications de bureau (Photoshop, GIMP)",
command_line: "Ligne de commande (ffmpeg, ImageMagick)",
self_hosted: "Un autre outil auto-hébergé",
nothing: "Rien, c'est nouveau pour moi",
},
selfHostMotivations: {
privacy_control: "Confidentialité et contrôle des données",
upload_limits: "Limites de téléversement et filigranes",
cost: "Coût",
offline: "Hors ligne ou isolé du réseau",
trying: "Je fais juste un essai",
},
discoverySources: {
github: "GitHub",
reddit_hn: "Reddit ou Hacker News",
search: "Moteur de recherche",
word_of_mouth: "Un ami ou un collègue",
other: "Autre",
},
}, },
onboarding: { onboarding: {
usageSurveyTitle: "Comment utilisez-vous SnapOtter ?", usageSurveyTitle: "Comment utilisez-vous SnapOtter ?",
usageSurveyToolsLabel: "Qu'est-ce qui compte le plus pour vous ?", priorToolLabel: "Qu'utilisiez-vous auparavant ?",
pickAnyHint: "(plusieurs choix possibles)", selfHostMotivationLabel: "Pourquoi l'auto-héberger ?",
discoverySourceLabel: "Comment avez-vous entendu parler de nous ?",
optionalHint: "(facultatif)",
continueLabel: "Continuer", continueLabel: "Continuer",
}, },
categories: { categories: {
+25 -2
View File
@@ -145,11 +145,34 @@ export const hi: TranslationKeys = {
batch_workflows: "बैच वर्कफ्लो", batch_workflows: "बैच वर्कफ्लो",
ai_tools: "AI टूल्स", ai_tools: "AI टूल्स",
}, },
priorTools: {
online_tools: "ऑनलाइन टूल्स",
desktop_apps: "डेस्कटॉप ऐप्स (Photoshop, GIMP)",
command_line: "कमांड लाइन (ffmpeg, ImageMagick)",
self_hosted: "कोई और सेल्फ-होस्टेड टूल",
nothing: "कुछ नहीं, यह नया है",
},
selfHostMotivations: {
privacy_control: "प्राइवेसी और डेटा नियंत्रण",
upload_limits: "अपलोड सीमाएं और वॉटरमार्क",
cost: "लागत",
offline: "ऑफ़लाइन या एयर-गैप्ड",
trying: "बस आज़मा रहे हैं",
},
discoverySources: {
github: "GitHub",
reddit_hn: "Reddit या Hacker News",
search: "सर्च इंजन",
word_of_mouth: "दोस्त या सहकर्मी",
other: "अन्य",
},
}, },
onboarding: { onboarding: {
usageSurveyTitle: "आप SnapOtter का उपयोग कैसे कर रहे हैं?", usageSurveyTitle: "आप SnapOtter का उपयोग कैसे कर रहे हैं?",
usageSurveyToolsLabel: "आपके लिए सबसे ज़्यादा महत्वपूर्ण क्या है?", priorToolLabel: "इससे पहले आप क्या इस्तेमाल कर रहे थे?",
pickAnyHint: "(कोई भी चुनें)", selfHostMotivationLabel: "इसे सेल्फ-होस्ट क्यों करें?",
discoverySourceLabel: "आपको हमारे बारे में कैसे पता चला?",
optionalHint: "(वैकल्पिक)",
continueLabel: "जारी रखें", continueLabel: "जारी रखें",
}, },
categories: { categories: {
+25 -2
View File
@@ -147,11 +147,34 @@ export const id: TranslationKeys = {
batch_workflows: "Alur kerja batch", batch_workflows: "Alur kerja batch",
ai_tools: "Alat AI", ai_tools: "Alat AI",
}, },
priorTools: {
online_tools: "Alat online",
desktop_apps: "Aplikasi desktop (Photoshop, GIMP)",
command_line: "Baris perintah (ffmpeg, ImageMagick)",
self_hosted: "Alat self-hosted lain",
nothing: "Tidak ada, ini baru",
},
selfHostMotivations: {
privacy_control: "Privasi dan kontrol data",
upload_limits: "Batas unggahan dan watermark",
cost: "Biaya",
offline: "Offline atau air-gapped",
trying: "Sekadar mencoba",
},
discoverySources: {
github: "GitHub",
reddit_hn: "Reddit atau Hacker News",
search: "Mesin pencari",
word_of_mouth: "Teman atau rekan kerja",
other: "Lainnya",
},
}, },
onboarding: { onboarding: {
usageSurveyTitle: "Bagaimana Anda menggunakan SnapOtter?", usageSurveyTitle: "Bagaimana Anda menggunakan SnapOtter?",
usageSurveyToolsLabel: "Apa yang paling penting bagi Anda?", priorToolLabel: "Apa yang Anda gunakan sebelumnya?",
pickAnyHint: "(pilih sebanyak yang Anda mau)", selfHostMotivationLabel: "Mengapa meng-host sendiri?",
discoverySourceLabel: "Dari mana Anda mengetahui kami?",
optionalHint: "(opsional)",
continueLabel: "Lanjutkan", continueLabel: "Lanjutkan",
}, },
categories: { categories: {
+25 -2
View File
@@ -148,11 +148,34 @@ export const it: TranslationKeys = {
batch_workflows: "Flusso di lavoro multiplo", batch_workflows: "Flusso di lavoro multiplo",
ai_tools: "Strumenti IA", ai_tools: "Strumenti IA",
}, },
priorTools: {
online_tools: "Strumenti online",
desktop_apps: "App desktop (Photoshop, GIMP)",
command_line: "Riga di comando (ffmpeg, ImageMagick)",
self_hosted: "Un altro strumento self-hosted",
nothing: "Niente, è una novità per me",
},
selfHostMotivations: {
privacy_control: "Privacy e controllo dei dati",
upload_limits: "Limiti di caricamento e filigrane",
cost: "Costo",
offline: "Offline o isolato dalla rete",
trying: "Lo sto solo provando",
},
discoverySources: {
github: "GitHub",
reddit_hn: "Reddit o Hacker News",
search: "Motore di ricerca",
word_of_mouth: "Un amico o un collega",
other: "Altro",
},
}, },
onboarding: { onboarding: {
usageSurveyTitle: "Come usi SnapOtter?", usageSurveyTitle: "Come usi SnapOtter?",
usageSurveyToolsLabel: "Cosa conta di più per te?", priorToolLabel: "Cosa usavi prima?",
pickAnyHint: "(scegli quante ne vuoi)", selfHostMotivationLabel: "Perché ospitarlo in autonomia?",
discoverySourceLabel: "Come hai saputo di noi?",
optionalHint: "(facoltativo)",
continueLabel: "Continua", continueLabel: "Continua",
}, },
categories: { categories: {
+25 -2
View File
@@ -149,11 +149,34 @@ export const ja: TranslationKeys = {
batch_workflows: "バッチ処理", batch_workflows: "バッチ処理",
ai_tools: "AIツール", ai_tools: "AIツール",
}, },
priorTools: {
online_tools: "オンラインツール",
desktop_apps: "デスクトップアプリ (Photoshop、GIMP)",
command_line: "コマンドライン (ffmpeg、ImageMagick)",
self_hosted: "別のセルフホスト型ツール",
nothing: "特になし、初めて使う",
},
selfHostMotivations: {
privacy_control: "プライバシーとデータの管理",
upload_limits: "アップロード制限やウォーターマーク",
cost: "コスト",
offline: "オフラインや隔離環境",
trying: "とりあえず試している",
},
discoverySources: {
github: "GitHub",
reddit_hn: "RedditやHacker News",
search: "検索エンジン",
word_of_mouth: "友人や同僚",
other: "その他",
},
}, },
onboarding: { onboarding: {
usageSurveyTitle: "SnapOtterをどのように使っていますか?", usageSurveyTitle: "SnapOtterをどのように使っていますか?",
usageSurveyToolsLabel: "あなたにとって最も重要なものは?", priorToolLabel: "以前は何を使っていましたか?",
pickAnyHint: "(いくつでも選択可)", selfHostMotivationLabel: "セルフホストする理由は?",
discoverySourceLabel: "SnapOtterをどこで知りましたか?",
optionalHint: "(任意)",
continueLabel: "続ける", continueLabel: "続ける",
}, },
categories: { categories: {
+25 -2
View File
@@ -147,11 +147,34 @@ export const ko: TranslationKeys = {
batch_workflows: "일괄 워크플로", batch_workflows: "일괄 워크플로",
ai_tools: "AI 도구", ai_tools: "AI 도구",
}, },
priorTools: {
online_tools: "온라인 도구",
desktop_apps: "데스크톱 앱 (Photoshop, GIMP)",
command_line: "명령줄 (ffmpeg, ImageMagick)",
self_hosted: "다른 자체 호스팅 도구",
nothing: "없음, 처음 사용함",
},
selfHostMotivations: {
privacy_control: "개인정보 및 데이터 관리",
upload_limits: "업로드 제한 및 워터마크",
cost: "비용",
offline: "오프라인 또는 폐쇄망",
trying: "그냥 사용해 보는 중",
},
discoverySources: {
github: "GitHub",
reddit_hn: "Reddit 또는 Hacker News",
search: "검색 엔진",
word_of_mouth: "친구 또는 동료",
other: "기타",
},
}, },
onboarding: { onboarding: {
usageSurveyTitle: "SnapOtter를 어떻게 사용하고 계신가요?", usageSurveyTitle: "SnapOtter를 어떻게 사용하고 계신가요?",
usageSurveyToolsLabel: "가장 중요하게 생각하는 것은 무엇인가요?", priorToolLabel: "이전에는 무엇을 사용하셨나요?",
pickAnyHint: "(원하는 만큼 선택)", selfHostMotivationLabel: "직접 호스팅하는 이유는 무엇인가요?",
discoverySourceLabel: "SnapOtter를 어떻게 알게 되셨나요?",
optionalHint: "(선택 사항)",
continueLabel: "계속", continueLabel: "계속",
}, },
categories: { categories: {
+25 -2
View File
@@ -148,11 +148,34 @@ export const nl: TranslationKeys = {
batch_workflows: "Batchworkflows", batch_workflows: "Batchworkflows",
ai_tools: "AI-tools", ai_tools: "AI-tools",
}, },
priorTools: {
online_tools: "Online tools",
desktop_apps: "Desktopapps (Photoshop, GIMP)",
command_line: "Opdrachtregel (ffmpeg, ImageMagick)",
self_hosted: "Een andere zelfgehoste tool",
nothing: "Niets, dit is nieuw voor mij",
},
selfHostMotivations: {
privacy_control: "Privacy en controle over data",
upload_limits: "Uploadlimieten en watermerken",
cost: "Kosten",
offline: "Offline of afgeschermd netwerk",
trying: "Gewoon aan het uitproberen",
},
discoverySources: {
github: "GitHub",
reddit_hn: "Reddit of Hacker News",
search: "Zoekmachine",
word_of_mouth: "Vriend of collega",
other: "Anders",
},
}, },
onboarding: { onboarding: {
usageSurveyTitle: "Hoe gebruik je SnapOtter?", usageSurveyTitle: "Hoe gebruik je SnapOtter?",
usageSurveyToolsLabel: "Wat is voor jou het belangrijkst?", priorToolLabel: "Wat gebruikte je hiervoor?",
pickAnyHint: "(kies er zoveel als je wilt)", selfHostMotivationLabel: "Waarom zelf hosten?",
discoverySourceLabel: "Hoe heb je ons gevonden?",
optionalHint: "(optioneel)",
continueLabel: "Doorgaan", continueLabel: "Doorgaan",
}, },
categories: { categories: {
+25 -2
View File
@@ -146,11 +146,34 @@ export const pl: TranslationKeys = {
batch_workflows: "Przetwarzanie wsadowe", batch_workflows: "Przetwarzanie wsadowe",
ai_tools: "Narzędzia AI", ai_tools: "Narzędzia AI",
}, },
priorTools: {
online_tools: "Narzędzia online",
desktop_apps: "Aplikacje desktopowe (Photoshop, GIMP)",
command_line: "Wiersz poleceń (ffmpeg, ImageMagick)",
self_hosted: "Inne narzędzie hostowane samodzielnie",
nothing: "Nic, to dla mnie nowość",
},
selfHostMotivations: {
privacy_control: "Prywatność i kontrola nad danymi",
upload_limits: "Limity przesyłania i znaki wodne",
cost: "Koszt",
offline: "Offline lub sieć odizolowana",
trying: "Po prostu testuję",
},
discoverySources: {
github: "GitHub",
reddit_hn: "Reddit lub Hacker News",
search: "Wyszukiwarka",
word_of_mouth: "Znajomy lub współpracownik",
other: "Inne",
},
}, },
onboarding: { onboarding: {
usageSurveyTitle: "Jak korzystasz ze SnapOtter?", usageSurveyTitle: "Jak korzystasz ze SnapOtter?",
usageSurveyToolsLabel: "Co jest dla Ciebie najważniejsze?", priorToolLabel: "Czego używałeś wcześniej?",
pickAnyHint: "(wybierz dowolną liczbę)", selfHostMotivationLabel: "Dlaczego hostujesz samodzielnie?",
discoverySourceLabel: "Skąd o nas wiesz?",
optionalHint: "(opcjonalnie)",
continueLabel: "Dalej", continueLabel: "Dalej",
}, },
categories: { categories: {
+25 -2
View File
@@ -148,11 +148,34 @@ export const ptBR: TranslationKeys = {
batch_workflows: "Fluxos em lote", batch_workflows: "Fluxos em lote",
ai_tools: "Ferramentas de AI", ai_tools: "Ferramentas de AI",
}, },
priorTools: {
online_tools: "Ferramentas online",
desktop_apps: "Aplicativos de desktop (Photoshop, GIMP)",
command_line: "Linha de comando (ffmpeg, ImageMagick)",
self_hosted: "Outra ferramenta auto-hospedada",
nothing: "Nada, isso é novo para mim",
},
selfHostMotivations: {
privacy_control: "Privacidade e controle dos dados",
upload_limits: "Limites de upload e marcas d'água",
cost: "Custo",
offline: "Offline ou isolado da rede",
trying: "Só estou experimentando",
},
discoverySources: {
github: "GitHub",
reddit_hn: "Reddit ou Hacker News",
search: "Mecanismo de busca",
word_of_mouth: "Amigo ou colega",
other: "Outro",
},
}, },
onboarding: { onboarding: {
usageSurveyTitle: "Como você usa o SnapOtter?", usageSurveyTitle: "Como você usa o SnapOtter?",
usageSurveyToolsLabel: "O que é mais importante para você?", priorToolLabel: "O que você usava antes?",
pickAnyHint: "(escolha quantas quiser)", selfHostMotivationLabel: "Por que auto-hospedar?",
discoverySourceLabel: "Como você ficou sabendo da gente?",
optionalHint: "(opcional)",
continueLabel: "Continuar", continueLabel: "Continuar",
}, },
categories: { categories: {
+25 -2
View File
@@ -147,11 +147,34 @@ export const ru: TranslationKeys = {
batch_workflows: "Пакетная обработка", batch_workflows: "Пакетная обработка",
ai_tools: "AI-инструменты", ai_tools: "AI-инструменты",
}, },
priorTools: {
online_tools: "Онлайн-инструменты",
desktop_apps: "Настольные приложения (Photoshop, GIMP)",
command_line: "Командная строка (ffmpeg, ImageMagick)",
self_hosted: "Другой self-hosted инструмент",
nothing: "Ничего, это впервые",
},
selfHostMotivations: {
privacy_control: "Приватность и контроль над данными",
upload_limits: "Ограничения на загрузку и водяные знаки",
cost: "Стоимость",
offline: "Офлайн или изолированная сеть",
trying: "Просто пробую",
},
discoverySources: {
github: "GitHub",
reddit_hn: "Reddit или Hacker News",
search: "Поисковая система",
word_of_mouth: "Друг или коллега",
other: "Другое",
},
}, },
onboarding: { onboarding: {
usageSurveyTitle: "Как вы используете SnapOtter?", usageSurveyTitle: "Как вы используете SnapOtter?",
usageSurveyToolsLabel: то для вас важнее всего?", priorToolLabel: ем вы пользовались раньше?",
pickAnyHint: "(выберите любое количество)", selfHostMotivationLabel: "Почему выбрали self-hosting?",
discoverySourceLabel: "Как вы о нас узнали?",
optionalHint: "(необязательно)",
continueLabel: "Продолжить", continueLabel: "Продолжить",
}, },
categories: { categories: {
+25 -2
View File
@@ -148,11 +148,34 @@ export const sv: TranslationKeys = {
batch_workflows: "Batch-arbetsflöden", batch_workflows: "Batch-arbetsflöden",
ai_tools: "AI-verktyg", ai_tools: "AI-verktyg",
}, },
priorTools: {
online_tools: "Onlineverktyg",
desktop_apps: "Skrivbordsappar (Photoshop, GIMP)",
command_line: "Kommandorad (ffmpeg, ImageMagick)",
self_hosted: "Ett annat självhostat verktyg",
nothing: "Inget, det här är nytt för mig",
},
selfHostMotivations: {
privacy_control: "Integritet och kontroll över data",
upload_limits: "Uppladdningsgränser och vattenstämplar",
cost: "Kostnad",
offline: "Offline eller isolerat nätverk",
trying: "Testar bara",
},
discoverySources: {
github: "GitHub",
reddit_hn: "Reddit eller Hacker News",
search: "Sökmotor",
word_of_mouth: "Vän eller kollega",
other: "Annat",
},
}, },
onboarding: { onboarding: {
usageSurveyTitle: "Hur använder du SnapOtter?", usageSurveyTitle: "Hur använder du SnapOtter?",
usageSurveyToolsLabel: "Vad betyder mest för dig?", priorToolLabel: "Vad använde du tidigare?",
pickAnyHint: "(välj hur många du vill)", selfHostMotivationLabel: "Varför självhosta?",
discoverySourceLabel: "Hur hörde du talas om oss?",
optionalHint: "(valfritt)",
continueLabel: "Fortsätt", continueLabel: "Fortsätt",
}, },
categories: { categories: {
+25 -2
View File
@@ -145,11 +145,34 @@ export const th: TranslationKeys = {
batch_workflows: "เวิร์กโฟลว์แบบชุด", batch_workflows: "เวิร์กโฟลว์แบบชุด",
ai_tools: "เครื่องมือ AI", ai_tools: "เครื่องมือ AI",
}, },
priorTools: {
online_tools: "เครื่องมือออนไลน์",
desktop_apps: "แอปเดสก์ท็อป (Photoshop, GIMP)",
command_line: "คอมมานด์ไลน์ (ffmpeg, ImageMagick)",
self_hosted: "เครื่องมือโฮสต์เองอื่น",
nothing: "ไม่มีเลย นี่เป็นครั้งแรก",
},
selfHostMotivations: {
privacy_control: "ความเป็นส่วนตัวและการควบคุมข้อมูล",
upload_limits: "ข้อจำกัดการอัปโหลดและลายน้ำ",
cost: "ค่าใช้จ่าย",
offline: "ออฟไลน์หรือแยกจากเครือข่าย",
trying: "แค่ลองใช้ดู",
},
discoverySources: {
github: "GitHub",
reddit_hn: "Reddit หรือ Hacker News",
search: "เครื่องมือค้นหา",
word_of_mouth: "เพื่อนหรือเพื่อนร่วมงาน",
other: "อื่นๆ",
},
}, },
onboarding: { onboarding: {
usageSurveyTitle: "คุณใช้ SnapOtter อย่างไร?", usageSurveyTitle: "คุณใช้ SnapOtter อย่างไร?",
usageSurveyToolsLabel: "อะไรสำคัญที่สุดสำหรับคุณ?", priorToolLabel: "ก่อนหน้านี้คุณใช้อะไรอยู่?",
pickAnyHint: "(เลือกได้หลายข้อ)", selfHostMotivationLabel: "ทำไมถึงโฮสต์เอง?",
discoverySourceLabel: "คุณรู้จักเราได้อย่างไร?",
optionalHint: "(ไม่บังคับ)",
continueLabel: "ดำเนินการต่อ", continueLabel: "ดำเนินการต่อ",
}, },
categories: { categories: {
+25 -2
View File
@@ -147,11 +147,34 @@ export const tr: TranslationKeys = {
batch_workflows: "Toplu iş akışları", batch_workflows: "Toplu iş akışları",
ai_tools: "AI araçları", ai_tools: "AI araçları",
}, },
priorTools: {
online_tools: "Çevrimiçi araçlar",
desktop_apps: "Masaüstü uygulamaları (Photoshop, GIMP)",
command_line: "Komut satırı (ffmpeg, ImageMagick)",
self_hosted: "Başka bir kendi sunucunuzda barındırılan araç",
nothing: "Hiçbiri, bu benim için yeni",
},
selfHostMotivations: {
privacy_control: "Gizlilik ve veri denetimi",
upload_limits: "Yükleme sınırları ve filigranlar",
cost: "Maliyet",
offline: "Çevrimdışı veya izole ağ",
trying: "Sadece deniyorum",
},
discoverySources: {
github: "GitHub",
reddit_hn: "Reddit veya Hacker News",
search: "Arama motoru",
word_of_mouth: "Arkadaş veya iş arkadaşı",
other: "Diğer",
},
}, },
onboarding: { onboarding: {
usageSurveyTitle: "SnapOtter'ı nasıl kullanıyorsunuz?", usageSurveyTitle: "SnapOtter'ı nasıl kullanıyorsunuz?",
usageSurveyToolsLabel: "Sizin için en önemlisi ne?", priorToolLabel: "Daha önce ne kullanıyordunuz?",
pickAnyHint: "(istediğiniz kadar seçin)", selfHostMotivationLabel: "Neden kendi sunucunuzda barındırıyorsunuz?",
discoverySourceLabel: "Bizi nereden duydunuz?",
optionalHint: "(isteğe bağlı)",
continueLabel: "Devam et", continueLabel: "Devam et",
}, },
categories: { categories: {
+25 -2
View File
@@ -147,11 +147,34 @@ export const uk: TranslationKeys = {
batch_workflows: "Пакетні робочі процеси", batch_workflows: "Пакетні робочі процеси",
ai_tools: "Інструменти AI", ai_tools: "Інструменти AI",
}, },
priorTools: {
online_tools: "Онлайн-інструменти",
desktop_apps: "Настільні застосунки (Photoshop, GIMP)",
command_line: "Командний рядок (ffmpeg, ImageMagick)",
self_hosted: "Інший self-hosted інструмент",
nothing: "Нічого, це вперше",
},
selfHostMotivations: {
privacy_control: "Приватність і контроль над даними",
upload_limits: "Обмеження на завантаження та водяні знаки",
cost: "Вартість",
offline: "Офлайн або ізольована мережа",
trying: "Просто пробую",
},
discoverySources: {
github: "GitHub",
reddit_hn: "Reddit або Hacker News",
search: "Пошукова система",
word_of_mouth: "Друг або колега",
other: "Інше",
},
}, },
onboarding: { onboarding: {
usageSurveyTitle: "Як ви використовуєте SnapOtter?", usageSurveyTitle: "Як ви використовуєте SnapOtter?",
usageSurveyToolsLabel: "Що для вас найважливіше?", priorToolLabel: "Чим ви користувалися раніше?",
pickAnyHint: "(виберіть будь-яку кількість)", selfHostMotivationLabel: "Чому обрали self-hosting?",
discoverySourceLabel: "Як ви про нас дізналися?",
optionalHint: "(необовʼязково)",
continueLabel: "Продовжити", continueLabel: "Продовжити",
}, },
categories: { categories: {
+25 -2
View File
@@ -149,11 +149,34 @@ export const vi: TranslationKeys = {
batch_workflows: "Quy trình hàng loạt", batch_workflows: "Quy trình hàng loạt",
ai_tools: "Công cụ AI", ai_tools: "Công cụ AI",
}, },
priorTools: {
online_tools: "Công cụ trực tuyến",
desktop_apps: "Ứng dụng máy tính (Photoshop, GIMP)",
command_line: "Dòng lệnh (ffmpeg, ImageMagick)",
self_hosted: "Công cụ tự lưu trữ khác",
nothing: "Không có gì, đây là lần đầu",
},
selfHostMotivations: {
privacy_control: "Quyền riêng tư và kiểm soát dữ liệu",
upload_limits: "Giới hạn tải lên và hình mờ",
cost: "Chi phí",
offline: "Ngoại tuyến hoặc cách ly mạng",
trying: "Chỉ dùng thử",
},
discoverySources: {
github: "GitHub",
reddit_hn: "Reddit hoặc Hacker News",
search: "Công cụ tìm kiếm",
word_of_mouth: "Bạn bè hoặc đồng nghiệp",
other: "Khác",
},
}, },
onboarding: { onboarding: {
usageSurveyTitle: "Bạn đang sử dụng SnapOtter như thế nào?", 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?", priorToolLabel: "Trước đây bạn đã dùng gì?",
pickAnyHint: "(chọn bao nhiêu tùy thích)", selfHostMotivationLabel: "Tại sao tự lưu trữ?",
discoverySourceLabel: "Bạn biết đến chúng tôi bằng cách nào?",
optionalHint: "(tùy chọn)",
continueLabel: "Tiếp tục", continueLabel: "Tiếp tục",
}, },
categories: { categories: {
+25 -2
View File
@@ -143,11 +143,34 @@ export const zhCN: TranslationKeys = {
batch_workflows: "批量工作流", batch_workflows: "批量工作流",
ai_tools: "AI 工具", ai_tools: "AI 工具",
}, },
priorTools: {
online_tools: "在线工具",
desktop_apps: "桌面应用 (Photoshop、GIMP)",
command_line: "命令行 (ffmpeg、ImageMagick)",
self_hosted: "其他自托管工具",
nothing: "没有,这是第一次用",
},
selfHostMotivations: {
privacy_control: "隐私和数据控制",
upload_limits: "上传限制和水印",
cost: "成本",
offline: "离线或隔离网络",
trying: "只是试用一下",
},
discoverySources: {
github: "GitHub",
reddit_hn: "Reddit 或 Hacker News",
search: "搜索引擎",
word_of_mouth: "朋友或同事",
other: "其他",
},
}, },
onboarding: { onboarding: {
usageSurveyTitle: "您如何使用 SnapOtter?", usageSurveyTitle: "您如何使用 SnapOtter?",
usageSurveyToolsLabel: "您最看重什么?", priorToolLabel: "您之前用的是什么?",
pickAnyHint: "(可多选)", selfHostMotivationLabel: "为什么选择自托管?",
discoverySourceLabel: "您是如何了解到我们的?",
optionalHint: "(可选)",
continueLabel: "继续", continueLabel: "继续",
}, },
categories: { categories: {
+25 -2
View File
@@ -144,11 +144,34 @@ export const zhTW: TranslationKeys = {
batch_workflows: "批次工作流程", batch_workflows: "批次工作流程",
ai_tools: "AI 工具", ai_tools: "AI 工具",
}, },
priorTools: {
online_tools: "線上工具",
desktop_apps: "桌面應用程式 (Photoshop、GIMP)",
command_line: "命令列 (ffmpeg、ImageMagick)",
self_hosted: "其他自架工具",
nothing: "沒有,這是第一次用",
},
selfHostMotivations: {
privacy_control: "隱私與資料掌控",
upload_limits: "上傳限制與浮水印",
cost: "成本",
offline: "離線或隔離網路",
trying: "只是試用看看",
},
discoverySources: {
github: "GitHub",
reddit_hn: "Reddit 或 Hacker News",
search: "搜尋引擎",
word_of_mouth: "朋友或同事",
other: "其他",
},
}, },
onboarding: { onboarding: {
usageSurveyTitle: "您如何使用 SnapOtter?", usageSurveyTitle: "您如何使用 SnapOtter?",
usageSurveyToolsLabel: "您最重視什麼?", priorToolLabel: "您之前用的是什麼?",
pickAnyHint: "(可複選)", selfHostMotivationLabel: "為什麼選擇自架?",
discoverySourceLabel: "您是如何得知我們的?",
optionalHint: "(選填)",
continueLabel: "繼續", continueLabel: "繼續",
}, },
categories: { categories: {
+28 -11
View File
@@ -134,7 +134,7 @@ describe("POST /api/v1/feedback", () => {
); );
}); });
it("accepts an onboarding usage-survey submission", async () => { it("accepts an onboarding usage-survey submission with the telemetry-blind answers", async () => {
process.env.ANALYTICS_BAKED_OVERRIDE = "on"; process.env.ANALYTICS_BAKED_OVERRIDE = "on";
await refreshAnalyticsGate(); await refreshAnalyticsGate();
const token = await loginAsAdmin(testApp.app); const token = await loginAsAdmin(testApp.app);
@@ -148,7 +148,9 @@ describe("POST /api/v1/feedback", () => {
surveyId: "onboarding-usage-v1", surveyId: "onboarding-usage-v1",
promptVariant: "onboarding-overlay-v1", promptVariant: "onboarding-overlay-v1",
usageType: "team_internal", usageType: "team_internal",
importantAreas: ["images", "pdf_docs"], priorTool: "command_line",
selfHostMotivation: "privacy_control",
discoverySource: "github",
}, },
}); });
@@ -160,13 +162,15 @@ describe("POST /api/v1/feedback", () => {
survey_id: "onboarding-usage-v1", survey_id: "onboarding-usage-v1",
prompt_variant: "onboarding-overlay-v1", prompt_variant: "onboarding-overlay-v1",
usage_type: "team_internal", usage_type: "team_internal",
important_areas: ["images", "pdf_docs"], prior_tool: "command_line",
selfhost_motivation: "privacy_control",
discovery_source: "github",
}), }),
undefined, undefined,
); );
}); });
it("accepts a persona-only onboarding submission with no important areas selected", async () => { it("accepts a persona-only onboarding submission with only the usage type", async () => {
process.env.ANALYTICS_BAKED_OVERRIDE = "on"; process.env.ANALYTICS_BAKED_OVERRIDE = "on";
await refreshAnalyticsGate(); await refreshAnalyticsGate();
const token = await loginAsAdmin(testApp.app); const token = await loginAsAdmin(testApp.app);
@@ -180,7 +184,6 @@ describe("POST /api/v1/feedback", () => {
surveyId: "onboarding-usage-v1", surveyId: "onboarding-usage-v1",
promptVariant: "onboarding-overlay-v1", promptVariant: "onboarding-overlay-v1",
usageType: "personal", usageType: "personal",
importantAreas: [],
}, },
}); });
@@ -195,12 +198,26 @@ describe("POST /api/v1/feedback", () => {
}), }),
undefined, undefined,
); );
// captureFeedback is mocked, so it receives the route's raw properties: the });
// empty importantAreas array is forwarded as-is. Dropping an empty
// important_areas before it reaches PostHog happens inside the real, it("rejects invalid onboarding survey answers", async () => {
// unmocked cleanFeedbackProperties (analytics.ts), which this test bypasses. const token = await loginAsAdmin(testApp.app);
const lastCall = captureFeedback.mock.calls.at(-1); const res = await testApp.app.inject({
expect(lastCall?.[0].important_areas).toEqual([]); method: "POST",
url: "/api/v1/feedback",
headers: { authorization: `Bearer ${token}` },
payload: {
source: "onboarding",
surveyId: "onboarding-usage-v1",
usageType: "personal",
priorTool: "carrier_pigeon",
},
});
expect(res.statusCode).toBe(400);
expect(JSON.parse(res.body).details).toContainEqual(
expect.objectContaining({ path: "priorTool" }),
);
}); });
it("drops identifying contact fields when contact consent is not checked", async () => { it("drops identifying contact fields when contact consent is not checked", async () => {
+9
View File
@@ -306,6 +306,9 @@ describe("captureFeedback", () => {
survey_id: "onboarding-usage-v1", survey_id: "onboarding-usage-v1",
contact_ok: false, contact_ok: false,
usage_type: "personal", usage_type: "personal",
prior_tool: "command_line",
selfhost_motivation: "privacy_control",
discovery_source: "github",
}, },
"distinct-onboarding", "distinct-onboarding",
); );
@@ -314,6 +317,12 @@ describe("captureFeedback", () => {
expect.objectContaining({ expect.objectContaining({
distinctId: "distinct-onboarding", distinctId: "distinct-onboarding",
event: "onboarding_survey_submitted", event: "onboarding_survey_submitted",
properties: expect.objectContaining({
usage_type: "personal",
prior_tool: "command_line",
selfhost_motivation: "privacy_control",
discovery_source: "github",
}),
}), }),
); );
}); });
+26
View File
@@ -5,6 +5,7 @@ import { db, schema } from "../../../apps/api/src/db/index.js";
import { import {
getSettingNumber, getSettingNumber,
getSettingString, getSettingString,
setSettingIfAbsent,
upsertSetting, upsertSetting,
} from "../../../apps/api/src/lib/settings-helpers.js"; } from "../../../apps/api/src/lib/settings-helpers.js";
@@ -117,3 +118,28 @@ describe("getSettingString", () => {
expect(result).toBe(""); expect(result).toBe("");
}); });
}); });
describe("setSettingIfAbsent", () => {
it("writes the value when the key is absent", async () => {
const key = uniqueKey();
keysToClean.push(key);
await setSettingIfAbsent(key, "first");
const rows = await db.select().from(schema.settings).where(eq(schema.settings.key, key));
expect(rows).toHaveLength(1);
expect(rows[0].value).toBe("first");
});
it("keeps the first value and ignores later writes (first-write-wins)", async () => {
const key = uniqueKey();
keysToClean.push(key);
await setSettingIfAbsent(key, "first");
await setSettingIfAbsent(key, "second");
const rows = await db.select().from(schema.settings).where(eq(schema.settings.key, key));
expect(rows).toHaveLength(1);
expect(rows[0].value).toBe("first");
});
});
+9 -2
View File
@@ -2,8 +2,8 @@ import { ANALYTICS_EVENTS } from "@snapotter/shared";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
describe("ANALYTICS_EVENTS", () => { describe("ANALYTICS_EVENTS", () => {
it("has exactly 25 event keys", () => { it("has exactly 27 event keys", () => {
expect(Object.keys(ANALYTICS_EVENTS)).toHaveLength(25); expect(Object.keys(ANALYTICS_EVENTS)).toHaveLength(27);
}); });
it("contains the expected keys", () => { it("contains the expected keys", () => {
@@ -32,6 +32,8 @@ describe("ANALYTICS_EVENTS", () => {
expect(ANALYTICS_EVENTS).toHaveProperty("PIPELINE_TEMPLATE_SELECTED"); expect(ANALYTICS_EVENTS).toHaveProperty("PIPELINE_TEMPLATE_SELECTED");
expect(ANALYTICS_EVENTS).toHaveProperty("AUTH_LOGIN"); expect(ANALYTICS_EVENTS).toHaveProperty("AUTH_LOGIN");
expect(ANALYTICS_EVENTS).toHaveProperty("AUTH_LOGIN_FAILED"); expect(ANALYTICS_EVENTS).toHaveProperty("AUTH_LOGIN_FAILED");
expect(ANALYTICS_EVENTS).toHaveProperty("FEEDBACK_PROMPT_SHOWN");
expect(ANALYTICS_EVENTS).toHaveProperty("FEEDBACK_PROMPT_DISMISSED");
}); });
it("all event values are strings", () => { it("all event values are strings", () => {
@@ -68,6 +70,11 @@ describe("ANALYTICS_EVENTS", () => {
expect(ANALYTICS_EVENTS.INSTANCE_STARTED).toBe("instance_started"); expect(ANALYTICS_EVENTS.INSTANCE_STARTED).toBe("instance_started");
}); });
it("feedback prompt lifecycle events have the correct snake_case values", () => {
expect(ANALYTICS_EVENTS.FEEDBACK_PROMPT_SHOWN).toBe("feedback_prompt_shown");
expect(ANALYTICS_EVENTS.FEEDBACK_PROMPT_DISMISSED).toBe("feedback_prompt_dismissed");
});
it("all values follow snake_case convention", () => { it("all values follow snake_case convention", () => {
for (const value of Object.values(ANALYTICS_EVENTS)) { for (const value of Object.values(ANALYTICS_EVENTS)) {
expect(value).toMatch(/^[a-z][a-z0-9_]*$/); expect(value).toMatch(/^[a-z][a-z0-9_]*$/);
+23 -8
View File
@@ -97,10 +97,14 @@ describe("shouldShowInstallFeedbackCard", () => {
}); });
describe("shouldShowUsageSurvey", () => { describe("shouldShowUsageSurvey", () => {
it("shows only for admins after analytics config is loaded and enabled", () => { // The survey now waits for the instance's first successful processing so we
// ask engaged users, not someone staring at an empty app on first landing.
const PROCESSED = { "onboarding.firstProcessedAt": "2026-01-14T00:00:00Z" };
it("shows only for admins after analytics is loaded and enabled, once a processing has completed", () => {
expect( expect(
shouldShowUsageSurvey({ shouldShowUsageSurvey({
settings: {}, settings: PROCESSED,
role: "admin", role: "admin",
analyticsConfigLoaded: true, analyticsConfigLoaded: true,
analyticsEnabled: true, analyticsEnabled: true,
@@ -109,7 +113,7 @@ describe("shouldShowUsageSurvey", () => {
expect( expect(
shouldShowUsageSurvey({ shouldShowUsageSurvey({
settings: {}, settings: PROCESSED,
role: "user", role: "user",
analyticsConfigLoaded: true, analyticsConfigLoaded: true,
analyticsEnabled: true, analyticsEnabled: true,
@@ -118,7 +122,7 @@ describe("shouldShowUsageSurvey", () => {
expect( expect(
shouldShowUsageSurvey({ shouldShowUsageSurvey({
settings: {}, settings: PROCESSED,
role: "admin", role: "admin",
analyticsConfigLoaded: false, analyticsConfigLoaded: false,
analyticsEnabled: true, analyticsEnabled: true,
@@ -127,7 +131,7 @@ describe("shouldShowUsageSurvey", () => {
expect( expect(
shouldShowUsageSurvey({ shouldShowUsageSurvey({
settings: {}, settings: PROCESSED,
role: "admin", role: "admin",
analyticsConfigLoaded: true, analyticsConfigLoaded: true,
analyticsEnabled: false, analyticsEnabled: false,
@@ -135,10 +139,21 @@ describe("shouldShowUsageSurvey", () => {
).toBe(false); ).toBe(false);
}); });
it("stays hidden after answering or permanently dismissing", () => { it("stays hidden until the instance's first successful processing", () => {
expect( expect(
shouldShowUsageSurvey({ shouldShowUsageSurvey({
settings: { "onboarding.usageSurvey.answeredAt": "2026-01-14T00:00:00Z" }, settings: {},
role: "admin",
analyticsConfigLoaded: true,
analyticsEnabled: true,
}),
).toBe(false);
});
it("stays hidden after answering or permanently dismissing, even once processing has happened", () => {
expect(
shouldShowUsageSurvey({
settings: { ...PROCESSED, "onboarding.usageSurvey.answeredAt": "2026-01-14T00:00:00Z" },
role: "admin", role: "admin",
analyticsConfigLoaded: true, analyticsConfigLoaded: true,
analyticsEnabled: true, analyticsEnabled: true,
@@ -147,7 +162,7 @@ describe("shouldShowUsageSurvey", () => {
expect( expect(
shouldShowUsageSurvey({ shouldShowUsageSurvey({
settings: { "onboarding.usageSurvey.dismissedAt": "2026-01-14T00:00:00Z" }, settings: { ...PROCESSED, "onboarding.usageSurvey.dismissedAt": "2026-01-14T00:00:00Z" },
role: "admin", role: "admin",
analyticsConfigLoaded: true, analyticsConfigLoaded: true,
analyticsEnabled: true, analyticsEnabled: true,
+61 -64
View File
@@ -6,13 +6,15 @@ import { MemoryRouter } from "react-router-dom";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
const submitFeedback = vi.hoisted(() => vi.fn().mockResolvedValue({ ok: true, accepted: true })); const submitFeedback = vi.hoisted(() => vi.fn().mockResolvedValue({ ok: true, accepted: true }));
const trackFeedbackPromptShown = vi.hoisted(() => vi.fn());
const trackFeedbackPromptDismissed = vi.hoisted(() => vi.fn());
const apiGet = vi.hoisted(() => vi.fn()); const apiGet = vi.hoisted(() => vi.fn());
const apiPut = vi.hoisted(() => vi.fn().mockResolvedValue({})); const apiPut = vi.hoisted(() => vi.fn().mockResolvedValue({}));
const useAuth = vi.hoisted(() => vi.fn()); const useAuth = vi.hoisted(() => vi.fn());
vi.mock("@/lib/feedback", async (importOriginal) => { vi.mock("@/lib/feedback", async (importOriginal) => {
const actual: Record<string, unknown> = await importOriginal(); const actual: Record<string, unknown> = await importOriginal();
return { ...actual, submitFeedback }; return { ...actual, submitFeedback, trackFeedbackPromptShown, trackFeedbackPromptDismissed };
}); });
vi.mock("@/lib/api", async (importOriginal) => { vi.mock("@/lib/api", async (importOriginal) => {
@@ -30,9 +32,15 @@ vi.mock("@/stores/analytics-store", () => ({
import { UsageSurveyOverlay } from "@/components/onboarding/usage-survey-overlay"; import { UsageSurveyOverlay } from "@/components/onboarding/usage-survey-overlay";
// The worker writes this marker on the instance's first successful processing.
// The survey is only eligible once it exists; without it the overlay stays hidden.
const PROCESSED = { "onboarding.firstProcessedAt": "2026-01-01T00:00:00Z" };
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
submitFeedback.mockClear(); submitFeedback.mockClear();
trackFeedbackPromptShown.mockClear();
trackFeedbackPromptDismissed.mockClear();
apiGet.mockClear(); apiGet.mockClear();
apiPut.mockClear(); apiPut.mockClear();
useAuth.mockReset(); useAuth.mockReset();
@@ -56,10 +64,21 @@ describe("UsageSurveyOverlay", () => {
expect(screen.queryByText("How are you using SnapOtter?")).toBeNull(); expect(screen.queryByText("How are you using SnapOtter?")).toBeNull();
}); });
it("stays hidden until the instance's first processing has completed", async () => {
useAuth.mockReturnValue({ role: "admin", mustChangePassword: false });
apiGet.mockResolvedValue({ settings: {} });
renderOverlay();
await waitFor(() => expect(apiGet).toHaveBeenCalled());
expect(screen.queryByText("How are you using SnapOtter?")).toBeNull();
expect(trackFeedbackPromptShown).not.toHaveBeenCalled();
});
it("renders nothing once already answered or dismissed", async () => { it("renders nothing once already answered or dismissed", async () => {
useAuth.mockReturnValue({ role: "admin", mustChangePassword: false }); useAuth.mockReturnValue({ role: "admin", mustChangePassword: false });
apiGet.mockResolvedValue({ apiGet.mockResolvedValue({
settings: { "onboarding.usageSurvey.dismissedAt": "2026-01-01T00:00:00Z" }, settings: { ...PROCESSED, "onboarding.usageSurvey.dismissedAt": "2026-01-01T00:00:00Z" },
}); });
renderOverlay(); renderOverlay();
@@ -68,28 +87,28 @@ describe("UsageSurveyOverlay", () => {
expect(screen.queryByText("How are you using SnapOtter?")).toBeNull(); expect(screen.queryByText("How are you using SnapOtter?")).toBeNull();
}); });
it("shows both questions for an admin instance that hasn't answered", async () => { it("shows the telemetry-blind questions after processing and emits a shown event", async () => {
useAuth.mockReturnValue({ role: "admin", mustChangePassword: false }); useAuth.mockReturnValue({ role: "admin", mustChangePassword: false });
apiGet.mockResolvedValue({ settings: {} }); apiGet.mockResolvedValue({ settings: PROCESSED });
renderOverlay(); renderOverlay();
expect(await screen.findByText("How are you using SnapOtter?")).toBeDefined(); expect(await screen.findByText("How are you using SnapOtter?")).toBeDefined();
expect(screen.getByText("What matters most to you?")).toBeDefined(); expect(screen.getByText("What were you using before?")).toBeDefined();
expect(screen.getByText("Why self-host it?")).toBeDefined();
expect(screen.getByRole("radio", { name: /Just me/ })).toBeDefined(); expect(screen.getByRole("radio", { name: /Just me/ })).toBeDefined();
expect(screen.getByRole("button", { name: "Continue" })).toBeDisabled(); expect(screen.getByRole("button", { name: "Continue" })).toBeDisabled();
await waitFor(() => expect(trackFeedbackPromptShown).toHaveBeenCalledWith("onboarding"));
}); });
it("submits the selected answers and records the settings key", async () => { it("submits only the usage type when nothing else is picked", async () => {
useAuth.mockReturnValue({ role: "admin", mustChangePassword: false }); useAuth.mockReturnValue({ role: "admin", mustChangePassword: false });
apiGet.mockResolvedValue({ settings: {} }); apiGet.mockResolvedValue({ settings: PROCESSED });
renderOverlay(); renderOverlay();
await screen.findByText("How are you using SnapOtter?"); await screen.findByText("How are you using SnapOtter?");
fireEvent.click(screen.getByRole("radio", { name: /Small team/ })); fireEvent.click(screen.getByRole("radio", { 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" })); fireEvent.click(screen.getByRole("button", { name: "Continue" }));
await waitFor(() => { await waitFor(() => {
@@ -98,7 +117,6 @@ describe("UsageSurveyOverlay", () => {
surveyId: "onboarding-usage-v1", surveyId: "onboarding-usage-v1",
promptVariant: "onboarding-overlay-v1", promptVariant: "onboarding-overlay-v1",
usageType: "team_internal", usageType: "team_internal",
importantAreas: ["images", "pdf_docs"],
}); });
}); });
expect(apiPut).toHaveBeenCalledWith("/v1/settings", { expect(apiPut).toHaveBeenCalledWith("/v1/settings", {
@@ -106,17 +124,18 @@ describe("UsageSurveyOverlay", () => {
}); });
}); });
it("includes install method and friction area when the admin selects them", async () => { it("includes prior tool, motivation, and discovery source when the admin selects them", async () => {
useAuth.mockReturnValue({ role: "admin", mustChangePassword: false }); useAuth.mockReturnValue({ role: "admin", mustChangePassword: false });
apiGet.mockResolvedValue({ settings: {} }); apiGet.mockResolvedValue({ settings: PROCESSED });
renderOverlay(); renderOverlay();
await screen.findByText("How are you using SnapOtter?"); await screen.findByText("How are you using SnapOtter?");
fireEvent.click(screen.getByRole("radio", { name: /Just me/ })); fireEvent.click(screen.getByRole("radio", { name: /Just me/ }));
fireEvent.click(screen.getByRole("radio", { name: "Built from source" })); fireEvent.click(screen.getByRole("radio", { name: /Command line/ }));
fireEvent.change(screen.getByLabelText("Hardest setup area"), { fireEvent.click(screen.getByRole("radio", { name: /Privacy and data control/ }));
target: { value: "docker" }, fireEvent.change(screen.getByLabelText(/How did you hear about us/), {
target: { value: "github" },
}); });
fireEvent.click(screen.getByRole("button", { name: "Continue" })); fireEvent.click(screen.getByRole("button", { name: "Continue" }));
@@ -126,16 +145,34 @@ describe("UsageSurveyOverlay", () => {
surveyId: "onboarding-usage-v1", surveyId: "onboarding-usage-v1",
promptVariant: "onboarding-overlay-v1", promptVariant: "onboarding-overlay-v1",
usageType: "personal", usageType: "personal",
importantAreas: [], priorTool: "command_line",
installMethod: "source", selfHostMotivation: "privacy_control",
frictionArea: "docker", discoverySource: "github",
}); });
}); });
}); });
it("dismissing writes the dismiss key, emits a dismissed event, and does not submit", async () => {
useAuth.mockReturnValue({ role: "admin", mustChangePassword: false });
apiGet.mockResolvedValue({ settings: PROCESSED });
renderOverlay();
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(trackFeedbackPromptDismissed).toHaveBeenCalledWith("onboarding", "dont_ask_again");
expect(submitFeedback).not.toHaveBeenCalled();
});
it("does not resubmit feedback if only the settings write failed on the first attempt", async () => { it("does not resubmit feedback if only the settings write failed on the first attempt", async () => {
useAuth.mockReturnValue({ role: "admin", mustChangePassword: false }); useAuth.mockReturnValue({ role: "admin", mustChangePassword: false });
apiGet.mockResolvedValue({ settings: {} }); apiGet.mockResolvedValue({ settings: PROCESSED });
apiPut.mockRejectedValueOnce(new Error("network error")).mockResolvedValueOnce({}); apiPut.mockRejectedValueOnce(new Error("network error")).mockResolvedValueOnce({});
renderOverlay(); renderOverlay();
@@ -153,32 +190,9 @@ describe("UsageSurveyOverlay", () => {
expect(submitFeedback).toHaveBeenCalledTimes(1); expect(submitFeedback).toHaveBeenCalledTimes(1);
}); });
it("resubmits feedback if the answer changes after a failed settings write", async () => {
useAuth.mockReturnValue({ role: "admin", mustChangePassword: false });
apiGet.mockResolvedValue({ settings: {} });
apiPut.mockRejectedValueOnce(new Error("network error")).mockResolvedValueOnce({});
renderOverlay();
await screen.findByText("How are you using SnapOtter?");
fireEvent.click(screen.getByRole("radio", { name: /Just me/ }));
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
await waitFor(() => expect(apiPut).toHaveBeenCalledTimes(1));
fireEvent.click(screen.getByRole("radio", { name: /Small team/ }));
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
await waitFor(() => expect(apiPut).toHaveBeenCalledTimes(2));
expect(submitFeedback).toHaveBeenCalledTimes(2);
expect(submitFeedback).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ usageType: "team_internal" }),
);
});
it("stays visible and re-enables Continue if the feedback submission itself fails", async () => { it("stays visible and re-enables Continue if the feedback submission itself fails", async () => {
useAuth.mockReturnValue({ role: "admin", mustChangePassword: false }); useAuth.mockReturnValue({ role: "admin", mustChangePassword: false });
apiGet.mockResolvedValue({ settings: {} }); apiGet.mockResolvedValue({ settings: PROCESSED });
submitFeedback.mockRejectedValueOnce(new Error("network error")); submitFeedback.mockRejectedValueOnce(new Error("network error"));
renderOverlay(); renderOverlay();
@@ -196,26 +210,9 @@ describe("UsageSurveyOverlay", () => {
expect(apiPut).not.toHaveBeenCalled(); expect(apiPut).not.toHaveBeenCalled();
}); });
it("dismissing writes the dismiss key without submitting feedback", async () => {
useAuth.mockReturnValue({ role: "admin", mustChangePassword: false });
apiGet.mockResolvedValue({ settings: {} });
renderOverlay();
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();
});
it("ignores a second dismiss click while the first write is in flight", async () => { it("ignores a second dismiss click while the first write is in flight", async () => {
useAuth.mockReturnValue({ role: "admin", mustChangePassword: false }); useAuth.mockReturnValue({ role: "admin", mustChangePassword: false });
apiGet.mockResolvedValue({ settings: {} }); apiGet.mockResolvedValue({ settings: PROCESSED });
let resolveApiPut: (() => void) | undefined; let resolveApiPut: (() => void) | undefined;
apiPut.mockImplementationOnce( apiPut.mockImplementationOnce(
() => () =>
@@ -244,7 +241,7 @@ describe("UsageSurveyOverlay", () => {
it("renders nothing when the admin must still change their password", async () => { it("renders nothing when the admin must still change their password", async () => {
useAuth.mockReturnValue({ role: "admin", mustChangePassword: true }); useAuth.mockReturnValue({ role: "admin", mustChangePassword: true });
apiGet.mockResolvedValue({ settings: {} }); apiGet.mockResolvedValue({ settings: PROCESSED });
renderOverlay(); renderOverlay();
@@ -255,7 +252,7 @@ describe("UsageSurveyOverlay", () => {
it("renders nothing on the change-password route even if mustChangePassword is stale-false", async () => { it("renders nothing on the change-password route even if mustChangePassword is stale-false", async () => {
useAuth.mockReturnValue({ role: "admin", mustChangePassword: false }); useAuth.mockReturnValue({ role: "admin", mustChangePassword: false });
apiGet.mockResolvedValue({ settings: {} }); apiGet.mockResolvedValue({ settings: PROCESSED });
renderOverlay("/change-password"); renderOverlay("/change-password");
@@ -266,7 +263,7 @@ describe("UsageSurveyOverlay", () => {
it("renders nothing on the privacy policy route", async () => { it("renders nothing on the privacy policy route", async () => {
useAuth.mockReturnValue({ role: "admin", mustChangePassword: false }); useAuth.mockReturnValue({ role: "admin", mustChangePassword: false });
apiGet.mockResolvedValue({ settings: {} }); apiGet.mockResolvedValue({ settings: PROCESSED });
renderOverlay("/privacy"); renderOverlay("/privacy");