mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: add PostHog customer feedback
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user