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