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:
@@ -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