mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: merge 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>
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ export const ANALYTICS_EVENTS = {
|
||||
AI_BUNDLE_ACTION: "ai_bundle_action",
|
||||
AI_BUNDLE_PROMPTED: "ai_bundle_prompted",
|
||||
BATCH_PROCESSED: "batch_processed",
|
||||
FEEDBACK_SUBMITTED: "feedback_submitted",
|
||||
} as const;
|
||||
|
||||
export type AnalyticsEvent = (typeof ANALYTICS_EVENTS)[keyof typeof ANALYTICS_EVENTS];
|
||||
|
||||
@@ -40,6 +40,96 @@ export const ar: TranslationKeys = {
|
||||
pageNotFound: "الصفحة غير موجودة",
|
||||
pageNotFoundDescription: "الصفحة التي تبحث عنها غير موجودة أو تم نقلها.",
|
||||
},
|
||||
feedback: {
|
||||
navLabel: "Share feedback",
|
||||
dialogTitle: "Share feedback",
|
||||
toolDialogTitle: "How did this tool work?",
|
||||
failedDialogTitle: "Report a tool issue",
|
||||
adminDialogTitle: "How was setup?",
|
||||
closeLabel: "Close feedback dialog",
|
||||
privacyNote:
|
||||
"Feedback is sent only when you submit it. SnapOtter does not include your files, file contents, file names, or private outputs.",
|
||||
typeLabel: "Feedback type",
|
||||
sentimentLabel: "Quick rating",
|
||||
messageLabel: "Feedback",
|
||||
messagePlaceholder: "Tell us what worked, what broke, or what would make SnapOtter better.",
|
||||
improveFirstLabel: "What should we improve first?",
|
||||
improveFirstPlaceholder: "Tell us what made setup harder than it should be.",
|
||||
contactOkLabel: "You can contact me about this feedback.",
|
||||
emailPlaceholder: "Email (optional)",
|
||||
namePlaceholder: "Name (optional)",
|
||||
companyPlaceholder: "Company or org (optional)",
|
||||
submit: "Send feedback",
|
||||
submitting: "Sending...",
|
||||
submitFailed: "Feedback could not be sent. Please try again.",
|
||||
thanksTitle: "Thanks for the feedback.",
|
||||
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
||||
quickThanks: "Thanks for the signal.",
|
||||
reportIssue: "Report issue",
|
||||
toolPromptTitle: "How did this tool work?",
|
||||
toolPromptDescription: "One quick signal helps us improve SnapOtter.",
|
||||
dismissPrompt: "Dismiss feedback prompt",
|
||||
workedWell: "Worked well",
|
||||
dontAskAgain: "Don't ask again",
|
||||
shareFeedback: "Share feedback",
|
||||
remindLater: "Remind me later",
|
||||
adminCardTitle: "How was setup?",
|
||||
adminCardDescription:
|
||||
"A quick note from admins helps us improve Docker, source installs, and docs.",
|
||||
installMethodLabel: "Install method",
|
||||
usageTypeLabel: "Use case",
|
||||
frictionAreaLabel: "Hardest setup area",
|
||||
importantAreasLabel: "Most important areas",
|
||||
sentiments: {
|
||||
great: "Great",
|
||||
okay: "Okay",
|
||||
issue: "Had an issue",
|
||||
missing: "Missing something",
|
||||
bug: "Bug",
|
||||
idea: "Idea",
|
||||
other: "Other",
|
||||
},
|
||||
types: {
|
||||
bug: "Bug",
|
||||
feature_request: "Feature request",
|
||||
confusing_ux: "Confusing UX",
|
||||
performance: "Performance",
|
||||
other: "Other",
|
||||
},
|
||||
installMethods: {
|
||||
docker: "Docker",
|
||||
docker_compose: "Docker Compose",
|
||||
source: "Built from source",
|
||||
cloud: "Cloud/demo",
|
||||
other: "Other",
|
||||
},
|
||||
usageTypes: {
|
||||
personal: "Personal",
|
||||
team_internal: "Team/internal",
|
||||
business_workflow: "Business workflow",
|
||||
education: "Education",
|
||||
evaluating: "Evaluating",
|
||||
},
|
||||
frictionAreas: {
|
||||
smooth: "Nothing, setup was smooth",
|
||||
docker: "Docker/container setup",
|
||||
environment_variables: "Environment variables",
|
||||
auth: "Authentication/SSO",
|
||||
storage: "Storage/uploads",
|
||||
workers: "Workers/queues",
|
||||
ai_tools: "AI tools",
|
||||
docs: "Documentation",
|
||||
performance: "Performance/resources",
|
||||
other: "Other",
|
||||
},
|
||||
importantAreas: {
|
||||
images: "Images",
|
||||
pdf_docs: "PDF/docs",
|
||||
video_audio: "Video/audio",
|
||||
batch_workflows: "Batch workflows",
|
||||
ai_tools: "AI tools",
|
||||
},
|
||||
},
|
||||
categories: {
|
||||
essentials: "الأساسيات",
|
||||
adjustments: "التعديلات",
|
||||
|
||||
@@ -40,6 +40,96 @@ export const de: TranslationKeys = {
|
||||
pageNotFound: "Seite nicht gefunden",
|
||||
pageNotFoundDescription: "Die gesuchte Seite existiert nicht oder wurde verschoben.",
|
||||
},
|
||||
feedback: {
|
||||
navLabel: "Share feedback",
|
||||
dialogTitle: "Share feedback",
|
||||
toolDialogTitle: "How did this tool work?",
|
||||
failedDialogTitle: "Report a tool issue",
|
||||
adminDialogTitle: "How was setup?",
|
||||
closeLabel: "Close feedback dialog",
|
||||
privacyNote:
|
||||
"Feedback is sent only when you submit it. SnapOtter does not include your files, file contents, file names, or private outputs.",
|
||||
typeLabel: "Feedback type",
|
||||
sentimentLabel: "Quick rating",
|
||||
messageLabel: "Feedback",
|
||||
messagePlaceholder: "Tell us what worked, what broke, or what would make SnapOtter better.",
|
||||
improveFirstLabel: "What should we improve first?",
|
||||
improveFirstPlaceholder: "Tell us what made setup harder than it should be.",
|
||||
contactOkLabel: "You can contact me about this feedback.",
|
||||
emailPlaceholder: "Email (optional)",
|
||||
namePlaceholder: "Name (optional)",
|
||||
companyPlaceholder: "Company or org (optional)",
|
||||
submit: "Send feedback",
|
||||
submitting: "Sending...",
|
||||
submitFailed: "Feedback could not be sent. Please try again.",
|
||||
thanksTitle: "Thanks for the feedback.",
|
||||
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
||||
quickThanks: "Thanks for the signal.",
|
||||
reportIssue: "Report issue",
|
||||
toolPromptTitle: "How did this tool work?",
|
||||
toolPromptDescription: "One quick signal helps us improve SnapOtter.",
|
||||
dismissPrompt: "Dismiss feedback prompt",
|
||||
workedWell: "Worked well",
|
||||
dontAskAgain: "Don't ask again",
|
||||
shareFeedback: "Share feedback",
|
||||
remindLater: "Remind me later",
|
||||
adminCardTitle: "How was setup?",
|
||||
adminCardDescription:
|
||||
"A quick note from admins helps us improve Docker, source installs, and docs.",
|
||||
installMethodLabel: "Install method",
|
||||
usageTypeLabel: "Use case",
|
||||
frictionAreaLabel: "Hardest setup area",
|
||||
importantAreasLabel: "Most important areas",
|
||||
sentiments: {
|
||||
great: "Great",
|
||||
okay: "Okay",
|
||||
issue: "Had an issue",
|
||||
missing: "Missing something",
|
||||
bug: "Bug",
|
||||
idea: "Idea",
|
||||
other: "Other",
|
||||
},
|
||||
types: {
|
||||
bug: "Bug",
|
||||
feature_request: "Feature request",
|
||||
confusing_ux: "Confusing UX",
|
||||
performance: "Performance",
|
||||
other: "Other",
|
||||
},
|
||||
installMethods: {
|
||||
docker: "Docker",
|
||||
docker_compose: "Docker Compose",
|
||||
source: "Built from source",
|
||||
cloud: "Cloud/demo",
|
||||
other: "Other",
|
||||
},
|
||||
usageTypes: {
|
||||
personal: "Personal",
|
||||
team_internal: "Team/internal",
|
||||
business_workflow: "Business workflow",
|
||||
education: "Education",
|
||||
evaluating: "Evaluating",
|
||||
},
|
||||
frictionAreas: {
|
||||
smooth: "Nothing, setup was smooth",
|
||||
docker: "Docker/container setup",
|
||||
environment_variables: "Environment variables",
|
||||
auth: "Authentication/SSO",
|
||||
storage: "Storage/uploads",
|
||||
workers: "Workers/queues",
|
||||
ai_tools: "AI tools",
|
||||
docs: "Documentation",
|
||||
performance: "Performance/resources",
|
||||
other: "Other",
|
||||
},
|
||||
importantAreas: {
|
||||
images: "Images",
|
||||
pdf_docs: "PDF/docs",
|
||||
video_audio: "Video/audio",
|
||||
batch_workflows: "Batch workflows",
|
||||
ai_tools: "AI tools",
|
||||
},
|
||||
},
|
||||
categories: {
|
||||
essentials: "Grundlagen",
|
||||
adjustments: "Anpassungen",
|
||||
|
||||
@@ -38,6 +38,96 @@ export const en = {
|
||||
pageNotFound: "Page not found",
|
||||
pageNotFoundDescription: "The page you are looking for does not exist or has been moved.",
|
||||
},
|
||||
feedback: {
|
||||
navLabel: "Share feedback",
|
||||
dialogTitle: "Share feedback",
|
||||
toolDialogTitle: "How did this tool work?",
|
||||
failedDialogTitle: "Report a tool issue",
|
||||
adminDialogTitle: "How was setup?",
|
||||
closeLabel: "Close feedback dialog",
|
||||
privacyNote:
|
||||
"Feedback is sent only when you submit it. SnapOtter does not include your files, file contents, file names, or private outputs.",
|
||||
typeLabel: "Feedback type",
|
||||
sentimentLabel: "Quick rating",
|
||||
messageLabel: "Feedback",
|
||||
messagePlaceholder: "Tell us what worked, what broke, or what would make SnapOtter better.",
|
||||
improveFirstLabel: "What should we improve first?",
|
||||
improveFirstPlaceholder: "Tell us what made setup harder than it should be.",
|
||||
contactOkLabel: "You can contact me about this feedback.",
|
||||
emailPlaceholder: "Email (optional)",
|
||||
namePlaceholder: "Name (optional)",
|
||||
companyPlaceholder: "Company or org (optional)",
|
||||
submit: "Send feedback",
|
||||
submitting: "Sending...",
|
||||
submitFailed: "Feedback could not be sent. Please try again.",
|
||||
thanksTitle: "Thanks for the feedback.",
|
||||
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
||||
quickThanks: "Thanks for the signal.",
|
||||
reportIssue: "Report issue",
|
||||
toolPromptTitle: "How did this tool work?",
|
||||
toolPromptDescription: "One quick signal helps us improve SnapOtter.",
|
||||
dismissPrompt: "Dismiss feedback prompt",
|
||||
workedWell: "Worked well",
|
||||
dontAskAgain: "Don't ask again",
|
||||
shareFeedback: "Share feedback",
|
||||
remindLater: "Remind me later",
|
||||
adminCardTitle: "How was setup?",
|
||||
adminCardDescription:
|
||||
"A quick note from admins helps us improve Docker, source installs, and docs.",
|
||||
installMethodLabel: "Install method",
|
||||
usageTypeLabel: "Use case",
|
||||
frictionAreaLabel: "Hardest setup area",
|
||||
importantAreasLabel: "Most important areas",
|
||||
sentiments: {
|
||||
great: "Great",
|
||||
okay: "Okay",
|
||||
issue: "Had an issue",
|
||||
missing: "Missing something",
|
||||
bug: "Bug",
|
||||
idea: "Idea",
|
||||
other: "Other",
|
||||
},
|
||||
types: {
|
||||
bug: "Bug",
|
||||
feature_request: "Feature request",
|
||||
confusing_ux: "Confusing UX",
|
||||
performance: "Performance",
|
||||
other: "Other",
|
||||
},
|
||||
installMethods: {
|
||||
docker: "Docker",
|
||||
docker_compose: "Docker Compose",
|
||||
source: "Built from source",
|
||||
cloud: "Cloud/demo",
|
||||
other: "Other",
|
||||
},
|
||||
usageTypes: {
|
||||
personal: "Personal",
|
||||
team_internal: "Team/internal",
|
||||
business_workflow: "Business workflow",
|
||||
education: "Education",
|
||||
evaluating: "Evaluating",
|
||||
},
|
||||
frictionAreas: {
|
||||
smooth: "Nothing, setup was smooth",
|
||||
docker: "Docker/container setup",
|
||||
environment_variables: "Environment variables",
|
||||
auth: "Authentication/SSO",
|
||||
storage: "Storage/uploads",
|
||||
workers: "Workers/queues",
|
||||
ai_tools: "AI tools",
|
||||
docs: "Documentation",
|
||||
performance: "Performance/resources",
|
||||
other: "Other",
|
||||
},
|
||||
importantAreas: {
|
||||
images: "Images",
|
||||
pdf_docs: "PDF/docs",
|
||||
video_audio: "Video/audio",
|
||||
batch_workflows: "Batch workflows",
|
||||
ai_tools: "AI tools",
|
||||
},
|
||||
},
|
||||
categories: {
|
||||
// Image
|
||||
essentials: "Essentials",
|
||||
|
||||
@@ -40,6 +40,96 @@ export const es: TranslationKeys = {
|
||||
pageNotFound: "Página no encontrada",
|
||||
pageNotFoundDescription: "La página que buscas no existe o ha sido movida.",
|
||||
},
|
||||
feedback: {
|
||||
navLabel: "Share feedback",
|
||||
dialogTitle: "Share feedback",
|
||||
toolDialogTitle: "How did this tool work?",
|
||||
failedDialogTitle: "Report a tool issue",
|
||||
adminDialogTitle: "How was setup?",
|
||||
closeLabel: "Close feedback dialog",
|
||||
privacyNote:
|
||||
"Feedback is sent only when you submit it. SnapOtter does not include your files, file contents, file names, or private outputs.",
|
||||
typeLabel: "Feedback type",
|
||||
sentimentLabel: "Quick rating",
|
||||
messageLabel: "Feedback",
|
||||
messagePlaceholder: "Tell us what worked, what broke, or what would make SnapOtter better.",
|
||||
improveFirstLabel: "What should we improve first?",
|
||||
improveFirstPlaceholder: "Tell us what made setup harder than it should be.",
|
||||
contactOkLabel: "You can contact me about this feedback.",
|
||||
emailPlaceholder: "Email (optional)",
|
||||
namePlaceholder: "Name (optional)",
|
||||
companyPlaceholder: "Company or org (optional)",
|
||||
submit: "Send feedback",
|
||||
submitting: "Sending...",
|
||||
submitFailed: "Feedback could not be sent. Please try again.",
|
||||
thanksTitle: "Thanks for the feedback.",
|
||||
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
||||
quickThanks: "Thanks for the signal.",
|
||||
reportIssue: "Report issue",
|
||||
toolPromptTitle: "How did this tool work?",
|
||||
toolPromptDescription: "One quick signal helps us improve SnapOtter.",
|
||||
dismissPrompt: "Dismiss feedback prompt",
|
||||
workedWell: "Worked well",
|
||||
dontAskAgain: "Don't ask again",
|
||||
shareFeedback: "Share feedback",
|
||||
remindLater: "Remind me later",
|
||||
adminCardTitle: "How was setup?",
|
||||
adminCardDescription:
|
||||
"A quick note from admins helps us improve Docker, source installs, and docs.",
|
||||
installMethodLabel: "Install method",
|
||||
usageTypeLabel: "Use case",
|
||||
frictionAreaLabel: "Hardest setup area",
|
||||
importantAreasLabel: "Most important areas",
|
||||
sentiments: {
|
||||
great: "Great",
|
||||
okay: "Okay",
|
||||
issue: "Had an issue",
|
||||
missing: "Missing something",
|
||||
bug: "Bug",
|
||||
idea: "Idea",
|
||||
other: "Other",
|
||||
},
|
||||
types: {
|
||||
bug: "Bug",
|
||||
feature_request: "Feature request",
|
||||
confusing_ux: "Confusing UX",
|
||||
performance: "Performance",
|
||||
other: "Other",
|
||||
},
|
||||
installMethods: {
|
||||
docker: "Docker",
|
||||
docker_compose: "Docker Compose",
|
||||
source: "Built from source",
|
||||
cloud: "Cloud/demo",
|
||||
other: "Other",
|
||||
},
|
||||
usageTypes: {
|
||||
personal: "Personal",
|
||||
team_internal: "Team/internal",
|
||||
business_workflow: "Business workflow",
|
||||
education: "Education",
|
||||
evaluating: "Evaluating",
|
||||
},
|
||||
frictionAreas: {
|
||||
smooth: "Nothing, setup was smooth",
|
||||
docker: "Docker/container setup",
|
||||
environment_variables: "Environment variables",
|
||||
auth: "Authentication/SSO",
|
||||
storage: "Storage/uploads",
|
||||
workers: "Workers/queues",
|
||||
ai_tools: "AI tools",
|
||||
docs: "Documentation",
|
||||
performance: "Performance/resources",
|
||||
other: "Other",
|
||||
},
|
||||
importantAreas: {
|
||||
images: "Images",
|
||||
pdf_docs: "PDF/docs",
|
||||
video_audio: "Video/audio",
|
||||
batch_workflows: "Batch workflows",
|
||||
ai_tools: "AI tools",
|
||||
},
|
||||
},
|
||||
categories: {
|
||||
essentials: "Esenciales",
|
||||
adjustments: "Ajustes",
|
||||
|
||||
@@ -40,6 +40,96 @@ export const fr: TranslationKeys = {
|
||||
pageNotFound: "Page introuvable",
|
||||
pageNotFoundDescription: "La page que vous recherchez n’existe pas ou a été déplacée.",
|
||||
},
|
||||
feedback: {
|
||||
navLabel: "Share feedback",
|
||||
dialogTitle: "Share feedback",
|
||||
toolDialogTitle: "How did this tool work?",
|
||||
failedDialogTitle: "Report a tool issue",
|
||||
adminDialogTitle: "How was setup?",
|
||||
closeLabel: "Close feedback dialog",
|
||||
privacyNote:
|
||||
"Feedback is sent only when you submit it. SnapOtter does not include your files, file contents, file names, or private outputs.",
|
||||
typeLabel: "Feedback type",
|
||||
sentimentLabel: "Quick rating",
|
||||
messageLabel: "Feedback",
|
||||
messagePlaceholder: "Tell us what worked, what broke, or what would make SnapOtter better.",
|
||||
improveFirstLabel: "What should we improve first?",
|
||||
improveFirstPlaceholder: "Tell us what made setup harder than it should be.",
|
||||
contactOkLabel: "You can contact me about this feedback.",
|
||||
emailPlaceholder: "Email (optional)",
|
||||
namePlaceholder: "Name (optional)",
|
||||
companyPlaceholder: "Company or org (optional)",
|
||||
submit: "Send feedback",
|
||||
submitting: "Sending...",
|
||||
submitFailed: "Feedback could not be sent. Please try again.",
|
||||
thanksTitle: "Thanks for the feedback.",
|
||||
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
||||
quickThanks: "Thanks for the signal.",
|
||||
reportIssue: "Report issue",
|
||||
toolPromptTitle: "How did this tool work?",
|
||||
toolPromptDescription: "One quick signal helps us improve SnapOtter.",
|
||||
dismissPrompt: "Dismiss feedback prompt",
|
||||
workedWell: "Worked well",
|
||||
dontAskAgain: "Don't ask again",
|
||||
shareFeedback: "Share feedback",
|
||||
remindLater: "Remind me later",
|
||||
adminCardTitle: "How was setup?",
|
||||
adminCardDescription:
|
||||
"A quick note from admins helps us improve Docker, source installs, and docs.",
|
||||
installMethodLabel: "Install method",
|
||||
usageTypeLabel: "Use case",
|
||||
frictionAreaLabel: "Hardest setup area",
|
||||
importantAreasLabel: "Most important areas",
|
||||
sentiments: {
|
||||
great: "Great",
|
||||
okay: "Okay",
|
||||
issue: "Had an issue",
|
||||
missing: "Missing something",
|
||||
bug: "Bug",
|
||||
idea: "Idea",
|
||||
other: "Other",
|
||||
},
|
||||
types: {
|
||||
bug: "Bug",
|
||||
feature_request: "Feature request",
|
||||
confusing_ux: "Confusing UX",
|
||||
performance: "Performance",
|
||||
other: "Other",
|
||||
},
|
||||
installMethods: {
|
||||
docker: "Docker",
|
||||
docker_compose: "Docker Compose",
|
||||
source: "Built from source",
|
||||
cloud: "Cloud/demo",
|
||||
other: "Other",
|
||||
},
|
||||
usageTypes: {
|
||||
personal: "Personal",
|
||||
team_internal: "Team/internal",
|
||||
business_workflow: "Business workflow",
|
||||
education: "Education",
|
||||
evaluating: "Evaluating",
|
||||
},
|
||||
frictionAreas: {
|
||||
smooth: "Nothing, setup was smooth",
|
||||
docker: "Docker/container setup",
|
||||
environment_variables: "Environment variables",
|
||||
auth: "Authentication/SSO",
|
||||
storage: "Storage/uploads",
|
||||
workers: "Workers/queues",
|
||||
ai_tools: "AI tools",
|
||||
docs: "Documentation",
|
||||
performance: "Performance/resources",
|
||||
other: "Other",
|
||||
},
|
||||
importantAreas: {
|
||||
images: "Images",
|
||||
pdf_docs: "PDF/docs",
|
||||
video_audio: "Video/audio",
|
||||
batch_workflows: "Batch workflows",
|
||||
ai_tools: "AI tools",
|
||||
},
|
||||
},
|
||||
categories: {
|
||||
essentials: "Essentiels",
|
||||
adjustments: "Réglages",
|
||||
|
||||
@@ -40,6 +40,96 @@ export const hi: TranslationKeys = {
|
||||
pageNotFound: "पेज नहीं मिला",
|
||||
pageNotFoundDescription: "आप जो पेज ढूँढ रहे हैं वह मौजूद नहीं है या स्थानांतरित कर दिया गया है।",
|
||||
},
|
||||
feedback: {
|
||||
navLabel: "Share feedback",
|
||||
dialogTitle: "Share feedback",
|
||||
toolDialogTitle: "How did this tool work?",
|
||||
failedDialogTitle: "Report a tool issue",
|
||||
adminDialogTitle: "How was setup?",
|
||||
closeLabel: "Close feedback dialog",
|
||||
privacyNote:
|
||||
"Feedback is sent only when you submit it. SnapOtter does not include your files, file contents, file names, or private outputs.",
|
||||
typeLabel: "Feedback type",
|
||||
sentimentLabel: "Quick rating",
|
||||
messageLabel: "Feedback",
|
||||
messagePlaceholder: "Tell us what worked, what broke, or what would make SnapOtter better.",
|
||||
improveFirstLabel: "What should we improve first?",
|
||||
improveFirstPlaceholder: "Tell us what made setup harder than it should be.",
|
||||
contactOkLabel: "You can contact me about this feedback.",
|
||||
emailPlaceholder: "Email (optional)",
|
||||
namePlaceholder: "Name (optional)",
|
||||
companyPlaceholder: "Company or org (optional)",
|
||||
submit: "Send feedback",
|
||||
submitting: "Sending...",
|
||||
submitFailed: "Feedback could not be sent. Please try again.",
|
||||
thanksTitle: "Thanks for the feedback.",
|
||||
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
||||
quickThanks: "Thanks for the signal.",
|
||||
reportIssue: "Report issue",
|
||||
toolPromptTitle: "How did this tool work?",
|
||||
toolPromptDescription: "One quick signal helps us improve SnapOtter.",
|
||||
dismissPrompt: "Dismiss feedback prompt",
|
||||
workedWell: "Worked well",
|
||||
dontAskAgain: "Don't ask again",
|
||||
shareFeedback: "Share feedback",
|
||||
remindLater: "Remind me later",
|
||||
adminCardTitle: "How was setup?",
|
||||
adminCardDescription:
|
||||
"A quick note from admins helps us improve Docker, source installs, and docs.",
|
||||
installMethodLabel: "Install method",
|
||||
usageTypeLabel: "Use case",
|
||||
frictionAreaLabel: "Hardest setup area",
|
||||
importantAreasLabel: "Most important areas",
|
||||
sentiments: {
|
||||
great: "Great",
|
||||
okay: "Okay",
|
||||
issue: "Had an issue",
|
||||
missing: "Missing something",
|
||||
bug: "Bug",
|
||||
idea: "Idea",
|
||||
other: "Other",
|
||||
},
|
||||
types: {
|
||||
bug: "Bug",
|
||||
feature_request: "Feature request",
|
||||
confusing_ux: "Confusing UX",
|
||||
performance: "Performance",
|
||||
other: "Other",
|
||||
},
|
||||
installMethods: {
|
||||
docker: "Docker",
|
||||
docker_compose: "Docker Compose",
|
||||
source: "Built from source",
|
||||
cloud: "Cloud/demo",
|
||||
other: "Other",
|
||||
},
|
||||
usageTypes: {
|
||||
personal: "Personal",
|
||||
team_internal: "Team/internal",
|
||||
business_workflow: "Business workflow",
|
||||
education: "Education",
|
||||
evaluating: "Evaluating",
|
||||
},
|
||||
frictionAreas: {
|
||||
smooth: "Nothing, setup was smooth",
|
||||
docker: "Docker/container setup",
|
||||
environment_variables: "Environment variables",
|
||||
auth: "Authentication/SSO",
|
||||
storage: "Storage/uploads",
|
||||
workers: "Workers/queues",
|
||||
ai_tools: "AI tools",
|
||||
docs: "Documentation",
|
||||
performance: "Performance/resources",
|
||||
other: "Other",
|
||||
},
|
||||
importantAreas: {
|
||||
images: "Images",
|
||||
pdf_docs: "PDF/docs",
|
||||
video_audio: "Video/audio",
|
||||
batch_workflows: "Batch workflows",
|
||||
ai_tools: "AI tools",
|
||||
},
|
||||
},
|
||||
categories: {
|
||||
essentials: "आवश्यक टूल्स",
|
||||
adjustments: "समायोजन",
|
||||
|
||||
@@ -40,6 +40,96 @@ export const id: TranslationKeys = {
|
||||
pageNotFound: "Halaman tidak ditemukan",
|
||||
pageNotFoundDescription: "Halaman yang Anda cari tidak ada atau telah dipindahkan.",
|
||||
},
|
||||
feedback: {
|
||||
navLabel: "Share feedback",
|
||||
dialogTitle: "Share feedback",
|
||||
toolDialogTitle: "How did this tool work?",
|
||||
failedDialogTitle: "Report a tool issue",
|
||||
adminDialogTitle: "How was setup?",
|
||||
closeLabel: "Close feedback dialog",
|
||||
privacyNote:
|
||||
"Feedback is sent only when you submit it. SnapOtter does not include your files, file contents, file names, or private outputs.",
|
||||
typeLabel: "Feedback type",
|
||||
sentimentLabel: "Quick rating",
|
||||
messageLabel: "Feedback",
|
||||
messagePlaceholder: "Tell us what worked, what broke, or what would make SnapOtter better.",
|
||||
improveFirstLabel: "What should we improve first?",
|
||||
improveFirstPlaceholder: "Tell us what made setup harder than it should be.",
|
||||
contactOkLabel: "You can contact me about this feedback.",
|
||||
emailPlaceholder: "Email (optional)",
|
||||
namePlaceholder: "Name (optional)",
|
||||
companyPlaceholder: "Company or org (optional)",
|
||||
submit: "Send feedback",
|
||||
submitting: "Sending...",
|
||||
submitFailed: "Feedback could not be sent. Please try again.",
|
||||
thanksTitle: "Thanks for the feedback.",
|
||||
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
||||
quickThanks: "Thanks for the signal.",
|
||||
reportIssue: "Report issue",
|
||||
toolPromptTitle: "How did this tool work?",
|
||||
toolPromptDescription: "One quick signal helps us improve SnapOtter.",
|
||||
dismissPrompt: "Dismiss feedback prompt",
|
||||
workedWell: "Worked well",
|
||||
dontAskAgain: "Don't ask again",
|
||||
shareFeedback: "Share feedback",
|
||||
remindLater: "Remind me later",
|
||||
adminCardTitle: "How was setup?",
|
||||
adminCardDescription:
|
||||
"A quick note from admins helps us improve Docker, source installs, and docs.",
|
||||
installMethodLabel: "Install method",
|
||||
usageTypeLabel: "Use case",
|
||||
frictionAreaLabel: "Hardest setup area",
|
||||
importantAreasLabel: "Most important areas",
|
||||
sentiments: {
|
||||
great: "Great",
|
||||
okay: "Okay",
|
||||
issue: "Had an issue",
|
||||
missing: "Missing something",
|
||||
bug: "Bug",
|
||||
idea: "Idea",
|
||||
other: "Other",
|
||||
},
|
||||
types: {
|
||||
bug: "Bug",
|
||||
feature_request: "Feature request",
|
||||
confusing_ux: "Confusing UX",
|
||||
performance: "Performance",
|
||||
other: "Other",
|
||||
},
|
||||
installMethods: {
|
||||
docker: "Docker",
|
||||
docker_compose: "Docker Compose",
|
||||
source: "Built from source",
|
||||
cloud: "Cloud/demo",
|
||||
other: "Other",
|
||||
},
|
||||
usageTypes: {
|
||||
personal: "Personal",
|
||||
team_internal: "Team/internal",
|
||||
business_workflow: "Business workflow",
|
||||
education: "Education",
|
||||
evaluating: "Evaluating",
|
||||
},
|
||||
frictionAreas: {
|
||||
smooth: "Nothing, setup was smooth",
|
||||
docker: "Docker/container setup",
|
||||
environment_variables: "Environment variables",
|
||||
auth: "Authentication/SSO",
|
||||
storage: "Storage/uploads",
|
||||
workers: "Workers/queues",
|
||||
ai_tools: "AI tools",
|
||||
docs: "Documentation",
|
||||
performance: "Performance/resources",
|
||||
other: "Other",
|
||||
},
|
||||
importantAreas: {
|
||||
images: "Images",
|
||||
pdf_docs: "PDF/docs",
|
||||
video_audio: "Video/audio",
|
||||
batch_workflows: "Batch workflows",
|
||||
ai_tools: "AI tools",
|
||||
},
|
||||
},
|
||||
categories: {
|
||||
essentials: "Dasar",
|
||||
adjustments: "Penyesuaian",
|
||||
|
||||
@@ -40,6 +40,96 @@ export const it: TranslationKeys = {
|
||||
pageNotFound: "Pagina non trovata",
|
||||
pageNotFoundDescription: "La pagina che stai cercando non esiste o è stata spostata.",
|
||||
},
|
||||
feedback: {
|
||||
navLabel: "Share feedback",
|
||||
dialogTitle: "Share feedback",
|
||||
toolDialogTitle: "How did this tool work?",
|
||||
failedDialogTitle: "Report a tool issue",
|
||||
adminDialogTitle: "How was setup?",
|
||||
closeLabel: "Close feedback dialog",
|
||||
privacyNote:
|
||||
"Feedback is sent only when you submit it. SnapOtter does not include your files, file contents, file names, or private outputs.",
|
||||
typeLabel: "Feedback type",
|
||||
sentimentLabel: "Quick rating",
|
||||
messageLabel: "Feedback",
|
||||
messagePlaceholder: "Tell us what worked, what broke, or what would make SnapOtter better.",
|
||||
improveFirstLabel: "What should we improve first?",
|
||||
improveFirstPlaceholder: "Tell us what made setup harder than it should be.",
|
||||
contactOkLabel: "You can contact me about this feedback.",
|
||||
emailPlaceholder: "Email (optional)",
|
||||
namePlaceholder: "Name (optional)",
|
||||
companyPlaceholder: "Company or org (optional)",
|
||||
submit: "Send feedback",
|
||||
submitting: "Sending...",
|
||||
submitFailed: "Feedback could not be sent. Please try again.",
|
||||
thanksTitle: "Thanks for the feedback.",
|
||||
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
||||
quickThanks: "Thanks for the signal.",
|
||||
reportIssue: "Report issue",
|
||||
toolPromptTitle: "How did this tool work?",
|
||||
toolPromptDescription: "One quick signal helps us improve SnapOtter.",
|
||||
dismissPrompt: "Dismiss feedback prompt",
|
||||
workedWell: "Worked well",
|
||||
dontAskAgain: "Don't ask again",
|
||||
shareFeedback: "Share feedback",
|
||||
remindLater: "Remind me later",
|
||||
adminCardTitle: "How was setup?",
|
||||
adminCardDescription:
|
||||
"A quick note from admins helps us improve Docker, source installs, and docs.",
|
||||
installMethodLabel: "Install method",
|
||||
usageTypeLabel: "Use case",
|
||||
frictionAreaLabel: "Hardest setup area",
|
||||
importantAreasLabel: "Most important areas",
|
||||
sentiments: {
|
||||
great: "Great",
|
||||
okay: "Okay",
|
||||
issue: "Had an issue",
|
||||
missing: "Missing something",
|
||||
bug: "Bug",
|
||||
idea: "Idea",
|
||||
other: "Other",
|
||||
},
|
||||
types: {
|
||||
bug: "Bug",
|
||||
feature_request: "Feature request",
|
||||
confusing_ux: "Confusing UX",
|
||||
performance: "Performance",
|
||||
other: "Other",
|
||||
},
|
||||
installMethods: {
|
||||
docker: "Docker",
|
||||
docker_compose: "Docker Compose",
|
||||
source: "Built from source",
|
||||
cloud: "Cloud/demo",
|
||||
other: "Other",
|
||||
},
|
||||
usageTypes: {
|
||||
personal: "Personal",
|
||||
team_internal: "Team/internal",
|
||||
business_workflow: "Business workflow",
|
||||
education: "Education",
|
||||
evaluating: "Evaluating",
|
||||
},
|
||||
frictionAreas: {
|
||||
smooth: "Nothing, setup was smooth",
|
||||
docker: "Docker/container setup",
|
||||
environment_variables: "Environment variables",
|
||||
auth: "Authentication/SSO",
|
||||
storage: "Storage/uploads",
|
||||
workers: "Workers/queues",
|
||||
ai_tools: "AI tools",
|
||||
docs: "Documentation",
|
||||
performance: "Performance/resources",
|
||||
other: "Other",
|
||||
},
|
||||
importantAreas: {
|
||||
images: "Images",
|
||||
pdf_docs: "PDF/docs",
|
||||
video_audio: "Video/audio",
|
||||
batch_workflows: "Batch workflows",
|
||||
ai_tools: "AI tools",
|
||||
},
|
||||
},
|
||||
categories: {
|
||||
essentials: "Essenziali",
|
||||
adjustments: "Regolazioni",
|
||||
|
||||
@@ -40,6 +40,96 @@ export const ja: TranslationKeys = {
|
||||
pageNotFound: "ページが見つかりません",
|
||||
pageNotFoundDescription: "お探しのページは存在しないか、移動された可能性があります。",
|
||||
},
|
||||
feedback: {
|
||||
navLabel: "Share feedback",
|
||||
dialogTitle: "Share feedback",
|
||||
toolDialogTitle: "How did this tool work?",
|
||||
failedDialogTitle: "Report a tool issue",
|
||||
adminDialogTitle: "How was setup?",
|
||||
closeLabel: "Close feedback dialog",
|
||||
privacyNote:
|
||||
"Feedback is sent only when you submit it. SnapOtter does not include your files, file contents, file names, or private outputs.",
|
||||
typeLabel: "Feedback type",
|
||||
sentimentLabel: "Quick rating",
|
||||
messageLabel: "Feedback",
|
||||
messagePlaceholder: "Tell us what worked, what broke, or what would make SnapOtter better.",
|
||||
improveFirstLabel: "What should we improve first?",
|
||||
improveFirstPlaceholder: "Tell us what made setup harder than it should be.",
|
||||
contactOkLabel: "You can contact me about this feedback.",
|
||||
emailPlaceholder: "Email (optional)",
|
||||
namePlaceholder: "Name (optional)",
|
||||
companyPlaceholder: "Company or org (optional)",
|
||||
submit: "Send feedback",
|
||||
submitting: "Sending...",
|
||||
submitFailed: "Feedback could not be sent. Please try again.",
|
||||
thanksTitle: "Thanks for the feedback.",
|
||||
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
||||
quickThanks: "Thanks for the signal.",
|
||||
reportIssue: "Report issue",
|
||||
toolPromptTitle: "How did this tool work?",
|
||||
toolPromptDescription: "One quick signal helps us improve SnapOtter.",
|
||||
dismissPrompt: "Dismiss feedback prompt",
|
||||
workedWell: "Worked well",
|
||||
dontAskAgain: "Don't ask again",
|
||||
shareFeedback: "Share feedback",
|
||||
remindLater: "Remind me later",
|
||||
adminCardTitle: "How was setup?",
|
||||
adminCardDescription:
|
||||
"A quick note from admins helps us improve Docker, source installs, and docs.",
|
||||
installMethodLabel: "Install method",
|
||||
usageTypeLabel: "Use case",
|
||||
frictionAreaLabel: "Hardest setup area",
|
||||
importantAreasLabel: "Most important areas",
|
||||
sentiments: {
|
||||
great: "Great",
|
||||
okay: "Okay",
|
||||
issue: "Had an issue",
|
||||
missing: "Missing something",
|
||||
bug: "Bug",
|
||||
idea: "Idea",
|
||||
other: "Other",
|
||||
},
|
||||
types: {
|
||||
bug: "Bug",
|
||||
feature_request: "Feature request",
|
||||
confusing_ux: "Confusing UX",
|
||||
performance: "Performance",
|
||||
other: "Other",
|
||||
},
|
||||
installMethods: {
|
||||
docker: "Docker",
|
||||
docker_compose: "Docker Compose",
|
||||
source: "Built from source",
|
||||
cloud: "Cloud/demo",
|
||||
other: "Other",
|
||||
},
|
||||
usageTypes: {
|
||||
personal: "Personal",
|
||||
team_internal: "Team/internal",
|
||||
business_workflow: "Business workflow",
|
||||
education: "Education",
|
||||
evaluating: "Evaluating",
|
||||
},
|
||||
frictionAreas: {
|
||||
smooth: "Nothing, setup was smooth",
|
||||
docker: "Docker/container setup",
|
||||
environment_variables: "Environment variables",
|
||||
auth: "Authentication/SSO",
|
||||
storage: "Storage/uploads",
|
||||
workers: "Workers/queues",
|
||||
ai_tools: "AI tools",
|
||||
docs: "Documentation",
|
||||
performance: "Performance/resources",
|
||||
other: "Other",
|
||||
},
|
||||
importantAreas: {
|
||||
images: "Images",
|
||||
pdf_docs: "PDF/docs",
|
||||
video_audio: "Video/audio",
|
||||
batch_workflows: "Batch workflows",
|
||||
ai_tools: "AI tools",
|
||||
},
|
||||
},
|
||||
categories: {
|
||||
essentials: "基本ツール",
|
||||
adjustments: "調整",
|
||||
|
||||
@@ -40,6 +40,96 @@ export const ko: TranslationKeys = {
|
||||
pageNotFound: "페이지를 찾을 수 없음",
|
||||
pageNotFoundDescription: "요청하신 페이지가 존재하지 않거나 이동되었습니다.",
|
||||
},
|
||||
feedback: {
|
||||
navLabel: "Share feedback",
|
||||
dialogTitle: "Share feedback",
|
||||
toolDialogTitle: "How did this tool work?",
|
||||
failedDialogTitle: "Report a tool issue",
|
||||
adminDialogTitle: "How was setup?",
|
||||
closeLabel: "Close feedback dialog",
|
||||
privacyNote:
|
||||
"Feedback is sent only when you submit it. SnapOtter does not include your files, file contents, file names, or private outputs.",
|
||||
typeLabel: "Feedback type",
|
||||
sentimentLabel: "Quick rating",
|
||||
messageLabel: "Feedback",
|
||||
messagePlaceholder: "Tell us what worked, what broke, or what would make SnapOtter better.",
|
||||
improveFirstLabel: "What should we improve first?",
|
||||
improveFirstPlaceholder: "Tell us what made setup harder than it should be.",
|
||||
contactOkLabel: "You can contact me about this feedback.",
|
||||
emailPlaceholder: "Email (optional)",
|
||||
namePlaceholder: "Name (optional)",
|
||||
companyPlaceholder: "Company or org (optional)",
|
||||
submit: "Send feedback",
|
||||
submitting: "Sending...",
|
||||
submitFailed: "Feedback could not be sent. Please try again.",
|
||||
thanksTitle: "Thanks for the feedback.",
|
||||
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
||||
quickThanks: "Thanks for the signal.",
|
||||
reportIssue: "Report issue",
|
||||
toolPromptTitle: "How did this tool work?",
|
||||
toolPromptDescription: "One quick signal helps us improve SnapOtter.",
|
||||
dismissPrompt: "Dismiss feedback prompt",
|
||||
workedWell: "Worked well",
|
||||
dontAskAgain: "Don't ask again",
|
||||
shareFeedback: "Share feedback",
|
||||
remindLater: "Remind me later",
|
||||
adminCardTitle: "How was setup?",
|
||||
adminCardDescription:
|
||||
"A quick note from admins helps us improve Docker, source installs, and docs.",
|
||||
installMethodLabel: "Install method",
|
||||
usageTypeLabel: "Use case",
|
||||
frictionAreaLabel: "Hardest setup area",
|
||||
importantAreasLabel: "Most important areas",
|
||||
sentiments: {
|
||||
great: "Great",
|
||||
okay: "Okay",
|
||||
issue: "Had an issue",
|
||||
missing: "Missing something",
|
||||
bug: "Bug",
|
||||
idea: "Idea",
|
||||
other: "Other",
|
||||
},
|
||||
types: {
|
||||
bug: "Bug",
|
||||
feature_request: "Feature request",
|
||||
confusing_ux: "Confusing UX",
|
||||
performance: "Performance",
|
||||
other: "Other",
|
||||
},
|
||||
installMethods: {
|
||||
docker: "Docker",
|
||||
docker_compose: "Docker Compose",
|
||||
source: "Built from source",
|
||||
cloud: "Cloud/demo",
|
||||
other: "Other",
|
||||
},
|
||||
usageTypes: {
|
||||
personal: "Personal",
|
||||
team_internal: "Team/internal",
|
||||
business_workflow: "Business workflow",
|
||||
education: "Education",
|
||||
evaluating: "Evaluating",
|
||||
},
|
||||
frictionAreas: {
|
||||
smooth: "Nothing, setup was smooth",
|
||||
docker: "Docker/container setup",
|
||||
environment_variables: "Environment variables",
|
||||
auth: "Authentication/SSO",
|
||||
storage: "Storage/uploads",
|
||||
workers: "Workers/queues",
|
||||
ai_tools: "AI tools",
|
||||
docs: "Documentation",
|
||||
performance: "Performance/resources",
|
||||
other: "Other",
|
||||
},
|
||||
importantAreas: {
|
||||
images: "Images",
|
||||
pdf_docs: "PDF/docs",
|
||||
video_audio: "Video/audio",
|
||||
batch_workflows: "Batch workflows",
|
||||
ai_tools: "AI tools",
|
||||
},
|
||||
},
|
||||
categories: {
|
||||
essentials: "기본 도구",
|
||||
adjustments: "조정",
|
||||
|
||||
@@ -40,6 +40,96 @@ export const nl: TranslationKeys = {
|
||||
pageNotFound: "Pagina niet gevonden",
|
||||
pageNotFoundDescription: "De pagina die je zoekt bestaat niet of is verplaatst.",
|
||||
},
|
||||
feedback: {
|
||||
navLabel: "Share feedback",
|
||||
dialogTitle: "Share feedback",
|
||||
toolDialogTitle: "How did this tool work?",
|
||||
failedDialogTitle: "Report a tool issue",
|
||||
adminDialogTitle: "How was setup?",
|
||||
closeLabel: "Close feedback dialog",
|
||||
privacyNote:
|
||||
"Feedback is sent only when you submit it. SnapOtter does not include your files, file contents, file names, or private outputs.",
|
||||
typeLabel: "Feedback type",
|
||||
sentimentLabel: "Quick rating",
|
||||
messageLabel: "Feedback",
|
||||
messagePlaceholder: "Tell us what worked, what broke, or what would make SnapOtter better.",
|
||||
improveFirstLabel: "What should we improve first?",
|
||||
improveFirstPlaceholder: "Tell us what made setup harder than it should be.",
|
||||
contactOkLabel: "You can contact me about this feedback.",
|
||||
emailPlaceholder: "Email (optional)",
|
||||
namePlaceholder: "Name (optional)",
|
||||
companyPlaceholder: "Company or org (optional)",
|
||||
submit: "Send feedback",
|
||||
submitting: "Sending...",
|
||||
submitFailed: "Feedback could not be sent. Please try again.",
|
||||
thanksTitle: "Thanks for the feedback.",
|
||||
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
||||
quickThanks: "Thanks for the signal.",
|
||||
reportIssue: "Report issue",
|
||||
toolPromptTitle: "How did this tool work?",
|
||||
toolPromptDescription: "One quick signal helps us improve SnapOtter.",
|
||||
dismissPrompt: "Dismiss feedback prompt",
|
||||
workedWell: "Worked well",
|
||||
dontAskAgain: "Don't ask again",
|
||||
shareFeedback: "Share feedback",
|
||||
remindLater: "Remind me later",
|
||||
adminCardTitle: "How was setup?",
|
||||
adminCardDescription:
|
||||
"A quick note from admins helps us improve Docker, source installs, and docs.",
|
||||
installMethodLabel: "Install method",
|
||||
usageTypeLabel: "Use case",
|
||||
frictionAreaLabel: "Hardest setup area",
|
||||
importantAreasLabel: "Most important areas",
|
||||
sentiments: {
|
||||
great: "Great",
|
||||
okay: "Okay",
|
||||
issue: "Had an issue",
|
||||
missing: "Missing something",
|
||||
bug: "Bug",
|
||||
idea: "Idea",
|
||||
other: "Other",
|
||||
},
|
||||
types: {
|
||||
bug: "Bug",
|
||||
feature_request: "Feature request",
|
||||
confusing_ux: "Confusing UX",
|
||||
performance: "Performance",
|
||||
other: "Other",
|
||||
},
|
||||
installMethods: {
|
||||
docker: "Docker",
|
||||
docker_compose: "Docker Compose",
|
||||
source: "Built from source",
|
||||
cloud: "Cloud/demo",
|
||||
other: "Other",
|
||||
},
|
||||
usageTypes: {
|
||||
personal: "Personal",
|
||||
team_internal: "Team/internal",
|
||||
business_workflow: "Business workflow",
|
||||
education: "Education",
|
||||
evaluating: "Evaluating",
|
||||
},
|
||||
frictionAreas: {
|
||||
smooth: "Nothing, setup was smooth",
|
||||
docker: "Docker/container setup",
|
||||
environment_variables: "Environment variables",
|
||||
auth: "Authentication/SSO",
|
||||
storage: "Storage/uploads",
|
||||
workers: "Workers/queues",
|
||||
ai_tools: "AI tools",
|
||||
docs: "Documentation",
|
||||
performance: "Performance/resources",
|
||||
other: "Other",
|
||||
},
|
||||
importantAreas: {
|
||||
images: "Images",
|
||||
pdf_docs: "PDF/docs",
|
||||
video_audio: "Video/audio",
|
||||
batch_workflows: "Batch workflows",
|
||||
ai_tools: "AI tools",
|
||||
},
|
||||
},
|
||||
categories: {
|
||||
essentials: "Basistools",
|
||||
adjustments: "Aanpassingen",
|
||||
|
||||
@@ -40,6 +40,96 @@ export const pl: TranslationKeys = {
|
||||
pageNotFound: "Nie znaleziono strony",
|
||||
pageNotFoundDescription: "Strona, której szukasz, nie istnieje lub została przeniesiona.",
|
||||
},
|
||||
feedback: {
|
||||
navLabel: "Share feedback",
|
||||
dialogTitle: "Share feedback",
|
||||
toolDialogTitle: "How did this tool work?",
|
||||
failedDialogTitle: "Report a tool issue",
|
||||
adminDialogTitle: "How was setup?",
|
||||
closeLabel: "Close feedback dialog",
|
||||
privacyNote:
|
||||
"Feedback is sent only when you submit it. SnapOtter does not include your files, file contents, file names, or private outputs.",
|
||||
typeLabel: "Feedback type",
|
||||
sentimentLabel: "Quick rating",
|
||||
messageLabel: "Feedback",
|
||||
messagePlaceholder: "Tell us what worked, what broke, or what would make SnapOtter better.",
|
||||
improveFirstLabel: "What should we improve first?",
|
||||
improveFirstPlaceholder: "Tell us what made setup harder than it should be.",
|
||||
contactOkLabel: "You can contact me about this feedback.",
|
||||
emailPlaceholder: "Email (optional)",
|
||||
namePlaceholder: "Name (optional)",
|
||||
companyPlaceholder: "Company or org (optional)",
|
||||
submit: "Send feedback",
|
||||
submitting: "Sending...",
|
||||
submitFailed: "Feedback could not be sent. Please try again.",
|
||||
thanksTitle: "Thanks for the feedback.",
|
||||
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
||||
quickThanks: "Thanks for the signal.",
|
||||
reportIssue: "Report issue",
|
||||
toolPromptTitle: "How did this tool work?",
|
||||
toolPromptDescription: "One quick signal helps us improve SnapOtter.",
|
||||
dismissPrompt: "Dismiss feedback prompt",
|
||||
workedWell: "Worked well",
|
||||
dontAskAgain: "Don't ask again",
|
||||
shareFeedback: "Share feedback",
|
||||
remindLater: "Remind me later",
|
||||
adminCardTitle: "How was setup?",
|
||||
adminCardDescription:
|
||||
"A quick note from admins helps us improve Docker, source installs, and docs.",
|
||||
installMethodLabel: "Install method",
|
||||
usageTypeLabel: "Use case",
|
||||
frictionAreaLabel: "Hardest setup area",
|
||||
importantAreasLabel: "Most important areas",
|
||||
sentiments: {
|
||||
great: "Great",
|
||||
okay: "Okay",
|
||||
issue: "Had an issue",
|
||||
missing: "Missing something",
|
||||
bug: "Bug",
|
||||
idea: "Idea",
|
||||
other: "Other",
|
||||
},
|
||||
types: {
|
||||
bug: "Bug",
|
||||
feature_request: "Feature request",
|
||||
confusing_ux: "Confusing UX",
|
||||
performance: "Performance",
|
||||
other: "Other",
|
||||
},
|
||||
installMethods: {
|
||||
docker: "Docker",
|
||||
docker_compose: "Docker Compose",
|
||||
source: "Built from source",
|
||||
cloud: "Cloud/demo",
|
||||
other: "Other",
|
||||
},
|
||||
usageTypes: {
|
||||
personal: "Personal",
|
||||
team_internal: "Team/internal",
|
||||
business_workflow: "Business workflow",
|
||||
education: "Education",
|
||||
evaluating: "Evaluating",
|
||||
},
|
||||
frictionAreas: {
|
||||
smooth: "Nothing, setup was smooth",
|
||||
docker: "Docker/container setup",
|
||||
environment_variables: "Environment variables",
|
||||
auth: "Authentication/SSO",
|
||||
storage: "Storage/uploads",
|
||||
workers: "Workers/queues",
|
||||
ai_tools: "AI tools",
|
||||
docs: "Documentation",
|
||||
performance: "Performance/resources",
|
||||
other: "Other",
|
||||
},
|
||||
importantAreas: {
|
||||
images: "Images",
|
||||
pdf_docs: "PDF/docs",
|
||||
video_audio: "Video/audio",
|
||||
batch_workflows: "Batch workflows",
|
||||
ai_tools: "AI tools",
|
||||
},
|
||||
},
|
||||
categories: {
|
||||
essentials: "Podstawowe",
|
||||
adjustments: "Korekta",
|
||||
|
||||
@@ -40,6 +40,96 @@ export const ptBR: TranslationKeys = {
|
||||
pageNotFound: "Página não encontrada",
|
||||
pageNotFoundDescription: "A página que você procura não existe ou foi movida.",
|
||||
},
|
||||
feedback: {
|
||||
navLabel: "Share feedback",
|
||||
dialogTitle: "Share feedback",
|
||||
toolDialogTitle: "How did this tool work?",
|
||||
failedDialogTitle: "Report a tool issue",
|
||||
adminDialogTitle: "How was setup?",
|
||||
closeLabel: "Close feedback dialog",
|
||||
privacyNote:
|
||||
"Feedback is sent only when you submit it. SnapOtter does not include your files, file contents, file names, or private outputs.",
|
||||
typeLabel: "Feedback type",
|
||||
sentimentLabel: "Quick rating",
|
||||
messageLabel: "Feedback",
|
||||
messagePlaceholder: "Tell us what worked, what broke, or what would make SnapOtter better.",
|
||||
improveFirstLabel: "What should we improve first?",
|
||||
improveFirstPlaceholder: "Tell us what made setup harder than it should be.",
|
||||
contactOkLabel: "You can contact me about this feedback.",
|
||||
emailPlaceholder: "Email (optional)",
|
||||
namePlaceholder: "Name (optional)",
|
||||
companyPlaceholder: "Company or org (optional)",
|
||||
submit: "Send feedback",
|
||||
submitting: "Sending...",
|
||||
submitFailed: "Feedback could not be sent. Please try again.",
|
||||
thanksTitle: "Thanks for the feedback.",
|
||||
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
||||
quickThanks: "Thanks for the signal.",
|
||||
reportIssue: "Report issue",
|
||||
toolPromptTitle: "How did this tool work?",
|
||||
toolPromptDescription: "One quick signal helps us improve SnapOtter.",
|
||||
dismissPrompt: "Dismiss feedback prompt",
|
||||
workedWell: "Worked well",
|
||||
dontAskAgain: "Don't ask again",
|
||||
shareFeedback: "Share feedback",
|
||||
remindLater: "Remind me later",
|
||||
adminCardTitle: "How was setup?",
|
||||
adminCardDescription:
|
||||
"A quick note from admins helps us improve Docker, source installs, and docs.",
|
||||
installMethodLabel: "Install method",
|
||||
usageTypeLabel: "Use case",
|
||||
frictionAreaLabel: "Hardest setup area",
|
||||
importantAreasLabel: "Most important areas",
|
||||
sentiments: {
|
||||
great: "Great",
|
||||
okay: "Okay",
|
||||
issue: "Had an issue",
|
||||
missing: "Missing something",
|
||||
bug: "Bug",
|
||||
idea: "Idea",
|
||||
other: "Other",
|
||||
},
|
||||
types: {
|
||||
bug: "Bug",
|
||||
feature_request: "Feature request",
|
||||
confusing_ux: "Confusing UX",
|
||||
performance: "Performance",
|
||||
other: "Other",
|
||||
},
|
||||
installMethods: {
|
||||
docker: "Docker",
|
||||
docker_compose: "Docker Compose",
|
||||
source: "Built from source",
|
||||
cloud: "Cloud/demo",
|
||||
other: "Other",
|
||||
},
|
||||
usageTypes: {
|
||||
personal: "Personal",
|
||||
team_internal: "Team/internal",
|
||||
business_workflow: "Business workflow",
|
||||
education: "Education",
|
||||
evaluating: "Evaluating",
|
||||
},
|
||||
frictionAreas: {
|
||||
smooth: "Nothing, setup was smooth",
|
||||
docker: "Docker/container setup",
|
||||
environment_variables: "Environment variables",
|
||||
auth: "Authentication/SSO",
|
||||
storage: "Storage/uploads",
|
||||
workers: "Workers/queues",
|
||||
ai_tools: "AI tools",
|
||||
docs: "Documentation",
|
||||
performance: "Performance/resources",
|
||||
other: "Other",
|
||||
},
|
||||
importantAreas: {
|
||||
images: "Images",
|
||||
pdf_docs: "PDF/docs",
|
||||
video_audio: "Video/audio",
|
||||
batch_workflows: "Batch workflows",
|
||||
ai_tools: "AI tools",
|
||||
},
|
||||
},
|
||||
categories: {
|
||||
essentials: "Essenciais",
|
||||
adjustments: "Ajustes",
|
||||
|
||||
@@ -40,6 +40,96 @@ export const ru: TranslationKeys = {
|
||||
pageNotFound: "Страница не найдена",
|
||||
pageNotFoundDescription: "Запрашиваемая страница не существует или была перемещена.",
|
||||
},
|
||||
feedback: {
|
||||
navLabel: "Share feedback",
|
||||
dialogTitle: "Share feedback",
|
||||
toolDialogTitle: "How did this tool work?",
|
||||
failedDialogTitle: "Report a tool issue",
|
||||
adminDialogTitle: "How was setup?",
|
||||
closeLabel: "Close feedback dialog",
|
||||
privacyNote:
|
||||
"Feedback is sent only when you submit it. SnapOtter does not include your files, file contents, file names, or private outputs.",
|
||||
typeLabel: "Feedback type",
|
||||
sentimentLabel: "Quick rating",
|
||||
messageLabel: "Feedback",
|
||||
messagePlaceholder: "Tell us what worked, what broke, or what would make SnapOtter better.",
|
||||
improveFirstLabel: "What should we improve first?",
|
||||
improveFirstPlaceholder: "Tell us what made setup harder than it should be.",
|
||||
contactOkLabel: "You can contact me about this feedback.",
|
||||
emailPlaceholder: "Email (optional)",
|
||||
namePlaceholder: "Name (optional)",
|
||||
companyPlaceholder: "Company or org (optional)",
|
||||
submit: "Send feedback",
|
||||
submitting: "Sending...",
|
||||
submitFailed: "Feedback could not be sent. Please try again.",
|
||||
thanksTitle: "Thanks for the feedback.",
|
||||
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
||||
quickThanks: "Thanks for the signal.",
|
||||
reportIssue: "Report issue",
|
||||
toolPromptTitle: "How did this tool work?",
|
||||
toolPromptDescription: "One quick signal helps us improve SnapOtter.",
|
||||
dismissPrompt: "Dismiss feedback prompt",
|
||||
workedWell: "Worked well",
|
||||
dontAskAgain: "Don't ask again",
|
||||
shareFeedback: "Share feedback",
|
||||
remindLater: "Remind me later",
|
||||
adminCardTitle: "How was setup?",
|
||||
adminCardDescription:
|
||||
"A quick note from admins helps us improve Docker, source installs, and docs.",
|
||||
installMethodLabel: "Install method",
|
||||
usageTypeLabel: "Use case",
|
||||
frictionAreaLabel: "Hardest setup area",
|
||||
importantAreasLabel: "Most important areas",
|
||||
sentiments: {
|
||||
great: "Great",
|
||||
okay: "Okay",
|
||||
issue: "Had an issue",
|
||||
missing: "Missing something",
|
||||
bug: "Bug",
|
||||
idea: "Idea",
|
||||
other: "Other",
|
||||
},
|
||||
types: {
|
||||
bug: "Bug",
|
||||
feature_request: "Feature request",
|
||||
confusing_ux: "Confusing UX",
|
||||
performance: "Performance",
|
||||
other: "Other",
|
||||
},
|
||||
installMethods: {
|
||||
docker: "Docker",
|
||||
docker_compose: "Docker Compose",
|
||||
source: "Built from source",
|
||||
cloud: "Cloud/demo",
|
||||
other: "Other",
|
||||
},
|
||||
usageTypes: {
|
||||
personal: "Personal",
|
||||
team_internal: "Team/internal",
|
||||
business_workflow: "Business workflow",
|
||||
education: "Education",
|
||||
evaluating: "Evaluating",
|
||||
},
|
||||
frictionAreas: {
|
||||
smooth: "Nothing, setup was smooth",
|
||||
docker: "Docker/container setup",
|
||||
environment_variables: "Environment variables",
|
||||
auth: "Authentication/SSO",
|
||||
storage: "Storage/uploads",
|
||||
workers: "Workers/queues",
|
||||
ai_tools: "AI tools",
|
||||
docs: "Documentation",
|
||||
performance: "Performance/resources",
|
||||
other: "Other",
|
||||
},
|
||||
importantAreas: {
|
||||
images: "Images",
|
||||
pdf_docs: "PDF/docs",
|
||||
video_audio: "Video/audio",
|
||||
batch_workflows: "Batch workflows",
|
||||
ai_tools: "AI tools",
|
||||
},
|
||||
},
|
||||
categories: {
|
||||
essentials: "Основные",
|
||||
adjustments: "Коррекция",
|
||||
|
||||
@@ -40,6 +40,96 @@ export const sv: TranslationKeys = {
|
||||
pageNotFound: "Sidan hittades inte",
|
||||
pageNotFoundDescription: "Sidan du letar efter finns inte eller har flyttats.",
|
||||
},
|
||||
feedback: {
|
||||
navLabel: "Share feedback",
|
||||
dialogTitle: "Share feedback",
|
||||
toolDialogTitle: "How did this tool work?",
|
||||
failedDialogTitle: "Report a tool issue",
|
||||
adminDialogTitle: "How was setup?",
|
||||
closeLabel: "Close feedback dialog",
|
||||
privacyNote:
|
||||
"Feedback is sent only when you submit it. SnapOtter does not include your files, file contents, file names, or private outputs.",
|
||||
typeLabel: "Feedback type",
|
||||
sentimentLabel: "Quick rating",
|
||||
messageLabel: "Feedback",
|
||||
messagePlaceholder: "Tell us what worked, what broke, or what would make SnapOtter better.",
|
||||
improveFirstLabel: "What should we improve first?",
|
||||
improveFirstPlaceholder: "Tell us what made setup harder than it should be.",
|
||||
contactOkLabel: "You can contact me about this feedback.",
|
||||
emailPlaceholder: "Email (optional)",
|
||||
namePlaceholder: "Name (optional)",
|
||||
companyPlaceholder: "Company or org (optional)",
|
||||
submit: "Send feedback",
|
||||
submitting: "Sending...",
|
||||
submitFailed: "Feedback could not be sent. Please try again.",
|
||||
thanksTitle: "Thanks for the feedback.",
|
||||
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
||||
quickThanks: "Thanks for the signal.",
|
||||
reportIssue: "Report issue",
|
||||
toolPromptTitle: "How did this tool work?",
|
||||
toolPromptDescription: "One quick signal helps us improve SnapOtter.",
|
||||
dismissPrompt: "Dismiss feedback prompt",
|
||||
workedWell: "Worked well",
|
||||
dontAskAgain: "Don't ask again",
|
||||
shareFeedback: "Share feedback",
|
||||
remindLater: "Remind me later",
|
||||
adminCardTitle: "How was setup?",
|
||||
adminCardDescription:
|
||||
"A quick note from admins helps us improve Docker, source installs, and docs.",
|
||||
installMethodLabel: "Install method",
|
||||
usageTypeLabel: "Use case",
|
||||
frictionAreaLabel: "Hardest setup area",
|
||||
importantAreasLabel: "Most important areas",
|
||||
sentiments: {
|
||||
great: "Great",
|
||||
okay: "Okay",
|
||||
issue: "Had an issue",
|
||||
missing: "Missing something",
|
||||
bug: "Bug",
|
||||
idea: "Idea",
|
||||
other: "Other",
|
||||
},
|
||||
types: {
|
||||
bug: "Bug",
|
||||
feature_request: "Feature request",
|
||||
confusing_ux: "Confusing UX",
|
||||
performance: "Performance",
|
||||
other: "Other",
|
||||
},
|
||||
installMethods: {
|
||||
docker: "Docker",
|
||||
docker_compose: "Docker Compose",
|
||||
source: "Built from source",
|
||||
cloud: "Cloud/demo",
|
||||
other: "Other",
|
||||
},
|
||||
usageTypes: {
|
||||
personal: "Personal",
|
||||
team_internal: "Team/internal",
|
||||
business_workflow: "Business workflow",
|
||||
education: "Education",
|
||||
evaluating: "Evaluating",
|
||||
},
|
||||
frictionAreas: {
|
||||
smooth: "Nothing, setup was smooth",
|
||||
docker: "Docker/container setup",
|
||||
environment_variables: "Environment variables",
|
||||
auth: "Authentication/SSO",
|
||||
storage: "Storage/uploads",
|
||||
workers: "Workers/queues",
|
||||
ai_tools: "AI tools",
|
||||
docs: "Documentation",
|
||||
performance: "Performance/resources",
|
||||
other: "Other",
|
||||
},
|
||||
importantAreas: {
|
||||
images: "Images",
|
||||
pdf_docs: "PDF/docs",
|
||||
video_audio: "Video/audio",
|
||||
batch_workflows: "Batch workflows",
|
||||
ai_tools: "AI tools",
|
||||
},
|
||||
},
|
||||
categories: {
|
||||
essentials: "Grundläggande",
|
||||
adjustments: "Justeringar",
|
||||
|
||||
@@ -40,6 +40,96 @@ export const th: TranslationKeys = {
|
||||
pageNotFound: "ไม่พบหน้านี้",
|
||||
pageNotFoundDescription: "หน้าที่คุณกำลังมองหาไม่มีอยู่หรือถูกย้ายแล้ว",
|
||||
},
|
||||
feedback: {
|
||||
navLabel: "Share feedback",
|
||||
dialogTitle: "Share feedback",
|
||||
toolDialogTitle: "How did this tool work?",
|
||||
failedDialogTitle: "Report a tool issue",
|
||||
adminDialogTitle: "How was setup?",
|
||||
closeLabel: "Close feedback dialog",
|
||||
privacyNote:
|
||||
"Feedback is sent only when you submit it. SnapOtter does not include your files, file contents, file names, or private outputs.",
|
||||
typeLabel: "Feedback type",
|
||||
sentimentLabel: "Quick rating",
|
||||
messageLabel: "Feedback",
|
||||
messagePlaceholder: "Tell us what worked, what broke, or what would make SnapOtter better.",
|
||||
improveFirstLabel: "What should we improve first?",
|
||||
improveFirstPlaceholder: "Tell us what made setup harder than it should be.",
|
||||
contactOkLabel: "You can contact me about this feedback.",
|
||||
emailPlaceholder: "Email (optional)",
|
||||
namePlaceholder: "Name (optional)",
|
||||
companyPlaceholder: "Company or org (optional)",
|
||||
submit: "Send feedback",
|
||||
submitting: "Sending...",
|
||||
submitFailed: "Feedback could not be sent. Please try again.",
|
||||
thanksTitle: "Thanks for the feedback.",
|
||||
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
||||
quickThanks: "Thanks for the signal.",
|
||||
reportIssue: "Report issue",
|
||||
toolPromptTitle: "How did this tool work?",
|
||||
toolPromptDescription: "One quick signal helps us improve SnapOtter.",
|
||||
dismissPrompt: "Dismiss feedback prompt",
|
||||
workedWell: "Worked well",
|
||||
dontAskAgain: "Don't ask again",
|
||||
shareFeedback: "Share feedback",
|
||||
remindLater: "Remind me later",
|
||||
adminCardTitle: "How was setup?",
|
||||
adminCardDescription:
|
||||
"A quick note from admins helps us improve Docker, source installs, and docs.",
|
||||
installMethodLabel: "Install method",
|
||||
usageTypeLabel: "Use case",
|
||||
frictionAreaLabel: "Hardest setup area",
|
||||
importantAreasLabel: "Most important areas",
|
||||
sentiments: {
|
||||
great: "Great",
|
||||
okay: "Okay",
|
||||
issue: "Had an issue",
|
||||
missing: "Missing something",
|
||||
bug: "Bug",
|
||||
idea: "Idea",
|
||||
other: "Other",
|
||||
},
|
||||
types: {
|
||||
bug: "Bug",
|
||||
feature_request: "Feature request",
|
||||
confusing_ux: "Confusing UX",
|
||||
performance: "Performance",
|
||||
other: "Other",
|
||||
},
|
||||
installMethods: {
|
||||
docker: "Docker",
|
||||
docker_compose: "Docker Compose",
|
||||
source: "Built from source",
|
||||
cloud: "Cloud/demo",
|
||||
other: "Other",
|
||||
},
|
||||
usageTypes: {
|
||||
personal: "Personal",
|
||||
team_internal: "Team/internal",
|
||||
business_workflow: "Business workflow",
|
||||
education: "Education",
|
||||
evaluating: "Evaluating",
|
||||
},
|
||||
frictionAreas: {
|
||||
smooth: "Nothing, setup was smooth",
|
||||
docker: "Docker/container setup",
|
||||
environment_variables: "Environment variables",
|
||||
auth: "Authentication/SSO",
|
||||
storage: "Storage/uploads",
|
||||
workers: "Workers/queues",
|
||||
ai_tools: "AI tools",
|
||||
docs: "Documentation",
|
||||
performance: "Performance/resources",
|
||||
other: "Other",
|
||||
},
|
||||
importantAreas: {
|
||||
images: "Images",
|
||||
pdf_docs: "PDF/docs",
|
||||
video_audio: "Video/audio",
|
||||
batch_workflows: "Batch workflows",
|
||||
ai_tools: "AI tools",
|
||||
},
|
||||
},
|
||||
categories: {
|
||||
essentials: "พื้นฐาน",
|
||||
adjustments: "การปรับแต่ง",
|
||||
|
||||
@@ -40,6 +40,96 @@ export const tr: TranslationKeys = {
|
||||
pageNotFound: "Sayfa bulunamadı",
|
||||
pageNotFoundDescription: "Aradığınız sayfa mevcut değil veya taşınmış.",
|
||||
},
|
||||
feedback: {
|
||||
navLabel: "Share feedback",
|
||||
dialogTitle: "Share feedback",
|
||||
toolDialogTitle: "How did this tool work?",
|
||||
failedDialogTitle: "Report a tool issue",
|
||||
adminDialogTitle: "How was setup?",
|
||||
closeLabel: "Close feedback dialog",
|
||||
privacyNote:
|
||||
"Feedback is sent only when you submit it. SnapOtter does not include your files, file contents, file names, or private outputs.",
|
||||
typeLabel: "Feedback type",
|
||||
sentimentLabel: "Quick rating",
|
||||
messageLabel: "Feedback",
|
||||
messagePlaceholder: "Tell us what worked, what broke, or what would make SnapOtter better.",
|
||||
improveFirstLabel: "What should we improve first?",
|
||||
improveFirstPlaceholder: "Tell us what made setup harder than it should be.",
|
||||
contactOkLabel: "You can contact me about this feedback.",
|
||||
emailPlaceholder: "Email (optional)",
|
||||
namePlaceholder: "Name (optional)",
|
||||
companyPlaceholder: "Company or org (optional)",
|
||||
submit: "Send feedback",
|
||||
submitting: "Sending...",
|
||||
submitFailed: "Feedback could not be sent. Please try again.",
|
||||
thanksTitle: "Thanks for the feedback.",
|
||||
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
||||
quickThanks: "Thanks for the signal.",
|
||||
reportIssue: "Report issue",
|
||||
toolPromptTitle: "How did this tool work?",
|
||||
toolPromptDescription: "One quick signal helps us improve SnapOtter.",
|
||||
dismissPrompt: "Dismiss feedback prompt",
|
||||
workedWell: "Worked well",
|
||||
dontAskAgain: "Don't ask again",
|
||||
shareFeedback: "Share feedback",
|
||||
remindLater: "Remind me later",
|
||||
adminCardTitle: "How was setup?",
|
||||
adminCardDescription:
|
||||
"A quick note from admins helps us improve Docker, source installs, and docs.",
|
||||
installMethodLabel: "Install method",
|
||||
usageTypeLabel: "Use case",
|
||||
frictionAreaLabel: "Hardest setup area",
|
||||
importantAreasLabel: "Most important areas",
|
||||
sentiments: {
|
||||
great: "Great",
|
||||
okay: "Okay",
|
||||
issue: "Had an issue",
|
||||
missing: "Missing something",
|
||||
bug: "Bug",
|
||||
idea: "Idea",
|
||||
other: "Other",
|
||||
},
|
||||
types: {
|
||||
bug: "Bug",
|
||||
feature_request: "Feature request",
|
||||
confusing_ux: "Confusing UX",
|
||||
performance: "Performance",
|
||||
other: "Other",
|
||||
},
|
||||
installMethods: {
|
||||
docker: "Docker",
|
||||
docker_compose: "Docker Compose",
|
||||
source: "Built from source",
|
||||
cloud: "Cloud/demo",
|
||||
other: "Other",
|
||||
},
|
||||
usageTypes: {
|
||||
personal: "Personal",
|
||||
team_internal: "Team/internal",
|
||||
business_workflow: "Business workflow",
|
||||
education: "Education",
|
||||
evaluating: "Evaluating",
|
||||
},
|
||||
frictionAreas: {
|
||||
smooth: "Nothing, setup was smooth",
|
||||
docker: "Docker/container setup",
|
||||
environment_variables: "Environment variables",
|
||||
auth: "Authentication/SSO",
|
||||
storage: "Storage/uploads",
|
||||
workers: "Workers/queues",
|
||||
ai_tools: "AI tools",
|
||||
docs: "Documentation",
|
||||
performance: "Performance/resources",
|
||||
other: "Other",
|
||||
},
|
||||
importantAreas: {
|
||||
images: "Images",
|
||||
pdf_docs: "PDF/docs",
|
||||
video_audio: "Video/audio",
|
||||
batch_workflows: "Batch workflows",
|
||||
ai_tools: "AI tools",
|
||||
},
|
||||
},
|
||||
categories: {
|
||||
essentials: "Temel Araçlar",
|
||||
adjustments: "Ayarlamalar",
|
||||
|
||||
@@ -40,6 +40,96 @@ export const uk: TranslationKeys = {
|
||||
pageNotFound: "Сторінку не знайдено",
|
||||
pageNotFoundDescription: "Сторінка, яку ви шукаєте, не існує або була переміщена.",
|
||||
},
|
||||
feedback: {
|
||||
navLabel: "Share feedback",
|
||||
dialogTitle: "Share feedback",
|
||||
toolDialogTitle: "How did this tool work?",
|
||||
failedDialogTitle: "Report a tool issue",
|
||||
adminDialogTitle: "How was setup?",
|
||||
closeLabel: "Close feedback dialog",
|
||||
privacyNote:
|
||||
"Feedback is sent only when you submit it. SnapOtter does not include your files, file contents, file names, or private outputs.",
|
||||
typeLabel: "Feedback type",
|
||||
sentimentLabel: "Quick rating",
|
||||
messageLabel: "Feedback",
|
||||
messagePlaceholder: "Tell us what worked, what broke, or what would make SnapOtter better.",
|
||||
improveFirstLabel: "What should we improve first?",
|
||||
improveFirstPlaceholder: "Tell us what made setup harder than it should be.",
|
||||
contactOkLabel: "You can contact me about this feedback.",
|
||||
emailPlaceholder: "Email (optional)",
|
||||
namePlaceholder: "Name (optional)",
|
||||
companyPlaceholder: "Company or org (optional)",
|
||||
submit: "Send feedback",
|
||||
submitting: "Sending...",
|
||||
submitFailed: "Feedback could not be sent. Please try again.",
|
||||
thanksTitle: "Thanks for the feedback.",
|
||||
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
||||
quickThanks: "Thanks for the signal.",
|
||||
reportIssue: "Report issue",
|
||||
toolPromptTitle: "How did this tool work?",
|
||||
toolPromptDescription: "One quick signal helps us improve SnapOtter.",
|
||||
dismissPrompt: "Dismiss feedback prompt",
|
||||
workedWell: "Worked well",
|
||||
dontAskAgain: "Don't ask again",
|
||||
shareFeedback: "Share feedback",
|
||||
remindLater: "Remind me later",
|
||||
adminCardTitle: "How was setup?",
|
||||
adminCardDescription:
|
||||
"A quick note from admins helps us improve Docker, source installs, and docs.",
|
||||
installMethodLabel: "Install method",
|
||||
usageTypeLabel: "Use case",
|
||||
frictionAreaLabel: "Hardest setup area",
|
||||
importantAreasLabel: "Most important areas",
|
||||
sentiments: {
|
||||
great: "Great",
|
||||
okay: "Okay",
|
||||
issue: "Had an issue",
|
||||
missing: "Missing something",
|
||||
bug: "Bug",
|
||||
idea: "Idea",
|
||||
other: "Other",
|
||||
},
|
||||
types: {
|
||||
bug: "Bug",
|
||||
feature_request: "Feature request",
|
||||
confusing_ux: "Confusing UX",
|
||||
performance: "Performance",
|
||||
other: "Other",
|
||||
},
|
||||
installMethods: {
|
||||
docker: "Docker",
|
||||
docker_compose: "Docker Compose",
|
||||
source: "Built from source",
|
||||
cloud: "Cloud/demo",
|
||||
other: "Other",
|
||||
},
|
||||
usageTypes: {
|
||||
personal: "Personal",
|
||||
team_internal: "Team/internal",
|
||||
business_workflow: "Business workflow",
|
||||
education: "Education",
|
||||
evaluating: "Evaluating",
|
||||
},
|
||||
frictionAreas: {
|
||||
smooth: "Nothing, setup was smooth",
|
||||
docker: "Docker/container setup",
|
||||
environment_variables: "Environment variables",
|
||||
auth: "Authentication/SSO",
|
||||
storage: "Storage/uploads",
|
||||
workers: "Workers/queues",
|
||||
ai_tools: "AI tools",
|
||||
docs: "Documentation",
|
||||
performance: "Performance/resources",
|
||||
other: "Other",
|
||||
},
|
||||
importantAreas: {
|
||||
images: "Images",
|
||||
pdf_docs: "PDF/docs",
|
||||
video_audio: "Video/audio",
|
||||
batch_workflows: "Batch workflows",
|
||||
ai_tools: "AI tools",
|
||||
},
|
||||
},
|
||||
categories: {
|
||||
essentials: "Основні",
|
||||
adjustments: "Корекція",
|
||||
|
||||
@@ -40,6 +40,96 @@ export const vi: TranslationKeys = {
|
||||
pageNotFound: "Không tìm thấy trang",
|
||||
pageNotFoundDescription: "Trang bạn đang tìm không tồn tại hoặc đã được di chuyển.",
|
||||
},
|
||||
feedback: {
|
||||
navLabel: "Share feedback",
|
||||
dialogTitle: "Share feedback",
|
||||
toolDialogTitle: "How did this tool work?",
|
||||
failedDialogTitle: "Report a tool issue",
|
||||
adminDialogTitle: "How was setup?",
|
||||
closeLabel: "Close feedback dialog",
|
||||
privacyNote:
|
||||
"Feedback is sent only when you submit it. SnapOtter does not include your files, file contents, file names, or private outputs.",
|
||||
typeLabel: "Feedback type",
|
||||
sentimentLabel: "Quick rating",
|
||||
messageLabel: "Feedback",
|
||||
messagePlaceholder: "Tell us what worked, what broke, or what would make SnapOtter better.",
|
||||
improveFirstLabel: "What should we improve first?",
|
||||
improveFirstPlaceholder: "Tell us what made setup harder than it should be.",
|
||||
contactOkLabel: "You can contact me about this feedback.",
|
||||
emailPlaceholder: "Email (optional)",
|
||||
namePlaceholder: "Name (optional)",
|
||||
companyPlaceholder: "Company or org (optional)",
|
||||
submit: "Send feedback",
|
||||
submitting: "Sending...",
|
||||
submitFailed: "Feedback could not be sent. Please try again.",
|
||||
thanksTitle: "Thanks for the feedback.",
|
||||
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
||||
quickThanks: "Thanks for the signal.",
|
||||
reportIssue: "Report issue",
|
||||
toolPromptTitle: "How did this tool work?",
|
||||
toolPromptDescription: "One quick signal helps us improve SnapOtter.",
|
||||
dismissPrompt: "Dismiss feedback prompt",
|
||||
workedWell: "Worked well",
|
||||
dontAskAgain: "Don't ask again",
|
||||
shareFeedback: "Share feedback",
|
||||
remindLater: "Remind me later",
|
||||
adminCardTitle: "How was setup?",
|
||||
adminCardDescription:
|
||||
"A quick note from admins helps us improve Docker, source installs, and docs.",
|
||||
installMethodLabel: "Install method",
|
||||
usageTypeLabel: "Use case",
|
||||
frictionAreaLabel: "Hardest setup area",
|
||||
importantAreasLabel: "Most important areas",
|
||||
sentiments: {
|
||||
great: "Great",
|
||||
okay: "Okay",
|
||||
issue: "Had an issue",
|
||||
missing: "Missing something",
|
||||
bug: "Bug",
|
||||
idea: "Idea",
|
||||
other: "Other",
|
||||
},
|
||||
types: {
|
||||
bug: "Bug",
|
||||
feature_request: "Feature request",
|
||||
confusing_ux: "Confusing UX",
|
||||
performance: "Performance",
|
||||
other: "Other",
|
||||
},
|
||||
installMethods: {
|
||||
docker: "Docker",
|
||||
docker_compose: "Docker Compose",
|
||||
source: "Built from source",
|
||||
cloud: "Cloud/demo",
|
||||
other: "Other",
|
||||
},
|
||||
usageTypes: {
|
||||
personal: "Personal",
|
||||
team_internal: "Team/internal",
|
||||
business_workflow: "Business workflow",
|
||||
education: "Education",
|
||||
evaluating: "Evaluating",
|
||||
},
|
||||
frictionAreas: {
|
||||
smooth: "Nothing, setup was smooth",
|
||||
docker: "Docker/container setup",
|
||||
environment_variables: "Environment variables",
|
||||
auth: "Authentication/SSO",
|
||||
storage: "Storage/uploads",
|
||||
workers: "Workers/queues",
|
||||
ai_tools: "AI tools",
|
||||
docs: "Documentation",
|
||||
performance: "Performance/resources",
|
||||
other: "Other",
|
||||
},
|
||||
importantAreas: {
|
||||
images: "Images",
|
||||
pdf_docs: "PDF/docs",
|
||||
video_audio: "Video/audio",
|
||||
batch_workflows: "Batch workflows",
|
||||
ai_tools: "AI tools",
|
||||
},
|
||||
},
|
||||
categories: {
|
||||
essentials: "Cơ bản",
|
||||
adjustments: "Điều chỉnh",
|
||||
|
||||
@@ -40,6 +40,96 @@ export const zhCN: TranslationKeys = {
|
||||
pageNotFound: "页面未找到",
|
||||
pageNotFoundDescription: "您查找的页面不存在或已被移动。",
|
||||
},
|
||||
feedback: {
|
||||
navLabel: "Share feedback",
|
||||
dialogTitle: "Share feedback",
|
||||
toolDialogTitle: "How did this tool work?",
|
||||
failedDialogTitle: "Report a tool issue",
|
||||
adminDialogTitle: "How was setup?",
|
||||
closeLabel: "Close feedback dialog",
|
||||
privacyNote:
|
||||
"Feedback is sent only when you submit it. SnapOtter does not include your files, file contents, file names, or private outputs.",
|
||||
typeLabel: "Feedback type",
|
||||
sentimentLabel: "Quick rating",
|
||||
messageLabel: "Feedback",
|
||||
messagePlaceholder: "Tell us what worked, what broke, or what would make SnapOtter better.",
|
||||
improveFirstLabel: "What should we improve first?",
|
||||
improveFirstPlaceholder: "Tell us what made setup harder than it should be.",
|
||||
contactOkLabel: "You can contact me about this feedback.",
|
||||
emailPlaceholder: "Email (optional)",
|
||||
namePlaceholder: "Name (optional)",
|
||||
companyPlaceholder: "Company or org (optional)",
|
||||
submit: "Send feedback",
|
||||
submitting: "Sending...",
|
||||
submitFailed: "Feedback could not be sent. Please try again.",
|
||||
thanksTitle: "Thanks for the feedback.",
|
||||
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
||||
quickThanks: "Thanks for the signal.",
|
||||
reportIssue: "Report issue",
|
||||
toolPromptTitle: "How did this tool work?",
|
||||
toolPromptDescription: "One quick signal helps us improve SnapOtter.",
|
||||
dismissPrompt: "Dismiss feedback prompt",
|
||||
workedWell: "Worked well",
|
||||
dontAskAgain: "Don't ask again",
|
||||
shareFeedback: "Share feedback",
|
||||
remindLater: "Remind me later",
|
||||
adminCardTitle: "How was setup?",
|
||||
adminCardDescription:
|
||||
"A quick note from admins helps us improve Docker, source installs, and docs.",
|
||||
installMethodLabel: "Install method",
|
||||
usageTypeLabel: "Use case",
|
||||
frictionAreaLabel: "Hardest setup area",
|
||||
importantAreasLabel: "Most important areas",
|
||||
sentiments: {
|
||||
great: "Great",
|
||||
okay: "Okay",
|
||||
issue: "Had an issue",
|
||||
missing: "Missing something",
|
||||
bug: "Bug",
|
||||
idea: "Idea",
|
||||
other: "Other",
|
||||
},
|
||||
types: {
|
||||
bug: "Bug",
|
||||
feature_request: "Feature request",
|
||||
confusing_ux: "Confusing UX",
|
||||
performance: "Performance",
|
||||
other: "Other",
|
||||
},
|
||||
installMethods: {
|
||||
docker: "Docker",
|
||||
docker_compose: "Docker Compose",
|
||||
source: "Built from source",
|
||||
cloud: "Cloud/demo",
|
||||
other: "Other",
|
||||
},
|
||||
usageTypes: {
|
||||
personal: "Personal",
|
||||
team_internal: "Team/internal",
|
||||
business_workflow: "Business workflow",
|
||||
education: "Education",
|
||||
evaluating: "Evaluating",
|
||||
},
|
||||
frictionAreas: {
|
||||
smooth: "Nothing, setup was smooth",
|
||||
docker: "Docker/container setup",
|
||||
environment_variables: "Environment variables",
|
||||
auth: "Authentication/SSO",
|
||||
storage: "Storage/uploads",
|
||||
workers: "Workers/queues",
|
||||
ai_tools: "AI tools",
|
||||
docs: "Documentation",
|
||||
performance: "Performance/resources",
|
||||
other: "Other",
|
||||
},
|
||||
importantAreas: {
|
||||
images: "Images",
|
||||
pdf_docs: "PDF/docs",
|
||||
video_audio: "Video/audio",
|
||||
batch_workflows: "Batch workflows",
|
||||
ai_tools: "AI tools",
|
||||
},
|
||||
},
|
||||
categories: {
|
||||
essentials: "基础工具",
|
||||
adjustments: "调整",
|
||||
|
||||
@@ -40,6 +40,96 @@ export const zhTW: TranslationKeys = {
|
||||
pageNotFound: "找不到頁面",
|
||||
pageNotFoundDescription: "您要找的頁面不存在或已被移動。",
|
||||
},
|
||||
feedback: {
|
||||
navLabel: "Share feedback",
|
||||
dialogTitle: "Share feedback",
|
||||
toolDialogTitle: "How did this tool work?",
|
||||
failedDialogTitle: "Report a tool issue",
|
||||
adminDialogTitle: "How was setup?",
|
||||
closeLabel: "Close feedback dialog",
|
||||
privacyNote:
|
||||
"Feedback is sent only when you submit it. SnapOtter does not include your files, file contents, file names, or private outputs.",
|
||||
typeLabel: "Feedback type",
|
||||
sentimentLabel: "Quick rating",
|
||||
messageLabel: "Feedback",
|
||||
messagePlaceholder: "Tell us what worked, what broke, or what would make SnapOtter better.",
|
||||
improveFirstLabel: "What should we improve first?",
|
||||
improveFirstPlaceholder: "Tell us what made setup harder than it should be.",
|
||||
contactOkLabel: "You can contact me about this feedback.",
|
||||
emailPlaceholder: "Email (optional)",
|
||||
namePlaceholder: "Name (optional)",
|
||||
companyPlaceholder: "Company or org (optional)",
|
||||
submit: "Send feedback",
|
||||
submitting: "Sending...",
|
||||
submitFailed: "Feedback could not be sent. Please try again.",
|
||||
thanksTitle: "Thanks for the feedback.",
|
||||
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
|
||||
quickThanks: "Thanks for the signal.",
|
||||
reportIssue: "Report issue",
|
||||
toolPromptTitle: "How did this tool work?",
|
||||
toolPromptDescription: "One quick signal helps us improve SnapOtter.",
|
||||
dismissPrompt: "Dismiss feedback prompt",
|
||||
workedWell: "Worked well",
|
||||
dontAskAgain: "Don't ask again",
|
||||
shareFeedback: "Share feedback",
|
||||
remindLater: "Remind me later",
|
||||
adminCardTitle: "How was setup?",
|
||||
adminCardDescription:
|
||||
"A quick note from admins helps us improve Docker, source installs, and docs.",
|
||||
installMethodLabel: "Install method",
|
||||
usageTypeLabel: "Use case",
|
||||
frictionAreaLabel: "Hardest setup area",
|
||||
importantAreasLabel: "Most important areas",
|
||||
sentiments: {
|
||||
great: "Great",
|
||||
okay: "Okay",
|
||||
issue: "Had an issue",
|
||||
missing: "Missing something",
|
||||
bug: "Bug",
|
||||
idea: "Idea",
|
||||
other: "Other",
|
||||
},
|
||||
types: {
|
||||
bug: "Bug",
|
||||
feature_request: "Feature request",
|
||||
confusing_ux: "Confusing UX",
|
||||
performance: "Performance",
|
||||
other: "Other",
|
||||
},
|
||||
installMethods: {
|
||||
docker: "Docker",
|
||||
docker_compose: "Docker Compose",
|
||||
source: "Built from source",
|
||||
cloud: "Cloud/demo",
|
||||
other: "Other",
|
||||
},
|
||||
usageTypes: {
|
||||
personal: "Personal",
|
||||
team_internal: "Team/internal",
|
||||
business_workflow: "Business workflow",
|
||||
education: "Education",
|
||||
evaluating: "Evaluating",
|
||||
},
|
||||
frictionAreas: {
|
||||
smooth: "Nothing, setup was smooth",
|
||||
docker: "Docker/container setup",
|
||||
environment_variables: "Environment variables",
|
||||
auth: "Authentication/SSO",
|
||||
storage: "Storage/uploads",
|
||||
workers: "Workers/queues",
|
||||
ai_tools: "AI tools",
|
||||
docs: "Documentation",
|
||||
performance: "Performance/resources",
|
||||
other: "Other",
|
||||
},
|
||||
importantAreas: {
|
||||
images: "Images",
|
||||
pdf_docs: "PDF/docs",
|
||||
video_audio: "Video/audio",
|
||||
batch_workflows: "Batch workflows",
|
||||
ai_tools: "AI tools",
|
||||
},
|
||||
},
|
||||
categories: {
|
||||
essentials: "基本工具",
|
||||
adjustments: "調整",
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { db, schema } from "../../../apps/api/src/db/index.js";
|
||||
import {
|
||||
__resetGateForTests,
|
||||
refreshAnalyticsGate,
|
||||
} from "../../../apps/api/src/lib/analytics-gate.js";
|
||||
import { buildTestApp, loginAsAdmin, type TestApp } from "../test-server.js";
|
||||
|
||||
const captureFeedback = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
||||
|
||||
vi.mock("../../../apps/api/src/lib/analytics.js", async (importOriginal) => {
|
||||
const actual: Record<string, unknown> = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
captureFeedback,
|
||||
};
|
||||
});
|
||||
|
||||
let testApp: TestApp;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(schema.settings).where(eq(schema.settings.key, "analyticsEnabled"));
|
||||
delete process.env.ANALYTICS_BAKED_OVERRIDE;
|
||||
captureFeedback.mockClear();
|
||||
__resetGateForTests();
|
||||
});
|
||||
|
||||
describe("POST /api/v1/feedback", () => {
|
||||
it("requires authentication", async () => {
|
||||
const res = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/feedback",
|
||||
payload: { source: "global", sentiment: "great" },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it("rejects empty feedback payloads", async () => {
|
||||
const token = await loginAsAdmin(testApp.app);
|
||||
const res = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/feedback",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { source: "global" },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(JSON.parse(res.body)).toMatchObject({
|
||||
code: "VALIDATION_ERROR",
|
||||
});
|
||||
});
|
||||
|
||||
it("declines capture when analytics is disabled", async () => {
|
||||
const token = await loginAsAdmin(testApp.app);
|
||||
const res = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/feedback",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
source: "tool_result",
|
||||
surveyId: "tool-result-v1",
|
||||
promptVariant: "inline-v1",
|
||||
sentiment: "great",
|
||||
toolId: "resize",
|
||||
jobStatus: "completed",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.body)).toEqual({ ok: true, accepted: false });
|
||||
expect(captureFeedback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts explicit feedback with survey, prompt, friction, and safe error fields", async () => {
|
||||
process.env.ANALYTICS_BAKED_OVERRIDE = "on";
|
||||
await refreshAnalyticsGate();
|
||||
const token = await loginAsAdmin(testApp.app);
|
||||
|
||||
const res = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/feedback",
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
"x-posthog-distinct-id": "distinct-test",
|
||||
},
|
||||
payload: {
|
||||
source: "admin_installer",
|
||||
surveyId: "admin-install-v1",
|
||||
promptVariant: "settings-card-v1",
|
||||
sentiment: "issue",
|
||||
feedbackType: "bug",
|
||||
message: "S3 setup took guessing.",
|
||||
contactOk: true,
|
||||
contactEmail: "user@example.com",
|
||||
contactName: "Pat",
|
||||
company: "Example Co",
|
||||
installMethod: "docker_compose",
|
||||
usageType: "team_internal",
|
||||
frictionArea: "environment_variables",
|
||||
importantAreas: ["pdf_docs", "batch_workflows"],
|
||||
errorCategory: "processing_error",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.body)).toEqual({ ok: true, accepted: true });
|
||||
expect(captureFeedback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
source: "admin_installer",
|
||||
survey_id: "admin-install-v1",
|
||||
prompt_variant: "settings-card-v1",
|
||||
feedback_type: "bug",
|
||||
contact_ok: true,
|
||||
contact_email: "user@example.com",
|
||||
contact_name: "Pat",
|
||||
company: "Example Co",
|
||||
install_method: "docker_compose",
|
||||
usage_type: "team_internal",
|
||||
friction_area: "environment_variables",
|
||||
important_areas: ["pdf_docs", "batch_workflows"],
|
||||
error_category: "processing_error",
|
||||
}),
|
||||
"distinct-test",
|
||||
);
|
||||
});
|
||||
|
||||
it("drops identifying contact fields when contact consent is not checked", async () => {
|
||||
process.env.ANALYTICS_BAKED_OVERRIDE = "on";
|
||||
await refreshAnalyticsGate();
|
||||
const token = await loginAsAdmin(testApp.app);
|
||||
|
||||
const res = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/feedback",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
source: "global",
|
||||
surveyId: "global-feedback-v1",
|
||||
sentiment: "okay",
|
||||
message: "Trying this with the team.",
|
||||
contactOk: false,
|
||||
contactEmail: "user@example.com",
|
||||
contactName: "Pat",
|
||||
company: "Example Co",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.body)).toEqual({ ok: true, accepted: true });
|
||||
expect(captureFeedback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
contact_ok: false,
|
||||
contact_email: undefined,
|
||||
contact_name: undefined,
|
||||
company: undefined,
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid survey ids", async () => {
|
||||
const token = await loginAsAdmin(testApp.app);
|
||||
const res = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/feedback",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
source: "global",
|
||||
surveyId: "not-real",
|
||||
sentiment: "great",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(JSON.parse(res.body).details).toContainEqual(
|
||||
expect.objectContaining({ path: "surveyId" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid prompt variants", async () => {
|
||||
const token = await loginAsAdmin(testApp.app);
|
||||
const res = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/feedback",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
source: "global",
|
||||
surveyId: "global-feedback-v1",
|
||||
promptVariant: "Inline V1!",
|
||||
sentiment: "great",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(JSON.parse(res.body).details).toContainEqual(
|
||||
expect.objectContaining({ path: "promptVariant" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid friction areas", async () => {
|
||||
const token = await loginAsAdmin(testApp.app);
|
||||
const res = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/feedback",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
source: "admin_installer",
|
||||
surveyId: "admin-install-v1",
|
||||
installMethod: "docker",
|
||||
frictionArea: "leaky_file_path",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(JSON.parse(res.body).details).toContainEqual(
|
||||
expect.objectContaining({ path: "frictionArea" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -60,6 +60,7 @@ import { auditLogRoutes } from "../../apps/api/src/routes/audit-log.js";
|
||||
import { registerBatchRoutes } from "../../apps/api/src/routes/batch.js";
|
||||
import { docsRoutes } from "../../apps/api/src/routes/docs.js";
|
||||
import { registerEnterpriseRoutes } from "../../apps/api/src/routes/enterprise/index.js";
|
||||
import { feedbackRoutes } from "../../apps/api/src/routes/feedback.js";
|
||||
import { registerFetchUrlsRoute } from "../../apps/api/src/routes/fetch-urls.js";
|
||||
import { fileRoutes } from "../../apps/api/src/routes/files.js";
|
||||
import { registerMemeTemplates } from "../../apps/api/src/routes/meme-templates.js";
|
||||
@@ -236,6 +237,9 @@ export async function buildTestApp(): Promise<TestApp> {
|
||||
// Analytics routes
|
||||
await analyticsRoutes(app);
|
||||
|
||||
// Explicit customer feedback capture
|
||||
await feedbackRoutes(app);
|
||||
|
||||
// API docs (Scalar)
|
||||
await docsRoutes(app);
|
||||
|
||||
|
||||
@@ -221,3 +221,62 @@ describe("trackEvent", () => {
|
||||
await expect(mod.trackEvent("test_event", { key: "value" })).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("captureFeedback", () => {
|
||||
it("captures feedback_submitted with explicit feedback properties", async () => {
|
||||
bakedConfig.enabled = true;
|
||||
bakedConfig.posthogApiKey = "phc_test_key";
|
||||
await mod.initAnalytics();
|
||||
|
||||
await mod.captureFeedback(
|
||||
{
|
||||
source: "admin_installer",
|
||||
survey_id: "admin-install-v1",
|
||||
prompt_variant: "settings-card-v1",
|
||||
sentiment: "issue",
|
||||
feedback_type: "bug",
|
||||
message: "Docs need a complete S3 example.",
|
||||
contact_ok: true,
|
||||
contact_email: "admin@example.com",
|
||||
contact_name: "Pat",
|
||||
company: "Example Co",
|
||||
install_method: "docker_compose",
|
||||
usage_type: "team_internal",
|
||||
friction_area: "environment_variables",
|
||||
important_areas: ["pdf_docs", "batch_workflows"],
|
||||
error_category: "processing_error",
|
||||
},
|
||||
"distinct-feedback",
|
||||
);
|
||||
|
||||
expect(mockCapture).toHaveBeenCalledWith({
|
||||
distinctId: "distinct-feedback",
|
||||
event: "feedback_submitted",
|
||||
properties: expect.objectContaining({
|
||||
feedback_version: 1,
|
||||
source: "admin_installer",
|
||||
survey_id: "admin-install-v1",
|
||||
prompt_variant: "settings-card-v1",
|
||||
contact_ok: true,
|
||||
contact_email: "admin@example.com",
|
||||
install_method: "docker_compose",
|
||||
usage_type: "team_internal",
|
||||
friction_area: "environment_variables",
|
||||
important_areas: ["pdf_docs", "batch_workflows"],
|
||||
error_category: "processing_error",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("does nothing when analytics is disabled", async () => {
|
||||
bakedConfig.enabled = false;
|
||||
|
||||
await mod.captureFeedback({
|
||||
source: "global",
|
||||
contact_ok: false,
|
||||
message: "A message",
|
||||
});
|
||||
|
||||
expect(mockCapture).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,8 +2,8 @@ import { ANALYTICS_EVENTS } from "@snapotter/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("ANALYTICS_EVENTS", () => {
|
||||
it("has exactly 12 event keys", () => {
|
||||
expect(Object.keys(ANALYTICS_EVENTS)).toHaveLength(12);
|
||||
it("has exactly 13 event keys", () => {
|
||||
expect(Object.keys(ANALYTICS_EVENTS)).toHaveLength(13);
|
||||
});
|
||||
|
||||
it("contains the expected keys", () => {
|
||||
@@ -19,6 +19,7 @@ describe("ANALYTICS_EVENTS", () => {
|
||||
expect(ANALYTICS_EVENTS).toHaveProperty("AI_BUNDLE_ACTION");
|
||||
expect(ANALYTICS_EVENTS).toHaveProperty("AI_BUNDLE_PROMPTED");
|
||||
expect(ANALYTICS_EVENTS).toHaveProperty("BATCH_PROCESSED");
|
||||
expect(ANALYTICS_EVENTS).toHaveProperty("FEEDBACK_SUBMITTED");
|
||||
});
|
||||
|
||||
it("all event values are strings", () => {
|
||||
@@ -43,6 +44,10 @@ describe("ANALYTICS_EVENTS", () => {
|
||||
expect(ANALYTICS_EVENTS.AI_BUNDLE_ACTION).toBe("ai_bundle_action");
|
||||
});
|
||||
|
||||
it("FEEDBACK_SUBMITTED has the correct snake_case value", () => {
|
||||
expect(ANALYTICS_EVENTS.FEEDBACK_SUBMITTED).toBe("feedback_submitted");
|
||||
});
|
||||
|
||||
it("all values follow snake_case convention", () => {
|
||||
for (const value of Object.values(ANALYTICS_EVENTS)) {
|
||||
expect(value).toMatch(/^[a-z][a-z0-9_]*$/);
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { AdminInstallFeedbackCard } from "@/components/feedback/admin-install-feedback-card";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("AdminInstallFeedbackCard", () => {
|
||||
it("does not render when hidden", () => {
|
||||
render(
|
||||
<AdminInstallFeedbackCard
|
||||
visible={false}
|
||||
onShare={vi.fn()}
|
||||
onRemindLater={vi.fn()}
|
||||
onDismissForever={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText("How was setup?")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders quiet admin install feedback actions", () => {
|
||||
render(
|
||||
<AdminInstallFeedbackCard
|
||||
visible={true}
|
||||
onShare={vi.fn()}
|
||||
onRemindLater={vi.fn()}
|
||||
onDismissForever={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("How was setup?")).toBeDefined();
|
||||
expect(screen.getByRole("button", { name: "Share feedback" })).toBeDefined();
|
||||
expect(screen.getByRole("button", { name: "Remind me later" })).toBeDefined();
|
||||
expect(screen.getByRole("button", { name: "Don't ask again" })).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls the supplied action callbacks", () => {
|
||||
const onShare = vi.fn();
|
||||
const onRemindLater = vi.fn();
|
||||
const onDismissForever = vi.fn();
|
||||
|
||||
render(
|
||||
<AdminInstallFeedbackCard
|
||||
visible={true}
|
||||
onShare={onShare}
|
||||
onRemindLater={onRemindLater}
|
||||
onDismissForever={onDismissForever}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Share feedback" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remind me later" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Don't ask again" }));
|
||||
|
||||
expect(onShare).toHaveBeenCalledTimes(1);
|
||||
expect(onRemindLater).toHaveBeenCalledTimes(1);
|
||||
expect(onDismissForever).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { FeedbackDialog } from "@/components/feedback/feedback-dialog";
|
||||
|
||||
const submitFeedback = vi.hoisted(() => vi.fn().mockResolvedValue({ ok: true, accepted: true }));
|
||||
|
||||
vi.mock("@/lib/feedback", async (importOriginal) => {
|
||||
const actual: Record<string, unknown> = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
submitFeedback,
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
submitFeedback.mockClear();
|
||||
});
|
||||
|
||||
describe("FeedbackDialog", () => {
|
||||
it("submits admin install feedback with install-specific fields", async () => {
|
||||
render(<FeedbackDialog open={true} source="admin_installer" onClose={vi.fn()} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Install method"), {
|
||||
target: { value: "docker_compose" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Use case"), {
|
||||
target: { value: "team_internal" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Hardest setup area"), {
|
||||
target: { value: "environment_variables" },
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("PDF/docs"));
|
||||
fireEvent.click(screen.getByLabelText("Batch workflows"));
|
||||
fireEvent.change(screen.getByLabelText("What should we improve first?"), {
|
||||
target: { value: "The S3 example needs one complete compose snippet." },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send feedback" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(submitFeedback).toHaveBeenCalledWith({
|
||||
source: "admin_installer",
|
||||
surveyId: "admin-install-v1",
|
||||
promptVariant: "settings-card-v1",
|
||||
feedbackType: "other",
|
||||
message: "The S3 example needs one complete compose snippet.",
|
||||
contactOk: false,
|
||||
contactEmail: undefined,
|
||||
contactName: undefined,
|
||||
company: undefined,
|
||||
installMethod: "docker_compose",
|
||||
usageType: "team_internal",
|
||||
frictionArea: "environment_variables",
|
||||
importantAreas: ["pdf_docs", "batch_workflows"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("does not submit contact fields without contact consent", async () => {
|
||||
render(<FeedbackDialog open={true} source="global" onClose={vi.fn()} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Feedback"), {
|
||||
target: { value: "I want a keyboard shortcut for download." },
|
||||
});
|
||||
expect(screen.queryByPlaceholderText("Email (optional)")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send feedback" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(submitFeedback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
source: "global",
|
||||
surveyId: "global-feedback-v1",
|
||||
contactOk: false,
|
||||
contactEmail: undefined,
|
||||
contactName: undefined,
|
||||
company: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { shouldShowInstallFeedbackCard } from "@/lib/feedback";
|
||||
|
||||
const NOW = new Date("2026-01-15T00:00:00Z").getTime();
|
||||
|
||||
describe("shouldShowInstallFeedbackCard", () => {
|
||||
it("shows only for admins after analytics config is loaded and enabled", () => {
|
||||
expect(
|
||||
shouldShowInstallFeedbackCard({
|
||||
settings: {},
|
||||
role: "admin",
|
||||
analyticsConfigLoaded: true,
|
||||
analyticsEnabled: true,
|
||||
now: NOW,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
shouldShowInstallFeedbackCard({
|
||||
settings: {},
|
||||
role: "user",
|
||||
analyticsConfigLoaded: true,
|
||||
analyticsEnabled: true,
|
||||
now: NOW,
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
shouldShowInstallFeedbackCard({
|
||||
settings: {},
|
||||
role: "admin",
|
||||
analyticsConfigLoaded: false,
|
||||
analyticsEnabled: true,
|
||||
now: NOW,
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
shouldShowInstallFeedbackCard({
|
||||
settings: {},
|
||||
role: "admin",
|
||||
analyticsConfigLoaded: true,
|
||||
analyticsEnabled: false,
|
||||
now: NOW,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("stays hidden after submit or permanent dismiss", () => {
|
||||
expect(
|
||||
shouldShowInstallFeedbackCard({
|
||||
settings: { "feedback.install.submittedAt": "2026-01-14T00:00:00Z" },
|
||||
role: "admin",
|
||||
analyticsConfigLoaded: true,
|
||||
analyticsEnabled: true,
|
||||
now: NOW,
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
shouldShowInstallFeedbackCard({
|
||||
settings: { "feedback.install.dismissedAt": "2026-01-14T00:00:00Z" },
|
||||
role: "admin",
|
||||
analyticsConfigLoaded: true,
|
||||
analyticsEnabled: true,
|
||||
now: NOW,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("honors snooze until the stored timestamp expires", () => {
|
||||
expect(
|
||||
shouldShowInstallFeedbackCard({
|
||||
settings: { "feedback.install.snoozedUntil": "2026-01-16T00:00:00Z" },
|
||||
role: "admin",
|
||||
analyticsConfigLoaded: true,
|
||||
analyticsEnabled: true,
|
||||
now: NOW,
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
shouldShowInstallFeedbackCard({
|
||||
settings: { "feedback.install.snoozedUntil": "2026-01-14T00:00:00Z" },
|
||||
role: "admin",
|
||||
analyticsConfigLoaded: true,
|
||||
analyticsEnabled: true,
|
||||
now: NOW,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ToolFeedbackPrompt } from "@/components/feedback/tool-feedback-prompt";
|
||||
import { useAnalyticsStore } from "@/stores/analytics-store";
|
||||
|
||||
const submitFeedback = vi.hoisted(() => vi.fn().mockResolvedValue({ ok: true, accepted: true }));
|
||||
const storageMap = vi.hoisted(() => new Map<string, string>());
|
||||
const localStorageMock = vi.hoisted(() => ({
|
||||
getItem: vi.fn((key: string) => storageMap.get(key) ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => storageMap.set(key, value)),
|
||||
removeItem: vi.fn((key: string) => storageMap.delete(key)),
|
||||
clear: vi.fn(() => storageMap.clear()),
|
||||
key: vi.fn((_index: number) => null),
|
||||
get length() {
|
||||
return storageMap.size;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/feedback", async (importOriginal) => {
|
||||
const actual: Record<string, unknown> = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
submitFeedback,
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("localStorage", localStorageMock);
|
||||
localStorage.clear();
|
||||
submitFeedback.mockClear();
|
||||
localStorageMock.getItem.mockClear();
|
||||
localStorageMock.setItem.mockClear();
|
||||
localStorageMock.removeItem.mockClear();
|
||||
localStorageMock.clear.mockClear();
|
||||
vi.spyOn(Date, "now").mockReturnValue(new Date("2026-01-15T00:00:00Z").getTime());
|
||||
useAnalyticsStore.setState({
|
||||
configLoaded: true,
|
||||
config: {
|
||||
enabled: true,
|
||||
posthogApiKey: "phc_test",
|
||||
posthogHost: "https://us.i.posthog.com",
|
||||
sentryDsn: "",
|
||||
sampleRate: 1,
|
||||
instanceId: "instance-1",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("ToolFeedbackPrompt", () => {
|
||||
it("renders when analytics feedback capture is enabled", () => {
|
||||
render(<ToolFeedbackPrompt toolId="resize" />);
|
||||
|
||||
expect(screen.getByText("How did this tool work?")).toBeDefined();
|
||||
expect(screen.getByRole("button", { name: "Worked well" })).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not render when analytics is disabled", () => {
|
||||
useAnalyticsStore.setState({
|
||||
configLoaded: true,
|
||||
config: {
|
||||
enabled: false,
|
||||
posthogApiKey: "",
|
||||
posthogHost: "",
|
||||
sentryDsn: "",
|
||||
sampleRate: 0,
|
||||
instanceId: "",
|
||||
},
|
||||
});
|
||||
|
||||
render(<ToolFeedbackPrompt toolId="resize" />);
|
||||
|
||||
expect(screen.queryByText("How did this tool work?")).toBeNull();
|
||||
});
|
||||
|
||||
it("submits quick positive feedback with survey and prompt metadata", async () => {
|
||||
render(<ToolFeedbackPrompt toolId="resize" />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Worked well" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(submitFeedback).toHaveBeenCalledWith({
|
||||
source: "tool_result",
|
||||
surveyId: "tool-result-v1",
|
||||
promptVariant: "inline-v1",
|
||||
sentiment: "great",
|
||||
feedbackType: "other",
|
||||
toolId: "resize",
|
||||
jobStatus: "completed",
|
||||
});
|
||||
});
|
||||
|
||||
expect(screen.getByText("Thanks for the signal.")).toBeDefined();
|
||||
expect(localStorage.getItem("snapotter-feedback-last-prompt-at")).toBeTruthy();
|
||||
expect(localStorage.getItem("snapotter-feedback-tool-prompt:resize")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("is suppressed during the 30-day global cooldown", () => {
|
||||
localStorage.setItem(
|
||||
"snapotter-feedback-last-prompt-at",
|
||||
String(Date.now() - 29 * 24 * 60 * 60 * 1000),
|
||||
);
|
||||
|
||||
render(<ToolFeedbackPrompt toolId="resize" />);
|
||||
|
||||
expect(screen.queryByText("How did this tool work?")).toBeNull();
|
||||
});
|
||||
|
||||
it("is suppressed during the 90-day per-tool cooldown", () => {
|
||||
localStorage.setItem(
|
||||
"snapotter-feedback-tool-prompt:resize",
|
||||
String(Date.now() - 89 * 24 * 60 * 60 * 1000),
|
||||
);
|
||||
|
||||
render(<ToolFeedbackPrompt toolId="resize" />);
|
||||
|
||||
expect(screen.queryByText("How did this tool work?")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows after the global and per-tool cooldowns have both elapsed", () => {
|
||||
localStorage.setItem(
|
||||
"snapotter-feedback-last-prompt-at",
|
||||
String(Date.now() - 31 * 24 * 60 * 60 * 1000),
|
||||
);
|
||||
localStorage.setItem(
|
||||
"snapotter-feedback-tool-prompt:resize",
|
||||
String(Date.now() - 91 * 24 * 60 * 60 * 1000),
|
||||
);
|
||||
|
||||
render(<ToolFeedbackPrompt toolId="resize" />);
|
||||
|
||||
expect(screen.getByText("How did this tool work?")).toBeDefined();
|
||||
});
|
||||
|
||||
it("supports Don't ask again suppression", () => {
|
||||
const { unmount } = render(<ToolFeedbackPrompt toolId="resize" />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Don't ask again" }));
|
||||
|
||||
expect(localStorage.getItem("snapotter-feedback-prompts-disabled")).toBe("true");
|
||||
expect(screen.queryByText("How did this tool work?")).toBeNull();
|
||||
|
||||
unmount();
|
||||
render(<ToolFeedbackPrompt toolId="convert" />);
|
||||
|
||||
expect(screen.queryByText("How did this tool work?")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"$schema": "https://turbo.build/schema.json",
|
||||
"globalEnv": ["ANALYTICS_BAKED_OVERRIDE"],
|
||||
"tasks": {
|
||||
"dev": {
|
||||
"cache": false,
|
||||
|
||||
Reference in New Issue
Block a user