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
+9
View File
@@ -31,6 +31,7 @@ import {
getBundleForTool,
getOptionalBundleForTool,
isToolInputError,
ONBOARDING_FIRST_PROCESSED_KEY,
type PipelineExecutedProperties,
TOOLS,
} from "@snapotter/shared";
@@ -53,6 +54,7 @@ import {
} from "../lib/object-storage.js";
import { OCR_MAX_ENCODED_INPUT_BYTES } from "../lib/ocr-limits.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 { InputValidationError } from "../modality/contract.js";
import {
@@ -493,6 +495,13 @@ async function processToolJob(job: Job<ToolJobData>): Promise<ToolJobResult> {
},
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
+11
View File
@@ -2,10 +2,13 @@ import {
ANALYTICS_BAKED,
ANALYTICS_EVENTS,
APP_VERSION,
type FeedbackDiscoverySource,
type FeedbackErrorCategory,
type FeedbackFrictionArea,
type FeedbackImportantArea,
type FeedbackInstallMethod,
type FeedbackPriorTool,
type FeedbackSelfHostMotivation,
type FeedbackSentiment,
type FeedbackSource,
type FeedbackSurveyId,
@@ -38,6 +41,11 @@ export interface FeedbackEventProperties {
usage_type?: FeedbackUsageType;
important_areas?: FeedbackImportantArea[];
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;
}
@@ -138,6 +146,9 @@ function cleanFeedbackProperties(properties: FeedbackEventProperties): Record<st
copyString("install_method");
copyString("usage_type");
copyString("friction_area");
copyString("prior_tool");
copyString("selfhost_motivation");
copyString("discovery_source");
copyString("error_category");
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.
* Returns `defaultValue` when the key is missing, non-numeric, or on DB error.
+9
View File
@@ -1,8 +1,11 @@
import {
FEEDBACK_DISCOVERY_SOURCE_VALUES,
FEEDBACK_ERROR_CATEGORY_VALUES,
FEEDBACK_FRICTION_AREA_VALUES,
FEEDBACK_IMPORTANT_AREA_VALUES,
FEEDBACK_INSTALL_METHOD_VALUES,
FEEDBACK_PRIOR_TOOL_VALUES,
FEEDBACK_SELFHOST_MOTIVATION_VALUES,
FEEDBACK_SENTIMENT_VALUES,
FEEDBACK_SOURCE_VALUES,
FEEDBACK_SURVEY_ID_VALUES,
@@ -58,6 +61,9 @@ const feedbackBodySchema = z
usageType: z.enum(FEEDBACK_USAGE_TYPE_VALUES).optional(),
importantAreas: z.array(z.enum(FEEDBACK_IMPORTANT_AREA_VALUES)).max(5).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(),
})
.superRefine((value, ctx) => {
@@ -97,6 +103,9 @@ function toPostHogProperties(body: z.infer<typeof feedbackBodySchema>): Feedback
usage_type: body.usageType,
important_areas: body.importantAreas,
friction_area: body.frictionArea,
prior_tool: body.priorTool,
selfhost_motivation: body.selfHostMotivation,
discovery_source: body.discoverySource,
error_category: body.errorCategory,
};
}
@@ -1,5 +1,7 @@
import { MessageSquare } from "lucide-react";
import { useEffect, useRef } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { trackFeedbackPromptDismissed, trackFeedbackPromptShown } from "@/lib/feedback";
interface AdminInstallFeedbackCardProps {
visible: boolean;
@@ -15,6 +17,17 @@ export function AdminInstallFeedbackCard({
onDismissForever,
}: AdminInstallFeedbackCardProps) {
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;
return (
@@ -36,14 +49,20 @@ export function AdminInstallFeedbackCard({
</button>
<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"
>
{t.feedback.remindLater}
</button>
<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"
>
{t.feedback.dontAskAgain}
@@ -7,6 +7,8 @@ import {
promptVariantForSource,
submitFeedback,
surveyIdForSource,
trackFeedbackPromptDismissed,
trackFeedbackPromptShown,
} from "@/lib/feedback";
import { cn } from "@/lib/utils";
import { useAnalyticsStore } from "@/stores/analytics-store";
@@ -96,19 +98,22 @@ export function ToolFeedbackPrompt({
const [dialogSentiment, setDialogSentiment] = useState<FeedbackSentiment | undefined>();
const [dialogOpen, setDialogOpen] = useState(false);
const [thanks, setThanks] = useState(false);
const source = jobStatus === "failed" ? "failed_job" : "tool_result";
useEffect(() => {
if (!analyticsLoaded || !analyticsConfig?.enabled) return;
const show = shouldShowPrompt(toolId);
if (show) markPromptShown();
if (show) {
markPromptShown();
trackFeedbackPromptShown(source);
}
setVisible(show);
}, [analyticsLoaded, analyticsConfig?.enabled, toolId]);
}, [analyticsLoaded, analyticsConfig?.enabled, toolId, source]);
if (!analyticsLoaded || !analyticsConfig?.enabled) return null;
if (!visible && !thanks && !dialogOpen) return null;
async function handleQuickSentiment(sentiment: FeedbackSentiment) {
const source = jobStatus === "failed" ? "failed_job" : "tool_result";
if (sentiment === "great") {
markPromptHandled(toolId);
setVisible(false);
@@ -136,11 +141,13 @@ export function ToolFeedbackPrompt({
function handleDismiss() {
markPromptHandled(toolId);
trackFeedbackPromptDismissed(source, "close");
setVisible(false);
}
function handleDontAskAgain() {
disablePrompts();
trackFeedbackPromptDismissed(source, "dont_ask_again");
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 { trackFeedbackPromptDismissed, trackFeedbackPromptShown } from "@/lib/feedback";
import { cn } from "@/lib/utils";
import { useConnectionStore } from "@/stores/connection-store";
import { useSettingsStore } from "@/stores/settings-store";
@@ -20,6 +21,9 @@ export function AppLayout({ children, breadcrumb, navVariant }: AppLayoutProps)
const [settingsOpen, setSettingsOpen] = useState(false);
const [helpOpen, setHelpOpen] = 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 connectionStatus = useConnectionStore((s) => s.status);
const bannerVisible = connectionStatus !== "connected";
@@ -51,7 +55,11 @@ export function AppLayout({ children, breadcrumb, navVariant }: AppLayoutProps)
variant={navVariant}
breadcrumb={breadcrumb}
onHelpClick={() => setHelpOpen(true)}
onFeedbackClick={() => setFeedbackOpen(true)}
onFeedbackClick={() => {
feedbackSubmittedRef.current = false;
trackFeedbackPromptShown("global");
setFeedbackOpen(true);
}}
onSettingsClick={() => setSettingsOpen(true)}
/>
@@ -74,7 +82,17 @@ export function AppLayout({ children, breadcrumb, navVariant }: AppLayoutProps)
<HelpDialog open={helpOpen} onClose={() => setHelpOpen(false)} />
{/* 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 */}
<AiInstallIndicator />
@@ -1,16 +1,9 @@
import { FEEDBACK_FRICTION_AREA_VALUES, FEEDBACK_INSTALL_METHOD_VALUES } from "@snapotter/shared";
import {
Building2,
FileText,
GraduationCap,
Image,
Layers,
Search,
Sparkles,
User,
Users,
Video,
} from "lucide-react";
FEEDBACK_DISCOVERY_SOURCE_VALUES,
FEEDBACK_PRIOR_TOOL_VALUES,
FEEDBACK_SELFHOST_MOTIVATION_VALUES,
} from "@snapotter/shared";
import { Building2, GraduationCap, Search, User, Users } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useLocation } from "react-router-dom";
import { useTranslation } from "@/contexts/i18n-context";
@@ -19,14 +12,16 @@ import { useFocusTrap } from "@/hooks/use-focus-trap";
import { apiGet, apiPut } from "@/lib/api";
import { AUTH_GUARD_UNGATED_PATHS } from "@/lib/auth-routes";
import {
type FeedbackFrictionArea,
type FeedbackImportantArea,
type FeedbackInstallMethod,
type FeedbackDiscoverySource,
type FeedbackPriorTool,
type FeedbackSelfHostMotivation,
type FeedbackUsageType,
promptVariantForSource,
shouldShowUsageSurvey,
submitFeedback,
surveyIdForSource,
trackFeedbackPromptDismissed,
trackFeedbackPromptShown,
} from "@/lib/feedback";
import { cn } from "@/lib/utils";
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 },
];
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, mustChangePassword } = useAuth();
@@ -63,15 +50,20 @@ export function UsageSurveyOverlay() {
const [settings, setSettings] = useState<Record<string, string> | null>(null);
const [usageType, setUsageType] = useState<FeedbackUsageType | null>(null);
const [importantAreas, setImportantAreas] = useState<FeedbackImportantArea[]>([]);
// Install method and friction area are optional: null until the admin picks one,
// so we never record an unanswered dropdown as a real value.
const [installMethod, setInstallMethod] = useState<FeedbackInstallMethod | null>(null);
const [frictionArea, setFrictionArea] = useState<FeedbackFrictionArea | null>(null);
// The survey asks only what telemetry cannot infer. Modality preference is
// already visible in tool_used, and install method in instance_started, so
// instead we ask what they came from, why they self-host, and how they found
// us. Prior tool and motivation are optional; discovery source is optional too.
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 [dismissing, setDismissing] = useState(false);
const busy = submitting || dismissing;
const submittedAnswerKeyRef = useRef<string | null>(null);
const shownTrackedRef = useRef(false);
const eligibleAuthState = role === "admin" && !mustChangePassword;
const eligibleRoute = !AUTH_GUARD_UNGATED_PATHS.has(location.pathname);
@@ -102,11 +94,15 @@ export function UsageSurveyOverlay() {
useFocusTrap(containerRef, visible);
function toggleArea(area: FeedbackImportantArea) {
setImportantAreas((current) =>
current.includes(area) ? current.filter((value) => value !== area) : [...current, area],
);
}
// Record the impression once the overlay first becomes visible, so the survey's
// completion and skip rates have a denominator (submissions alone can't measure
// how many admins saw it and walked away).
useEffect(() => {
if (visible && !shownTrackedRef.current) {
shownTrackedRef.current = true;
trackFeedbackPromptShown("onboarding");
}
}, [visible]);
async function recordSettingsKey(key: string) {
const value = new Date().toISOString();
@@ -117,12 +113,7 @@ export function UsageSurveyOverlay() {
async function handleContinue() {
if (!usageType || busy) return;
setSubmitting(true);
const answerKey = JSON.stringify({
usageType,
importantAreas: [...importantAreas].sort(),
installMethod,
frictionArea,
});
const answerKey = JSON.stringify({ usageType, priorTool, selfHostMotivation, discoverySource });
try {
if (submittedAnswerKeyRef.current !== answerKey) {
await withTimeout(
@@ -131,9 +122,9 @@ export function UsageSurveyOverlay() {
surveyId: surveyIdForSource("onboarding"),
promptVariant: promptVariantForSource("onboarding"),
usageType,
importantAreas,
...(installMethod ? { installMethod } : {}),
...(frictionArea ? { frictionArea } : {}),
...(priorTool ? { priorTool } : {}),
...(selfHostMotivation ? { selfHostMotivation } : {}),
...(discoverySource ? { discoverySource } : {}),
}),
WRITE_TIMEOUT_MS,
);
@@ -155,6 +146,7 @@ export function UsageSurveyOverlay() {
async function handleDismiss() {
if (busy) return;
setDismissing(true);
trackFeedbackPromptDismissed("onboarding", "dont_ask_again");
try {
await recordSettingsKey("onboarding.usageSurvey.dismissedAt");
} catch {
@@ -217,64 +209,62 @@ export function UsageSurveyOverlay() {
</div>
<div className="space-y-2">
<p id="usage-survey-tools-label" className="text-sm font-medium text-foreground">
{t.onboarding.usageSurveyToolsLabel}{" "}
<span className="text-xs font-normal text-muted-foreground">
{t.onboarding.pickAnyHint}
</span>
<p id="usage-survey-prior-label" className="text-sm font-medium text-foreground">
{t.onboarding.priorToolLabel}
</p>
{/* biome-ignore lint/a11y/useSemanticElements: plain group wrapper for toggle buttons, a fieldset would disrupt the grid layout */}
<div
role="group"
aria-labelledby="usage-survey-tools-label"
className="grid grid-cols-2 gap-2"
role="radiogroup"
aria-labelledby="usage-survey-prior-label"
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
key={value}
type="button"
aria-pressed={importantAreas.includes(value)}
onClick={() => toggleArea(value)}
role="radio"
aria-checked={priorTool === value}
onClick={() => setPriorTool((current) => (current === value ? null : 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)
"rounded-lg border px-3 py-2.5 text-sm font-medium text-start transition-colors",
priorTool === value
? "border-primary bg-primary/10 text-primary-ink"
: "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.importantAreas[value]}
{t.feedback.priorTools[value]}
</button>
))}
</div>
</div>
<div className="space-y-2">
<p id="usage-survey-install-label" className="text-sm font-medium text-foreground">
{t.feedback.installMethodLabel}
<p id="usage-survey-motivation-label" className="text-sm font-medium text-foreground">
{t.onboarding.selfHostMotivationLabel}
</p>
<div
role="radiogroup"
aria-labelledby="usage-survey-install-label"
className="grid grid-cols-2 gap-2"
aria-labelledby="usage-survey-motivation-label"
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
<button
key={value}
type="button"
role="radio"
aria-checked={installMethod === value}
onClick={() => setInstallMethod((current) => (current === value ? null : value))}
aria-checked={selfHostMotivation === value}
onClick={() =>
setSelfHostMotivation((current) => (current === value ? null : value))
}
className={cn(
"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-border text-foreground hover:bg-muted",
)}
>
{t.feedback.installMethods[value]}
{t.feedback.selfHostMotivations[value]}
</button>
))}
</div>
@@ -282,23 +272,26 @@ export function UsageSurveyOverlay() {
<div className="space-y-2">
<label
htmlFor="usage-survey-friction-area"
htmlFor="usage-survey-discovery-source"
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>
<select
id="usage-survey-friction-area"
value={frictionArea ?? ""}
id="usage-survey-discovery-source"
value={discoverySource ?? ""}
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"
>
<option value="" />
{FEEDBACK_FRICTION_AREA_VALUES.map((value) => (
{FEEDBACK_DISCOVERY_SOURCE_VALUES.map((value) => (
<option key={value} value={value}>
{t.feedback.frictionAreas[value]}
{t.feedback.discoverySources[value]}
</option>
))}
</select>
+2
View File
@@ -25,6 +25,8 @@ const ALLOWED: Record<string, ReadonlySet<string>> = {
pipeline_step_added: new Set(["tool_id"]),
pipeline_saved: new Set(["step_count"]),
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> {
+45
View File
@@ -1,21 +1,29 @@
import type {
FeedbackDiscoverySource,
FeedbackErrorCategory,
FeedbackFrictionArea,
FeedbackImportantArea,
FeedbackInstallMethod,
FeedbackPriorTool,
FeedbackSelfHostMotivation,
FeedbackSentiment,
FeedbackSource,
FeedbackSurveyId,
FeedbackType,
FeedbackUsageType,
} from "@snapotter/shared";
import { ANALYTICS_EVENTS, ONBOARDING_FIRST_PROCESSED_KEY } from "@snapotter/shared";
import { track } from "@/lib/analytics";
import { apiPost } from "@/lib/api";
export type {
FeedbackDiscoverySource,
FeedbackErrorCategory,
FeedbackFrictionArea,
FeedbackImportantArea,
FeedbackInstallMethod,
FeedbackPriorTool,
FeedbackSelfHostMotivation,
FeedbackSentiment,
FeedbackSource,
FeedbackSurveyId,
@@ -50,6 +58,9 @@ export interface FeedbackPayload {
usageType?: FeedbackUsageType;
importantAreas?: FeedbackImportantArea[];
frictionArea?: FeedbackFrictionArea;
priorTool?: FeedbackPriorTool;
selfHostMotivation?: FeedbackSelfHostMotivation;
discoverySource?: FeedbackDiscoverySource;
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 {
const value = (message ?? "").toLowerCase();
if (!value) return "unknown";
@@ -146,6 +186,11 @@ export function shouldShowUsageSurvey({
analyticsEnabled,
}: UsageSurveyVisibilityOptions): boolean {
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 (
!settings["onboarding.usageSurvey.answeredAt"] &&
!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 { useRecentTools } from "@/hooks/use-recent-tools.js";
import type { FeedbackPromptVariant } from "@/lib/feedback.js";
import { trackFeedbackPromptDismissed, trackFeedbackPromptShown } from "@/lib/feedback.js";
import { format } from "@/lib/format.js";
import { ICON_MAP } from "@/lib/icon-map.js";
import { getCategoryName, getToolName } from "@/lib/tool-i18n.js";
@@ -55,9 +56,14 @@ export function HomePage() {
const analyticsOn = analyticsConfigLoaded && analyticsConfig?.enabled === true;
const [requestOpen, setRequestOpen] = useState(false);
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) => {
requestSubmittedRef.current = false;
setRequestVariant(variant);
trackFeedbackPromptShown("search_miss");
setRequestOpen(true);
}, []);
@@ -200,7 +206,15 @@ export function HomePage() {
source="search_miss"
searchQuery={search}
promptVariant={requestVariant}
onClose={() => setRequestOpen(false)}
onSubmitted={() => {
requestSubmittedRef.current = true;
}}
onClose={() => {
if (!requestSubmittedRef.current) {
trackFeedbackPromptDismissed("search_miss", "close");
}
setRequestOpen(false);
}}
/>
</div>
</div>