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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user