mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
+3
-1
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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_]*$/);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user