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
+4
View File
@@ -50,6 +50,7 @@ import { configRoutes } from "./routes/config.js";
import { docsRoutes } from "./routes/docs.js";
import { registerEnterpriseRoutes } from "./routes/enterprise/index.js";
import { registerFeatureRoutes } from "./routes/features.js";
import { feedbackRoutes } from "./routes/feedback.js";
import { registerFetchUrlsRoute } from "./routes/fetch-urls.js";
import { filePreviewRoutes } from "./routes/file-preview.js";
import { fileRoutes } from "./routes/files.js";
@@ -438,6 +439,9 @@ await settingsRoutes(app);
// Analytics config and consent routes
await analyticsRoutes(app);
// Explicit customer feedback capture (respects the analytics gate)
await feedbackRoutes(app);
// Feature management routes (AI feature bundle install/uninstall)
await registerFeatureRoutes(app);
+89 -1
View File
@@ -1,4 +1,4 @@
import { ANALYTICS_BAKED } from "@snapotter/shared";
import { ANALYTICS_BAKED, ANALYTICS_EVENTS, APP_VERSION } from "@snapotter/shared";
import { eq } from "drizzle-orm";
import type { PostHog } from "posthog-node";
import { db, schema } from "../db/index.js";
@@ -7,6 +7,43 @@ import { analyticsEnabled, bakedEnabled } from "./analytics-gate.js";
let posthogClient: PostHog | null = null;
export interface FeedbackEventProperties {
source: "global" | "tool_result" | "failed_job" | "admin_installer";
survey_id?: "global-feedback-v1" | "tool-result-v1" | "failed-job-v1" | "admin-install-v1";
prompt_variant?: string;
sentiment?: "great" | "okay" | "issue" | "missing" | "bug" | "idea" | "other";
feedback_type?: "bug" | "feature_request" | "confusing_ux" | "performance" | "other";
message?: string;
contact_ok: boolean;
contact_email?: string;
contact_name?: string;
company?: string;
tool_id?: string;
job_status?: "completed" | "failed";
install_method?: "docker" | "docker_compose" | "source" | "cloud" | "other";
usage_type?: "personal" | "team_internal" | "business_workflow" | "education" | "evaluating";
important_areas?: string[];
friction_area?:
| "smooth"
| "docker"
| "environment_variables"
| "auth"
| "storage"
| "workers"
| "ai_tools"
| "docs"
| "performance"
| "other";
error_category?:
| "validation_error"
| "upload_error"
| "processing_error"
| "timeout"
| "unsupported_format"
| "worker_unavailable"
| "unknown";
}
export async function initAnalytics(): Promise<void> {
if (!bakedEnabled()) return;
@@ -68,3 +105,54 @@ export async function trackEvent(
// analytics must never throw
}
}
function cleanFeedbackProperties(properties: FeedbackEventProperties): Record<string, unknown> {
const out: Record<string, unknown> = {
feedback_version: 1,
app_version: APP_VERSION,
source: properties.source,
contact_ok: properties.contact_ok,
};
const copyString = (from: keyof FeedbackEventProperties, to = from) => {
const value = properties[from];
if (typeof value === "string" && value.length > 0) out[to] = value;
};
copyString("survey_id");
copyString("prompt_variant");
copyString("sentiment");
copyString("feedback_type");
copyString("message");
copyString("contact_email");
copyString("contact_name");
copyString("company");
copyString("tool_id");
copyString("job_status");
copyString("install_method");
copyString("usage_type");
copyString("friction_area");
copyString("error_category");
if (properties.important_areas?.length) {
out.important_areas = properties.important_areas;
}
return out;
}
export async function captureFeedback(
properties: FeedbackEventProperties,
distinctId?: string,
): Promise<void> {
try {
if (!analyticsEnabled() || !posthogClient) return;
posthogClient.capture({
distinctId: distinctId ?? (await getInstanceId()),
event: ANALYTICS_EVENTS.FEEDBACK_SUBMITTED,
properties: cleanFeedbackProperties(properties),
});
} catch {
// feedback capture must never throw
}
}
+173
View File
@@ -0,0 +1,173 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { captureFeedback, 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"] as const;
const SURVEY_ID_VALUES = [
"global-feedback-v1",
"tool-result-v1",
"failed-job-v1",
"admin-install-v1",
] as const;
const SENTIMENT_VALUES = ["great", "okay", "issue", "missing", "bug", "idea", "other"] as const;
const FEEDBACK_TYPE_VALUES = [
"bug",
"feature_request",
"confusing_ux",
"performance",
"other",
] as const;
const INSTALL_METHOD_VALUES = ["docker", "docker_compose", "source", "cloud", "other"] as const;
const USAGE_TYPE_VALUES = [
"personal",
"team_internal",
"business_workflow",
"education",
"evaluating",
] as const;
const IMPORTANT_AREA_VALUES = [
"images",
"pdf_docs",
"video_audio",
"batch_workflows",
"ai_tools",
] as const;
const FRICTION_AREA_VALUES = [
"smooth",
"docker",
"environment_variables",
"auth",
"storage",
"workers",
"ai_tools",
"docs",
"performance",
"other",
] as const;
const ERROR_CATEGORY_VALUES = [
"validation_error",
"upload_error",
"processing_error",
"timeout",
"unsupported_format",
"worker_unavailable",
"unknown",
] as const;
const toolIdSchema = z
.string()
.trim()
.regex(/^[a-z0-9-]{1,80}$/);
function stripUnsafeControlCharacters(value: string): string {
let out = "";
for (const char of value) {
const code = char.charCodeAt(0);
if (char === "\n" || char === "\t" || (code >= 32 && code !== 127)) {
out += char;
}
}
return out;
}
const optionalText = (max: number) =>
z.string().trim().max(max).transform(stripUnsafeControlCharacters).optional();
const feedbackBodySchema = z
.object({
source: z.enum(SOURCE_VALUES),
surveyId: z.enum(SURVEY_ID_VALUES).optional(),
promptVariant: z
.string()
.trim()
.max(80)
.regex(/^[a-z0-9_-]+$/)
.optional(),
sentiment: z.enum(SENTIMENT_VALUES).optional(),
feedbackType: z.enum(FEEDBACK_TYPE_VALUES).optional(),
message: optionalText(2000),
contactOk: z.boolean().default(false),
contactEmail: z.union([z.string().trim().email().max(320), z.literal("")]).optional(),
contactName: optionalText(120),
company: optionalText(160),
toolId: toolIdSchema.optional(),
jobStatus: z.enum(["completed", "failed"]).optional(),
installMethod: z.enum(INSTALL_METHOD_VALUES).optional(),
usageType: z.enum(USAGE_TYPE_VALUES).optional(),
importantAreas: z.array(z.enum(IMPORTANT_AREA_VALUES)).max(5).optional(),
frictionArea: z.enum(FRICTION_AREA_VALUES).optional(),
errorCategory: z.enum(ERROR_CATEGORY_VALUES).optional(),
})
.superRefine((value, ctx) => {
const hasText = Boolean(value.message?.trim());
const hasChoice = Boolean(
value.sentiment || value.feedbackType || value.installMethod || value.usageType,
);
if (!hasText && !hasChoice) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Feedback must include a rating, type, or message.",
path: ["message"],
});
}
});
function toPostHogProperties(body: z.infer<typeof feedbackBodySchema>): FeedbackEventProperties {
return {
source: body.source,
survey_id: body.surveyId,
prompt_variant: body.promptVariant,
sentiment: body.sentiment,
feedback_type: body.feedbackType,
message: body.message || undefined,
contact_ok: body.contactOk,
contact_email: body.contactOk ? body.contactEmail || undefined : undefined,
contact_name: body.contactOk ? body.contactName || undefined : undefined,
company: body.contactOk ? body.company || undefined : undefined,
tool_id: body.toolId,
job_status: body.jobStatus,
install_method: body.installMethod,
usage_type: body.usageType,
important_areas: body.importantAreas,
friction_area: body.frictionArea,
error_category: body.errorCategory,
};
}
export async function feedbackRoutes(app: FastifyInstance): Promise<void> {
app.post(
"/api/v1/feedback",
{ config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
async (request: FastifyRequest, reply: FastifyReply) => {
const user = requireAuth(request, reply);
if (!user) return;
const parsed = feedbackBodySchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({
error: "Invalid feedback payload",
code: "VALIDATION_ERROR",
details: parsed.error.issues.map((issue) => ({
path: issue.path.join("."),
message: issue.message,
})),
});
}
if (!analyticsEnabled()) {
return reply.send({ ok: true, accepted: false });
}
await captureFeedback(
toPostHogProperties(parsed.data),
request.headers["x-posthog-distinct-id"] as string | undefined,
);
return reply.send({ ok: true, accepted: true });
},
);
app.log.info("Feedback routes registered");
}
+1
View File
@@ -534,6 +534,7 @@ Query parameters:
| Method | Path | Access | Description |
|--------|------|--------|-------------|
| `GET` | `/api/v1/config/analytics` | Public | Get the effective analytics configuration (PostHog key, Sentry DSN, sample rate). Keys, DSN, and instance ID are blank when analytics is off, either from the compile-time bake or the instance `analyticsEnabled` setting. |
| `POST` | `/api/v1/feedback` | Auth | Submit explicit user feedback to the configured PostHog project as `feedback_submitted`. The route respects the analytics gate, rate-limits submissions, strips contact fields unless `contactOk` is true, and never accepts file contents, file names, upload paths, or raw private error text. When analytics is disabled, it returns `{ "ok": true, "accepted": false }`. |
| `PUT` | `/api/v1/settings` | Admin (`settings:write`) | Set the instance-wide opt-out. Send a JSON body `{ "analyticsEnabled": "false" }` to turn analytics off for everyone, or `"true"` to turn it back on. |
## Features / AI Bundles
+1 -1
View File
@@ -4,7 +4,7 @@ description: Security hardening guide for SnapOtter. Container security, network
# Security & Hardening
SnapOtter processes files entirely on your infrastructure. It sends anonymous, content-free product analytics and crash reports by default to help improve the project. It never sends your files, file names, file contents, OCR output, image metadata, or document text. An administrator can turn it off in one click under Settings > System > Privacy, no rebuild required. File processing always stays inside your container.
SnapOtter processes files entirely on your infrastructure. It sends anonymous, content-free product analytics and crash reports by default to help improve the project. It never sends your files, file names, file contents, OCR output, image metadata, or document text. Optional feedback is sent only after a user submits it, only when analytics is enabled, and contact fields are included only with explicit contact consent. An administrator can turn analytics and feedback capture off in one click under Settings > System > Privacy, no rebuild required. File processing always stays inside your container.
The container runs as a dedicated non-root user (`snapotter`) with all Linux capabilities dropped except the minimum required set. For the full vulnerability disclosure policy and security architecture, see [SECURITY.md](https://github.com/snapotter-hq/SnapOtter/blob/main/SECURITY.md) on GitHub.
+6
View File
@@ -99,6 +99,12 @@ const breadcrumbJsonLd = {
<code class="text-xs">SNAPOTTER_ANALYTICS=off</code> build arg as a compile-time
hard-off.
</li>
<li>
Optional in-app feedback is sent only when a user submits it, and only when analytics
is enabled. Feedback appears in PostHog as <code class="text-xs">feedback_submitted</code>.
Contact details are included only after explicit contact consent. Files, file names,
upload paths, private outputs, and raw error logs are not included.
</li>
</ul>
</section>
@@ -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>
);