feat: add PostHog customer feedback

This commit is contained in:
SnapOtter
2026-06-29 18:16:33 +08:00
parent 6f85b3d12a
commit 649e65b035
47 changed files with 3937 additions and 4 deletions
@@ -5,8 +5,10 @@ import { Link } from "react-router-dom";
import { useTranslation } from "@/contexts/i18n-context";
import { formatHeaders } from "@/lib/api";
import { formatFileSize, triggerDownload } from "@/lib/download";
import { classifyFeedbackError } from "@/lib/feedback";
import { format } from "@/lib/format";
import { cn } from "@/lib/utils";
import { ToolFeedbackPrompt } from "../feedback/tool-feedback-prompt";
/** Tools whose primary output is text/data, not a downloadable file. */
const DATA_OUTPUT_TOOLS = new Set([
@@ -213,6 +215,16 @@ export function ReviewPanel({
</div>
)}
<ToolFeedbackPrompt
toolId={currentToolId}
jobStatus={hasBatchStats && failedCount > 0 ? "failed" : "completed"}
errorCategory={
hasBatchStats && failedCount > 0
? classifyFeedbackError(t.toolPage.batchPartialSuccess)
: undefined
}
/>
{/* Edit settings / New file -- side by side */}
<div className="grid grid-cols-2 gap-2">
<button
@@ -0,0 +1,54 @@
import { MessageSquare } from "lucide-react";
import { useTranslation } from "@/contexts/i18n-context";
interface AdminInstallFeedbackCardProps {
visible: boolean;
onShare: () => void;
onRemindLater: () => void;
onDismissForever: () => void;
}
export function AdminInstallFeedbackCard({
visible,
onShare,
onRemindLater,
onDismissForever,
}: AdminInstallFeedbackCardProps) {
const { t } = useTranslation();
if (!visible) return null;
return (
<div className="rounded-lg border border-border bg-muted/30 p-3 space-y-3">
<div className="flex items-start gap-2">
<MessageSquare className="h-4 w-4 text-primary shrink-0 mt-0.5" />
<div className="min-w-0">
<h5 className="text-sm font-semibold text-foreground">{t.feedback.adminCardTitle}</h5>
<p className="text-xs text-muted-foreground">{t.feedback.adminCardDescription}</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={onShare}
className="rounded-md bg-primary px-2.5 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
>
{t.feedback.shareFeedback}
</button>
<button
type="button"
onClick={onRemindLater}
className="rounded-md border border-border px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:bg-background hover:text-foreground"
>
{t.feedback.remindLater}
</button>
<button
type="button"
onClick={onDismissForever}
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}
</button>
</div>
</div>
);
}
@@ -0,0 +1,520 @@
import { CheckCircle2, MessageSquare, Send, X } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import {
type FeedbackErrorCategory,
type FeedbackFrictionArea,
type FeedbackImportantArea,
type FeedbackInstallMethod,
type FeedbackPayload,
type FeedbackSentiment,
type FeedbackSource,
type FeedbackType,
type FeedbackUsageType,
promptVariantForSource,
submitFeedback,
surveyIdForSource,
} from "@/lib/feedback";
import { cn } from "@/lib/utils";
interface FeedbackDialogProps {
open: boolean;
source: FeedbackSource;
toolId?: string;
jobStatus?: "completed" | "failed";
errorCategory?: FeedbackErrorCategory;
initialSentiment?: FeedbackSentiment;
onClose: () => void;
onSubmitted?: () => void;
}
const FEEDBACK_TYPES: FeedbackType[] = [
"bug",
"feature_request",
"confusing_ux",
"performance",
"other",
];
const SENTIMENTS: FeedbackSentiment[] = ["great", "okay", "issue", "missing"];
const INSTALL_METHODS: FeedbackInstallMethod[] = [
"docker",
"docker_compose",
"source",
"cloud",
"other",
];
const USAGE_TYPES: FeedbackUsageType[] = [
"personal",
"team_internal",
"business_workflow",
"education",
"evaluating",
];
const FRICTION_AREAS: FeedbackFrictionArea[] = [
"smooth",
"docker",
"environment_variables",
"auth",
"storage",
"workers",
"ai_tools",
"docs",
"performance",
"other",
];
const IMPORTANT_AREAS: FeedbackImportantArea[] = [
"images",
"pdf_docs",
"video_audio",
"batch_workflows",
"ai_tools",
];
export function FeedbackDialog({
open,
source,
toolId,
jobStatus,
errorCategory,
initialSentiment,
onClose,
onSubmitted,
}: FeedbackDialogProps) {
const { t } = useTranslation();
const dialogRef = useRef<HTMLDivElement>(null);
const [sentiment, setSentiment] = useState<FeedbackSentiment | "">(initialSentiment ?? "");
const [feedbackType, setFeedbackType] = useState<FeedbackType>("other");
const [message, setMessage] = useState("");
const [contactOk, setContactOk] = useState(false);
const [contactEmail, setContactEmail] = useState("");
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);
const [submitted, setSubmitted] = useState(false);
const [error, setError] = useState<string | null>(null);
useFocusTrap(dialogRef, open);
useEffect(() => {
if (!open) return;
setSentiment(initialSentiment ?? "");
setFeedbackType(source === "failed_job" ? "bug" : "other");
setMessage("");
setContactOk(false);
setContactEmail("");
setContactName("");
setCompany("");
setInstallMethod("docker_compose");
setUsageType("evaluating");
setFrictionArea("smooth");
setImportantAreas([]);
setSubmitting(false);
setSubmitted(false);
setError(null);
}, [open, initialSentiment, source]);
useEffect(() => {
if (!open) return;
const handler = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [open, onClose]);
const title = useMemo(() => {
if (source === "tool_result") return t.feedback.toolDialogTitle;
if (source === "failed_job") return t.feedback.failedDialogTitle;
if (source === "admin_installer") return t.feedback.adminDialogTitle;
return t.feedback.dialogTitle;
}, [source, t.feedback]);
const isAdminInstall = source === "admin_installer";
const canSubmit = Boolean(
message.trim() || sentiment || feedbackType !== "other" || isAdminInstall,
);
function toggleImportantArea(area: FeedbackImportantArea) {
setImportantAreas((current) =>
current.includes(area) ? current.filter((value) => value !== area) : [...current, area],
);
}
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!canSubmit || submitting) return;
setSubmitting(true);
setError(null);
const payload: FeedbackPayload = {
source,
surveyId: surveyIdForSource(source),
promptVariant: promptVariantForSource(source),
...(sentiment ? { sentiment } : {}),
feedbackType,
message: message.trim() || undefined,
contactOk,
contactEmail: contactOk ? contactEmail.trim() || undefined : undefined,
contactName: contactOk ? contactName.trim() || undefined : undefined,
company: contactOk ? company.trim() || undefined : undefined,
toolId,
jobStatus,
errorCategory,
...(isAdminInstall
? {
installMethod,
usageType,
frictionArea,
importantAreas,
}
: {}),
};
try {
await submitFeedback(payload);
setSubmitted(true);
onSubmitted?.();
} catch {
setError(t.feedback.submitFailed);
} finally {
setSubmitting(false);
}
}
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
aria-hidden="true"
className="absolute inset-0 bg-black/50 backdrop-blur-sm cursor-default"
onClick={onClose}
/>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="feedback-dialog-title"
className="relative bg-background border border-border rounded-xl shadow-2xl w-full max-w-lg max-h-[85dvh] flex flex-col overflow-hidden"
>
<div className="flex items-center justify-between p-4 border-b border-border shrink-0">
<div className="flex items-center gap-2 min-w-0">
<MessageSquare className="h-4 w-4 text-primary shrink-0" />
<h2 id="feedback-dialog-title" className="text-lg font-semibold text-foreground">
{title}
</h2>
</div>
<button
type="button"
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
aria-label={t.feedback.closeLabel}
>
<X className="h-4 w-4" />
</button>
</div>
{submitted ? (
<div className="p-6 space-y-4">
<div className="flex items-start gap-3">
<CheckCircle2 className="h-5 w-5 text-emerald-600 shrink-0 mt-0.5" />
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">{t.feedback.thanksTitle}</p>
<p className="text-sm text-muted-foreground">{t.feedback.thanksDescription}</p>
</div>
</div>
<button
type="button"
onClick={onClose}
className="w-full py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90"
>
{t.common.close}
</button>
</div>
) : (
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto p-5 space-y-5">
<p className="text-sm text-muted-foreground">{t.feedback.privacyNote}</p>
{isAdminInstall ? (
<AdminInstallFields
installMethod={installMethod}
setInstallMethod={setInstallMethod}
usageType={usageType}
setUsageType={setUsageType}
frictionArea={frictionArea}
setFrictionArea={setFrictionArea}
importantAreas={importantAreas}
toggleImportantArea={toggleImportantArea}
/>
) : (
<>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground" htmlFor="feedback-type">
{t.feedback.typeLabel}
</label>
<select
id="feedback-type"
value={feedbackType}
onChange={(event) => setFeedbackType(event.target.value as FeedbackType)}
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground"
>
{FEEDBACK_TYPES.map((type) => (
<option key={type} value={type}>
{t.feedback.types[type]}
</option>
))}
</select>
</div>
<div className="space-y-2">
<span className="text-sm font-medium text-foreground">
{t.feedback.sentimentLabel}
</span>
<div className="grid grid-cols-2 gap-2">
{SENTIMENTS.map((value) => (
<button
key={value}
type="button"
onClick={() => setSentiment(value)}
className={cn(
"py-2 rounded-lg border text-sm font-medium transition-colors",
sentiment === value
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
{t.feedback.sentiments[value]}
</button>
))}
</div>
</div>
</>
)}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground" htmlFor="feedback-message">
{isAdminInstall ? t.feedback.improveFirstLabel : t.feedback.messageLabel}
</label>
<textarea
id="feedback-message"
value={message}
onChange={(event) => setMessage(event.target.value)}
maxLength={2000}
rows={5}
placeholder={
isAdminInstall
? t.feedback.improveFirstPlaceholder
: t.feedback.messagePlaceholder
}
className="w-full resize-y rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground"
/>
</div>
<ContactFields
contactOk={contactOk}
setContactOk={setContactOk}
contactEmail={contactEmail}
setContactEmail={setContactEmail}
contactName={contactName}
setContactName={setContactName}
company={company}
setCompany={setCompany}
/>
{error && <p className="text-sm text-destructive">{error}</p>}
<div className="flex items-center justify-end gap-2 pt-2">
<button
type="button"
onClick={onClose}
className="px-3 py-2 rounded-lg border border-border text-sm text-muted-foreground hover:bg-muted hover:text-foreground"
>
{t.common.cancel}
</button>
<button
type="submit"
disabled={!canSubmit || submitting}
className="px-3 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 flex items-center gap-2"
>
<Send className="h-4 w-4" />
{submitting ? t.feedback.submitting : t.feedback.submit}
</button>
</div>
</form>
)}
</div>
</div>
);
}
interface AdminInstallFieldsProps {
installMethod: FeedbackInstallMethod;
setInstallMethod: (value: FeedbackInstallMethod) => void;
usageType: FeedbackUsageType;
setUsageType: (value: FeedbackUsageType) => void;
frictionArea: FeedbackFrictionArea;
setFrictionArea: (value: FeedbackFrictionArea) => void;
importantAreas: FeedbackImportantArea[];
toggleImportantArea: (value: FeedbackImportantArea) => void;
}
function AdminInstallFields({
installMethod,
setInstallMethod,
usageType,
setUsageType,
frictionArea,
setFrictionArea,
importantAreas,
toggleImportantArea,
}: AdminInstallFieldsProps) {
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>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground" htmlFor="feedback-friction-area">
{t.feedback.frictionAreaLabel}
</label>
<select
id="feedback-friction-area"
value={frictionArea}
onChange={(event) => setFrictionArea(event.target.value as FeedbackFrictionArea)}
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground"
>
{FRICTION_AREAS.map((area) => (
<option key={area} value={area}>
{t.feedback.frictionAreas[area]}
</option>
))}
</select>
</div>
<fieldset className="space-y-2">
<legend className="text-sm font-medium text-foreground">
{t.feedback.importantAreasLabel}
</legend>
<div className="grid gap-2 sm:grid-cols-2">
{IMPORTANT_AREAS.map((area) => (
<label key={area} className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={importantAreas.includes(area)}
onChange={() => toggleImportantArea(area)}
className="h-4 w-4 rounded border-border"
/>
<span>{t.feedback.importantAreas[area]}</span>
</label>
))}
</div>
</fieldset>
</>
);
}
interface ContactFieldsProps {
contactOk: boolean;
setContactOk: (value: boolean) => void;
contactEmail: string;
setContactEmail: (value: string) => void;
contactName: string;
setContactName: (value: string) => void;
company: string;
setCompany: (value: string) => void;
}
function ContactFields({
contactOk,
setContactOk,
contactEmail,
setContactEmail,
contactName,
setContactName,
company,
setCompany,
}: ContactFieldsProps) {
const { t } = useTranslation();
return (
<div className="space-y-3">
<label className="flex items-start gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={contactOk}
onChange={(event) => setContactOk(event.target.checked)}
className="mt-0.5 h-4 w-4 rounded border-border"
/>
<span>{t.feedback.contactOkLabel}</span>
</label>
{contactOk && (
<div className="grid gap-3 sm:grid-cols-2">
<input
value={contactEmail}
onChange={(event) => setContactEmail(event.target.value)}
type="email"
maxLength={320}
placeholder={t.feedback.emailPlaceholder}
className="rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground"
/>
<input
value={contactName}
onChange={(event) => setContactName(event.target.value)}
maxLength={120}
placeholder={t.feedback.namePlaceholder}
className="rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground"
/>
<input
value={company}
onChange={(event) => setCompany(event.target.value)}
maxLength={160}
placeholder={t.feedback.companyPlaceholder}
className="rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground sm:col-span-2"
/>
</div>
)}
</div>
);
}
@@ -0,0 +1,200 @@
import { MessageSquare, X } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import {
type FeedbackErrorCategory,
type FeedbackSentiment,
promptVariantForSource,
submitFeedback,
surveyIdForSource,
} from "@/lib/feedback";
import { cn } from "@/lib/utils";
import { useAnalyticsStore } from "@/stores/analytics-store";
import { FeedbackDialog } from "./feedback-dialog";
interface ToolFeedbackPromptProps {
toolId: string;
jobStatus?: "completed" | "failed";
errorCategory?: FeedbackErrorCategory;
}
const GLOBAL_LAST_PROMPT_KEY = "snapotter-feedback-last-prompt-at";
const PROMPTS_DISABLED_KEY = "snapotter-feedback-prompts-disabled";
const TOOL_PROMPT_PREFIX = "snapotter-feedback-tool-prompt:";
const GLOBAL_PROMPT_COOLDOWN_MS = 30 * 24 * 60 * 60 * 1000;
const TOOL_PROMPT_COOLDOWN_MS = 90 * 24 * 60 * 60 * 1000;
function readNumber(key: string): number {
try {
const raw = localStorage.getItem(key);
const parsed = raw ? Number(raw) : 0;
return Number.isFinite(parsed) ? parsed : 0;
} catch {
return 0;
}
}
function writeNow(key: string): void {
try {
localStorage.setItem(key, String(Date.now()));
} catch {
// ignore storage failures
}
}
function promptsDisabled(): boolean {
try {
return localStorage.getItem(PROMPTS_DISABLED_KEY) === "true";
} catch {
return false;
}
}
function disablePrompts(): void {
try {
localStorage.setItem(PROMPTS_DISABLED_KEY, "true");
} catch {
// ignore storage failures
}
}
function shouldShowPrompt(toolId: string): boolean {
if (!toolId || promptsDisabled()) return false;
const now = Date.now();
const lastGlobal = readNumber(GLOBAL_LAST_PROMPT_KEY);
if (lastGlobal && now - lastGlobal < GLOBAL_PROMPT_COOLDOWN_MS) return false;
const lastTool = readNumber(`${TOOL_PROMPT_PREFIX}${toolId}`);
if (lastTool && now - lastTool < TOOL_PROMPT_COOLDOWN_MS) return false;
return true;
}
function markPromptHandled(toolId: string): void {
writeNow(GLOBAL_LAST_PROMPT_KEY);
writeNow(`${TOOL_PROMPT_PREFIX}${toolId}`);
}
export function ToolFeedbackPrompt({
toolId,
jobStatus = "completed",
errorCategory,
}: ToolFeedbackPromptProps) {
const { t } = useTranslation();
const analyticsConfig = useAnalyticsStore((s) => s.config);
const analyticsLoaded = useAnalyticsStore((s) => s.configLoaded);
const [visible, setVisible] = useState(false);
const [dialogSentiment, setDialogSentiment] = useState<FeedbackSentiment | undefined>();
const [dialogOpen, setDialogOpen] = useState(false);
const [thanks, setThanks] = useState(false);
useEffect(() => {
if (!analyticsLoaded || !analyticsConfig?.enabled) return;
setVisible(shouldShowPrompt(toolId));
}, [analyticsLoaded, analyticsConfig?.enabled, toolId]);
if (!analyticsLoaded || !analyticsConfig?.enabled) return null;
if (!visible && !thanks && !dialogOpen) return null;
async function handleQuickSentiment(sentiment: FeedbackSentiment) {
const source = jobStatus === "failed" ? "failed_job" : "tool_result";
if (sentiment === "great") {
markPromptHandled(toolId);
setVisible(false);
setThanks(true);
try {
await submitFeedback({
source,
surveyId: surveyIdForSource(source),
promptVariant: promptVariantForSource(source),
sentiment,
feedbackType: "other",
toolId,
jobStatus,
errorCategory,
});
} catch {
// Explicit feedback is best-effort; keep the user flow calm.
}
return;
}
setDialogSentiment(sentiment);
setDialogOpen(true);
}
function handleDismiss() {
markPromptHandled(toolId);
setVisible(false);
}
function handleDontAskAgain() {
disablePrompts();
setVisible(false);
}
function handleSubmitted() {
markPromptHandled(toolId);
setVisible(false);
setThanks(true);
}
return (
<>
{visible && (
<div className="rounded-lg border border-border bg-muted/35 p-3 space-y-2">
<div className="flex items-start gap-2">
<MessageSquare className="h-4 w-4 text-primary shrink-0 mt-0.5" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground">{t.feedback.toolPromptTitle}</p>
<p className="text-xs text-muted-foreground">{t.feedback.toolPromptDescription}</p>
</div>
<button
type="button"
onClick={handleDismiss}
aria-label={t.feedback.dismissPrompt}
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-background"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
<div className="grid grid-cols-3 gap-1.5">
{(["great", "issue", "missing"] as FeedbackSentiment[]).map((sentiment) => (
<button
key={sentiment}
type="button"
onClick={() => handleQuickSentiment(sentiment)}
className={cn(
"rounded-md border border-border px-2 py-1.5 text-xs font-medium text-muted-foreground hover:bg-background hover:text-foreground",
sentiment === "great" && "hover:border-emerald-500/60",
)}
>
{sentiment === "great" ? t.feedback.workedWell : t.feedback.sentiments[sentiment]}
</button>
))}
</div>
<button
type="button"
onClick={handleDontAskAgain}
className="text-xs text-muted-foreground hover:text-foreground"
>
{t.feedback.dontAskAgain}
</button>
</div>
)}
{thanks && (
<p className="text-xs text-emerald-600 dark:text-emerald-400">{t.feedback.quickThanks}</p>
)}
<FeedbackDialog
open={dialogOpen}
source={jobStatus === "failed" ? "failed_job" : "tool_result"}
toolId={toolId}
jobStatus={jobStatus}
errorCategory={errorCategory}
initialSentiment={dialogSentiment}
onClose={() => setDialogOpen(false)}
onSubmitted={handleSubmitted}
/>
</>
);
}
@@ -1,8 +1,10 @@
import { useEffect, useState } from "react";
import { useMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
import { useAnalyticsStore } from "@/stores/analytics-store";
import { useConnectionStore } from "@/stores/connection-store";
import { useSettingsStore } from "@/stores/settings-store";
import { FeedbackDialog } from "../feedback/feedback-dialog";
import { HelpDialog } from "../help/help-dialog";
import { SettingsDialog } from "../settings/settings-dialog";
import { AiInstallIndicator } from "./ai-install-indicator";
@@ -18,9 +20,12 @@ interface AppLayoutProps {
export function AppLayout({ children, breadcrumb, navVariant }: AppLayoutProps) {
const [settingsOpen, setSettingsOpen] = useState(false);
const [helpOpen, setHelpOpen] = useState(false);
const [feedbackOpen, setFeedbackOpen] = useState(false);
const isMobile = useMobile();
const analyticsConfig = useAnalyticsStore((s) => s.config);
const connectionStatus = useConnectionStore((s) => s.status);
const bannerVisible = connectionStatus !== "connected";
const feedbackEnabled = Boolean(analyticsConfig?.enabled);
// Load global settings (disabled tools, experimental flag, default theme) on
// every authenticated page, not just the home grid. Without this, navigating
@@ -44,7 +49,9 @@ export function AppLayout({ children, breadcrumb, navVariant }: AppLayoutProps)
variant={navVariant}
breadcrumb={breadcrumb}
onHelpClick={() => setHelpOpen(true)}
onFeedbackClick={() => setFeedbackOpen(true)}
onSettingsClick={() => setSettingsOpen(true)}
feedbackEnabled={feedbackEnabled}
/>
{/* Main content area */}
@@ -65,6 +72,9 @@ export function AppLayout({ children, breadcrumb, navVariant }: AppLayoutProps)
{/* Help dialog */}
<HelpDialog open={helpOpen} onClose={() => setHelpOpen(false)} />
{/* Feedback dialog */}
<FeedbackDialog open={feedbackOpen} source="global" onClose={() => setFeedbackOpen(false)} />
{/* Global AI install progress */}
<AiInstallIndicator />
</div>
@@ -5,6 +5,7 @@ import {
Globe,
HelpCircle,
LayoutGrid,
MessageSquare,
Moon,
Sun,
Workflow,
@@ -24,7 +25,9 @@ interface TopNavProps {
variant?: "light" | "dark";
breadcrumb?: { modality?: string; modalityTab?: string; toolName?: string };
onHelpClick: () => void;
onFeedbackClick?: () => void;
onSettingsClick: () => void;
feedbackEnabled?: boolean;
}
interface NavLinkItem {
@@ -53,7 +56,9 @@ export function TopNav({
variant = "light",
breadcrumb,
onHelpClick,
onFeedbackClick,
onSettingsClick,
feedbackEnabled = false,
}: TopNavProps) {
const location = useLocation();
const isMobile = useMobile();
@@ -109,6 +114,22 @@ export function TopNav({
<div className="flex-1" />
{feedbackEnabled && onFeedbackClick && (
<button
type="button"
onClick={onFeedbackClick}
className={cn(
"p-1.5 rounded-md transition-colors",
isDark
? "text-[#aaa] hover:text-[#e0e0e0] hover:bg-[#333]"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
aria-label={t.feedback.navLabel}
>
<MessageSquare className="h-4 w-4" />
</button>
)}
<button
type="button"
onClick={onHelpClick}
@@ -231,6 +252,22 @@ export function TopNav({
{!isMobile && <ThemeToggle isDark={isDark} />}
{!isMobile && <LanguageSelector isDark={isDark} />}
{feedbackEnabled && onFeedbackClick && (
<button
type="button"
onClick={onFeedbackClick}
className={cn(
"p-1.5 rounded-md transition-colors",
isDark
? "text-[#aaa] hover:text-[#e0e0e0] hover:bg-[#333]"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
aria-label={t.feedback.navLabel}
>
<MessageSquare className="h-4 w-4" />
</button>
)}
<button
type="button"
onClick={onHelpClick}
@@ -328,6 +365,7 @@ function LanguageSelector({ isDark }: { isDark: boolean }) {
<span className={l.code === locale ? "font-medium" : ""}>{l.nativeName}</span>
{l.code === locale && (
<svg
aria-hidden="true"
className="h-3.5 w-3.5 text-primary"
viewBox="0 0 24 24"
fill="none"
@@ -33,6 +33,7 @@ import { useAuth } from "@/hooks/use-auth";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { useMobile } from "@/hooks/use-mobile";
import { apiDelete, apiGet, apiPost, apiPut, clearToken, formatHeaders } from "@/lib/api";
import { shouldShowInstallFeedbackCard } from "@/lib/feedback";
import { format, plural } from "@/lib/format";
import { getCategoryName, getToolDescription, getToolName } from "@/lib/tool-i18n";
import { cn, copyToClipboard } from "@/lib/utils";
@@ -40,6 +41,8 @@ import { useAnalyticsStore } from "@/stores/analytics-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useThemeStore } from "@/stores/theme-store";
import { OtterLogo } from "../common/otter-logo";
import { AdminInstallFeedbackCard } from "../feedback/admin-install-feedback-card";
import { FeedbackDialog } from "../feedback/feedback-dialog";
import { AiFeaturesSection } from "./ai-features-section";
import { UsageSection } from "./usage-section";
@@ -541,10 +544,14 @@ function writableSettings(settings: Record<string, string>): Record<string, stri
function SystemSection() {
const { t } = useTranslation();
const { role } = useAuth();
const analyticsConfig = useAnalyticsStore((s) => s.config);
const analyticsConfigLoaded = useAnalyticsStore((s) => s.configLoaded);
const [settings, setSettings] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saveMsg, setSaveMsg] = useState<string | null>(null);
const [installFeedbackOpen, setInstallFeedbackOpen] = useState(false);
const [bundleLoading, setBundleLoading] = useState(false);
const [bundleError, setBundleError] = useState<string | null>(null);
@@ -567,6 +574,24 @@ function SystemSection() {
setSettings((prev) => ({ ...prev, [key]: value }));
}, []);
const updateInstallFeedbackState = useCallback(async (key: string, value: string) => {
await apiPut("/v1/settings", { [key]: value });
setSettings((prev) => ({ ...prev, [key]: value }));
}, []);
const handleInstallFeedbackSubmitted = useCallback(() => {
void updateInstallFeedbackState("feedback.install.submittedAt", new Date().toISOString());
}, [updateInstallFeedbackState]);
const handleInstallFeedbackSnooze = useCallback(() => {
const snoozedUntil = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toISOString();
void updateInstallFeedbackState("feedback.install.snoozedUntil", snoozedUntil);
}, [updateInstallFeedbackState]);
const handleInstallFeedbackDismiss = useCallback(() => {
void updateInstallFeedbackState("feedback.install.dismissedAt", new Date().toISOString());
}, [updateInstallFeedbackState]);
const handleSave = useCallback(async () => {
setSaving(true);
setSaveMsg(null);
@@ -600,6 +625,13 @@ function SystemSection() {
);
}
const installFeedbackVisible = shouldShowInstallFeedbackCard({
settings,
role,
analyticsConfigLoaded,
analyticsEnabled: Boolean(analyticsConfig?.enabled),
});
return (
<div className="space-y-6">
<div>
@@ -747,6 +779,13 @@ function SystemSection() {
</button>
</SettingRow>
<AdminInstallFeedbackCard
visible={installFeedbackVisible}
onShare={() => setInstallFeedbackOpen(true)}
onRemindLater={handleInstallFeedbackSnooze}
onDismissForever={handleInstallFeedbackDismiss}
/>
<div className="pt-4 border-t border-border">
<h4 className="text-sm font-semibold text-foreground mb-3">
{t.settings.dataRetention.title}
@@ -846,6 +885,13 @@ function SystemSection() {
</SettingRow>
{bundleError && <p className="text-sm text-destructive mt-2">{bundleError}</p>}
</div>
<FeedbackDialog
open={installFeedbackOpen}
source="admin_installer"
onClose={() => setInstallFeedbackOpen(false)}
onSubmitted={handleInstallFeedbackSubmitted}
/>
</div>
);
}
+142
View File
@@ -0,0 +1,142 @@
import { apiPost } from "@/lib/api";
export type FeedbackSource = "global" | "tool_result" | "failed_job" | "admin_installer";
export type FeedbackSurveyId =
| "global-feedback-v1"
| "tool-result-v1"
| "failed-job-v1"
| "admin-install-v1";
export type FeedbackPromptVariant =
| "nav-v1"
| "inline-v1"
| "failed-button-v1"
| "settings-card-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";
export type FeedbackUsageType =
| "personal"
| "team_internal"
| "business_workflow"
| "education"
| "evaluating";
export type FeedbackImportantArea =
| "images"
| "pdf_docs"
| "video_audio"
| "batch_workflows"
| "ai_tools";
export type FeedbackFrictionArea =
| "smooth"
| "docker"
| "environment_variables"
| "auth"
| "storage"
| "workers"
| "ai_tools"
| "docs"
| "performance"
| "other";
export type FeedbackErrorCategory =
| "validation_error"
| "upload_error"
| "processing_error"
| "timeout"
| "unsupported_format"
| "worker_unavailable"
| "unknown";
export interface FeedbackPayload {
source: FeedbackSource;
surveyId?: FeedbackSurveyId;
promptVariant?: FeedbackPromptVariant;
sentiment?: FeedbackSentiment;
feedbackType?: FeedbackType;
message?: string;
contactOk?: boolean;
contactEmail?: string;
contactName?: string;
company?: string;
toolId?: string;
jobStatus?: "completed" | "failed";
installMethod?: FeedbackInstallMethod;
usageType?: FeedbackUsageType;
importantAreas?: FeedbackImportantArea[];
frictionArea?: FeedbackFrictionArea;
errorCategory?: FeedbackErrorCategory;
}
export interface FeedbackResponse {
ok: boolean;
accepted: boolean;
}
interface InstallFeedbackVisibilityOptions {
settings: Record<string, string>;
role: string | null;
analyticsConfigLoaded: boolean;
analyticsEnabled: boolean;
now?: number;
}
export function surveyIdForSource(source: FeedbackSource): FeedbackSurveyId {
switch (source) {
case "tool_result":
return "tool-result-v1";
case "failed_job":
return "failed-job-v1";
case "admin_installer":
return "admin-install-v1";
case "global":
return "global-feedback-v1";
}
}
export function promptVariantForSource(source: FeedbackSource): FeedbackPromptVariant {
switch (source) {
case "tool_result":
return "inline-v1";
case "failed_job":
return "failed-button-v1";
case "admin_installer":
return "settings-card-v1";
case "global":
return "nav-v1";
}
}
export function classifyFeedbackError(message: string | null | undefined): FeedbackErrorCategory {
const value = (message ?? "").toLowerCase();
if (!value) return "unknown";
if (value.includes("timed out") || value.includes("timeout")) return "timeout";
if (value.includes("upload") || value.includes("interrupted")) return "upload_error";
if (value.includes("validation") || value.includes("invalid") || value.includes("required")) {
return "validation_error";
}
if (value.includes("unsupported")) return "unsupported_format";
if (value.includes("worker") || value.includes("queue")) return "worker_unavailable";
return "processing_error";
}
export function shouldShowInstallFeedbackCard({
settings,
role,
analyticsConfigLoaded,
analyticsEnabled,
now = Date.now(),
}: InstallFeedbackVisibilityOptions): boolean {
if (!analyticsConfigLoaded || !analyticsEnabled || role !== "admin") return false;
if (settings["feedback.install.submittedAt"] || settings["feedback.install.dismissedAt"]) {
return false;
}
const snoozedUntil = settings["feedback.install.snoozedUntil"];
if (!snoozedUntil) return true;
const parsedSnooze = Date.parse(snoozedUntil);
return !Number.isFinite(parsedSnooze) || parsedSnooze <= now;
}
export async function submitFeedback(payload: FeedbackPayload): Promise<FeedbackResponse> {
return apiPost<FeedbackResponse>("/v1/feedback", payload);
}
@@ -49,6 +49,14 @@ export function PrivacyPolicyPage() {
<code className="text-xs bg-muted px-1 py-0.5 rounded">SNAPOTTER_ANALYTICS=off</code>,
which strips it from the bundle entirely. Everything works normally without it.
</p>
<p className="mt-3">
SnapOtter also includes optional feedback prompts. Feedback is sent only when you
submit it, and only when analytics is enabled. Feedback events go to PostHog as{" "}
<code className="text-xs bg-muted px-1 py-0.5 rounded">feedback_submitted</code>.
Contact name, email, and company are sent only if you check the contact permission
box. SnapOtter does not attach files, file names, upload paths, OCR/transcription
output, private document text, or raw error logs to feedback.
</p>
</section>
<section>
+47
View File
@@ -16,6 +16,7 @@ import {
Download,
FileImage,
Loader2,
MessageSquare,
Upload,
XCircle,
} from "lucide-react";
@@ -31,6 +32,7 @@ import { SideBySideComparison } from "@/components/common/side-by-side-compariso
import { ThumbnailStrip } from "@/components/common/thumbnail-strip";
import { ToolDropzone } from "@/components/common/tool-dropzone";
import { FeatureInstallPrompt } from "@/components/features/feature-install-prompt";
import { FeedbackDialog } from "@/components/feedback/feedback-dialog";
import { AppLayout } from "@/components/layout/app-layout";
import { CropCanvas } from "@/components/tools/crop-canvas";
import type { EraserCanvasRef } from "@/components/tools/eraser-canvas";
@@ -43,11 +45,13 @@ import { useMobile } from "@/hooks/use-mobile";
import { usePageTitle } from "@/hooks/use-page-title";
import { recordRecentTool } from "@/hooks/use-recent-tools";
import { formatFileSize } from "@/lib/download";
import { classifyFeedbackError } from "@/lib/feedback";
import { format } from "@/lib/format";
import { ICON_MAP } from "@/lib/icon-map";
import { MULTI_FILE_TOOLS } from "@/lib/tool-display-modes";
import { getToolName } from "@/lib/tool-i18n";
import { getToolRegistryEntry } from "@/lib/tool-registry";
import { useAnalyticsStore } from "@/stores/analytics-store";
import { useBase64Store } from "@/stores/base64-store";
import { useCollageStore } from "@/stores/collage-store";
import { useDuplicateStore } from "@/stores/duplicate-store";
@@ -322,11 +326,15 @@ export function ToolPage() {
[navigateNext, navigatePrev],
);
const [mobileSettingsOpen, setMobileSettingsOpen] = useState(false);
const [failedFeedbackOpen, setFailedFeedbackOpen] = useState(false);
const analyticsConfig = useAnalyticsStore((s) => s.config);
const feedbackEnabled = Boolean(analyticsConfig?.enabled);
const [previewTransform, setPreviewTransform] = useState<PreviewTransform | null>(null);
const [previewFilter, setPreviewFilter] = useState<string>("");
const [imageWrapperStyle, setImageWrapperStyle] = useState<React.CSSProperties | null>(null);
const [imageWrapperChildren, setImageWrapperChildren] = useState<React.ReactNode>(null);
const [bgPreview, setBgPreview] = useState<BgPreviewState | null>(null);
const failedFeedbackCategory = classifyFeedbackError(currentEntry?.error);
const [cropCrop, setCropCrop] = useState<Crop>({
unit: "%",
@@ -782,6 +790,16 @@ export function ToolPage() {
>
{t.toolPage.tryDifferentFile}
</button>
{feedbackEnabled && (
<button
type="button"
onClick={() => setFailedFeedbackOpen(true)}
className="px-4 py-2 rounded-md border border-border text-sm text-muted-foreground hover:bg-muted hover:text-foreground inline-flex items-center justify-center gap-2"
>
<MessageSquare className="h-3.5 w-3.5" />
{t.feedback.reportIssue}
</button>
)}
</div>
</div>
</div>
@@ -1101,6 +1119,17 @@ export function ToolPage() {
</Suspense>
</div>
{feedbackEnabled && currentEntry?.status === "failed" && (
<button
type="button"
onClick={() => setFailedFeedbackOpen(true)}
className="w-full py-2 rounded-lg border border-border text-muted-foreground hover:text-foreground hover:bg-muted text-xs font-medium flex items-center justify-center gap-2"
>
<MessageSquare className="h-3.5 w-3.5" />
{t.feedback.reportIssue}
</button>
)}
{/* Batch download — shown right after settings for easy access */}
{entries.length > 1 && hasProcessed && batchZipBlob && (
<button
@@ -1246,6 +1275,15 @@ export function ToolPage() {
>
<div className="settings-container space-y-3">{renderSettingsContent()}</div>
</BottomSheet>
<FeedbackDialog
open={failedFeedbackOpen}
source="failed_job"
toolId={tool?.id}
jobStatus="failed"
errorCategory={failedFeedbackCategory}
initialSentiment="issue"
onClose={() => setFailedFeedbackOpen(false)}
/>
</div>
</AppLayout>
);
@@ -1339,6 +1377,15 @@ export function ToolPage() {
/>
)}
</section>
<FeedbackDialog
open={failedFeedbackOpen}
source="failed_job"
toolId={tool?.id}
jobStatus="failed"
errorCategory={failedFeedbackCategory}
initialSentiment="issue"
onClose={() => setFailedFeedbackOpen(false)}
/>
</div>
</AppLayout>
);