feat(feedback): always-on nav button with GitHub/email handoff when analytics is off (#428)

Keep the top-nav feedback button always visible (icon plus label on desktop, icon-only on mobile) instead of hiding it when an instance opts out of analytics. When analytics is off, the dialog keeps the typed message and hands off to a prefilled GitHub issue plus a contact@snapotter.com email, with no fake Thanks. Adds a feedback.yml issue template, URL builders, and feedback strings across all 21 locales.

Claude-Session: https://claude.ai/code/session_01XVrHKXwzZDWBWgkGQdPZ3A
This commit is contained in:
SnapOtter
2026-07-04 17:37:12 +08:00
committed by GitHub
parent 7b04317ed2
commit 5dcc06a99e
28 changed files with 302 additions and 10 deletions
+16
View File
@@ -0,0 +1,16 @@
name: General feedback
description: Share general feedback, ideas, or reactions about SnapOtter.
title: "[Feedback]: "
labels: ["feedback"]
body:
- type: markdown
attributes:
value: Thanks for taking a moment to share feedback.
- type: textarea
id: details
attributes:
label: Your feedback
description: What is on your mind?
placeholder: Tell us what you think...
validations:
required: true
@@ -9,6 +9,8 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context"; import { useTranslation } from "@/contexts/i18n-context";
import { useFocusTrap } from "@/hooks/use-focus-trap"; import { useFocusTrap } from "@/hooks/use-focus-trap";
import { import {
buildFeedbackGithubUrl,
buildFeedbackMailtoUrl,
type FeedbackErrorCategory, type FeedbackErrorCategory,
type FeedbackFrictionArea, type FeedbackFrictionArea,
type FeedbackImportantArea, type FeedbackImportantArea,
@@ -115,6 +117,7 @@ export function FeedbackDialog({
const isAdminInstall = source === "admin_installer"; const isAdminInstall = source === "admin_installer";
const isSearchMiss = source === "search_miss"; const isSearchMiss = source === "search_miss";
const isGlobal = source === "global";
const canSubmit = Boolean( const canSubmit = Boolean(
message.trim() || sentiment || feedbackType !== "other" || isAdminInstall, message.trim() || sentiment || feedbackType !== "other" || isAdminInstall,
); );
@@ -203,7 +206,28 @@ export function FeedbackDialog({
{submitted ? ( {submitted ? (
<div className="p-6 space-y-4"> <div className="p-6 space-y-4">
{isSearchMiss && !accepted ? ( {isGlobal && !accepted ? (
<div className="space-y-3">
<p className="text-sm text-foreground">{t.feedback.offlineDescription}</p>
<p className="text-xs text-muted-foreground">{t.feedback.offlinePublicNote}</p>
<div className="flex flex-col gap-2">
<a
href={buildFeedbackGithubUrl(message)}
target="_blank"
rel="noopener noreferrer"
className="w-full text-center py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90"
>
{t.feedback.offlineGithubButton}
</a>
<a
href={buildFeedbackMailtoUrl(message)}
className="w-full text-center py-2 rounded-lg border border-border text-sm text-muted-foreground hover:bg-muted hover:text-foreground"
>
{t.feedback.offlineEmailButton}
</a>
</div>
</div>
) : isSearchMiss && !accepted ? (
<p className="text-sm text-foreground"> <p className="text-sm text-foreground">
<a <a
href={buildToolRequestDiscussionUrl(searchQuery ?? "")} href={buildToolRequestDiscussionUrl(searchQuery ?? "")}
@@ -1,7 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useMobile } from "@/hooks/use-mobile"; import { useMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useAnalyticsStore } from "@/stores/analytics-store";
import { useConnectionStore } from "@/stores/connection-store"; import { useConnectionStore } from "@/stores/connection-store";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
import { FeedbackDialog } from "../feedback/feedback-dialog"; import { FeedbackDialog } from "../feedback/feedback-dialog";
@@ -22,10 +21,8 @@ export function AppLayout({ children, breadcrumb, navVariant }: AppLayoutProps)
const [helpOpen, setHelpOpen] = useState(false); const [helpOpen, setHelpOpen] = useState(false);
const [feedbackOpen, setFeedbackOpen] = useState(false); const [feedbackOpen, setFeedbackOpen] = useState(false);
const isMobile = useMobile(); const isMobile = useMobile();
const analyticsConfig = useAnalyticsStore((s) => s.config);
const connectionStatus = useConnectionStore((s) => s.status); const connectionStatus = useConnectionStore((s) => s.status);
const bannerVisible = connectionStatus !== "connected"; const bannerVisible = connectionStatus !== "connected";
const feedbackEnabled = Boolean(analyticsConfig?.enabled);
// Load global settings (disabled tools, experimental flag, default theme) on // Load global settings (disabled tools, experimental flag, default theme) on
// every authenticated page, not just the home grid. Without this, navigating // every authenticated page, not just the home grid. Without this, navigating
@@ -51,7 +48,6 @@ export function AppLayout({ children, breadcrumb, navVariant }: AppLayoutProps)
onHelpClick={() => setHelpOpen(true)} onHelpClick={() => setHelpOpen(true)}
onFeedbackClick={() => setFeedbackOpen(true)} onFeedbackClick={() => setFeedbackOpen(true)}
onSettingsClick={() => setSettingsOpen(true)} onSettingsClick={() => setSettingsOpen(true)}
feedbackEnabled={feedbackEnabled}
/> />
{/* Main content area */} {/* Main content area */}
+4 -5
View File
@@ -28,7 +28,6 @@ interface TopNavProps {
onHelpClick: () => void; onHelpClick: () => void;
onFeedbackClick?: () => void; onFeedbackClick?: () => void;
onSettingsClick: () => void; onSettingsClick: () => void;
feedbackEnabled?: boolean;
} }
interface NavLinkItem { interface NavLinkItem {
@@ -59,7 +58,6 @@ export function TopNav({
onHelpClick, onHelpClick,
onFeedbackClick, onFeedbackClick,
onSettingsClick, onSettingsClick,
feedbackEnabled = false,
}: TopNavProps) { }: TopNavProps) {
const location = useLocation(); const location = useLocation();
const isMobile = useMobile(); const isMobile = useMobile();
@@ -114,7 +112,7 @@ export function TopNav({
<div className="flex-1" /> <div className="flex-1" />
{feedbackEnabled && onFeedbackClick && ( {onFeedbackClick && (
<button <button
type="button" type="button"
onClick={onFeedbackClick} onClick={onFeedbackClick}
@@ -254,12 +252,12 @@ export function TopNav({
{!isMobile && <ThemeToggle isDark={isDark} />} {!isMobile && <ThemeToggle isDark={isDark} />}
{!isMobile && <LanguageSelector isDark={isDark} />} {!isMobile && <LanguageSelector isDark={isDark} />}
{feedbackEnabled && onFeedbackClick && ( {onFeedbackClick && (
<button <button
type="button" type="button"
onClick={onFeedbackClick} onClick={onFeedbackClick}
className={cn( className={cn(
"p-1.5 rounded-md transition-colors", "flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-sm font-medium transition-colors",
isDark isDark
? "text-[#aaa] hover:text-[#e0e0e0] hover:bg-[#333]" ? "text-[#aaa] hover:text-[#e0e0e0] hover:bg-[#333]"
: "text-muted-foreground hover:text-foreground hover:bg-muted", : "text-muted-foreground hover:text-foreground hover:bg-muted",
@@ -267,6 +265,7 @@ export function TopNav({
aria-label={t.feedback.navLabel} aria-label={t.feedback.navLabel}
> >
<MessageSquare className="h-4 w-4" /> <MessageSquare className="h-4 w-4" />
{t.feedback.navButtonLabel}
</button> </button>
)} )}
+30
View File
@@ -155,3 +155,33 @@ export function shouldShowUsageSurvey({
export async function submitFeedback(payload: FeedbackPayload): Promise<FeedbackResponse> { export async function submitFeedback(payload: FeedbackPayload): Promise<FeedbackResponse> {
return apiPost<FeedbackResponse>("/v1/feedback", payload); return apiPost<FeedbackResponse>("/v1/feedback", payload);
} }
const FEEDBACK_ISSUE_NEW_URL = "https://github.com/snapotter-hq/snapotter/issues/new";
const MAX_FEEDBACK_LEN = 2000;
export const SNAPOTTER_FEEDBACK_EMAIL = "contact@snapotter.com";
/** Normalize newlines, trim, and clamp so the message is safe to put in a URL. */
function sanitizeFeedbackMessage(message: string): string {
return message.replace(/\r\n/g, "\n").trim().slice(0, MAX_FEEDBACK_LEN);
}
/**
* Prefilled GitHub issue URL for general feedback. Blank issues are disabled on
* the repo, so we must target a template by file name; `details` matches the
* `id` of the textarea in `.github/ISSUE_TEMPLATE/feedback.yml`.
*/
export function buildFeedbackGithubUrl(message: string): string {
const params = new URLSearchParams({
template: "feedback.yml",
details: sanitizeFeedbackMessage(message),
});
return `${FEEDBACK_ISSUE_NEW_URL}?${params.toString()}`;
}
/** Prefilled mailto to the project address, for users who prefer a private channel. */
export function buildFeedbackMailtoUrl(message: string): string {
const subject = encodeURIComponent("SnapOtter feedback");
const body = encodeURIComponent(sanitizeFeedbackMessage(message));
return `mailto:${SNAPOTTER_FEEDBACK_EMAIL}?subject=${subject}&body=${body}`;
}
+6
View File
@@ -42,6 +42,7 @@ export const ar: TranslationKeys = {
}, },
feedback: { feedback: {
navLabel: "Share feedback", navLabel: "Share feedback",
navButtonLabel: "ملاحظات",
dialogTitle: "Share feedback", dialogTitle: "Share feedback",
toolDialogTitle: "How did this tool work?", toolDialogTitle: "How did this tool work?",
failedDialogTitle: "Report a tool issue", failedDialogTitle: "Report a tool issue",
@@ -65,6 +66,11 @@ export const ar: TranslationKeys = {
searchMissTitle: "طلب أداة", searchMissTitle: "طلب أداة",
searchMissContext: "لقد بحثت عن: {query}", searchMissContext: "لقد بحثت عن: {query}",
searchMissDiscussionsFallback: "تعذّر تسجيل ذلك. افتح طلبًا في Discussions.", searchMissDiscussionsFallback: "تعذّر تسجيل ذلك. افتح طلبًا في Discussions.",
offlineDescription:
"التحليلات معطّلة في هذا المثيل، لذلك لا تُسجَّل الملاحظات هنا. لا يزال بإمكانك التواصل مباشرةً مع فريق SnapOtter.",
offlinePublicNote: "مشكلات GitHub علنية.",
offlineGithubButton: "افتح مشكلة على GitHub",
offlineEmailButton: "راسلنا",
thanksTitle: "Thanks for the feedback.", thanksTitle: "Thanks for the feedback.",
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.", thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
quickThanks: "Thanks for the signal.", quickThanks: "Thanks for the signal.",
+6
View File
@@ -42,6 +42,7 @@ export const de: TranslationKeys = {
}, },
feedback: { feedback: {
navLabel: "Share feedback", navLabel: "Share feedback",
navButtonLabel: "Feedback",
dialogTitle: "Share feedback", dialogTitle: "Share feedback",
toolDialogTitle: "How did this tool work?", toolDialogTitle: "How did this tool work?",
failedDialogTitle: "Report a tool issue", failedDialogTitle: "Report a tool issue",
@@ -66,6 +67,11 @@ export const de: TranslationKeys = {
searchMissContext: "Du hast gesucht nach: {query}", searchMissContext: "Du hast gesucht nach: {query}",
searchMissDiscussionsFallback: searchMissDiscussionsFallback:
"Das konnten wir nicht speichern. Stelle eine Anfrage in Discussions.", "Das konnten wir nicht speichern. Stelle eine Anfrage in Discussions.",
offlineDescription:
"Für diese Instanz ist die Analyse deaktiviert, daher wird Feedback hier nicht erfasst. Du kannst das SnapOtter-Team weiterhin direkt erreichen.",
offlinePublicNote: "GitHub-Issues sind öffentlich.",
offlineGithubButton: "Ein GitHub-Issue öffnen",
offlineEmailButton: "Schreib uns",
thanksTitle: "Thanks for the feedback.", thanksTitle: "Thanks for the feedback.",
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.", thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
quickThanks: "Thanks for the signal.", quickThanks: "Thanks for the signal.",
+6
View File
@@ -40,6 +40,7 @@ export const en = {
}, },
feedback: { feedback: {
navLabel: "Share feedback", navLabel: "Share feedback",
navButtonLabel: "Feedback",
dialogTitle: "Share feedback", dialogTitle: "Share feedback",
toolDialogTitle: "How did this tool work?", toolDialogTitle: "How did this tool work?",
failedDialogTitle: "Report a tool issue", failedDialogTitle: "Report a tool issue",
@@ -63,6 +64,11 @@ export const en = {
searchMissTitle: "Request a tool", searchMissTitle: "Request a tool",
searchMissContext: "You searched for: {query}", searchMissContext: "You searched for: {query}",
searchMissDiscussionsFallback: "We couldn't record that. Open a request in Discussions.", searchMissDiscussionsFallback: "We couldn't record that. Open a request in Discussions.",
offlineDescription:
"This instance has analytics turned off, so feedback isn't recorded here. You can still reach the SnapOtter team directly.",
offlinePublicNote: "GitHub issues are public.",
offlineGithubButton: "Open a GitHub issue",
offlineEmailButton: "Email us",
thanksTitle: "Thanks for the feedback.", thanksTitle: "Thanks for the feedback.",
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.", thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
quickThanks: "Thanks for the signal.", quickThanks: "Thanks for the signal.",
+6
View File
@@ -42,6 +42,7 @@ export const es: TranslationKeys = {
}, },
feedback: { feedback: {
navLabel: "Share feedback", navLabel: "Share feedback",
navButtonLabel: "Comentarios",
dialogTitle: "Share feedback", dialogTitle: "Share feedback",
toolDialogTitle: "How did this tool work?", toolDialogTitle: "How did this tool work?",
failedDialogTitle: "Report a tool issue", failedDialogTitle: "Report a tool issue",
@@ -65,6 +66,11 @@ export const es: TranslationKeys = {
searchMissTitle: "Solicitar una herramienta", searchMissTitle: "Solicitar una herramienta",
searchMissContext: "Buscaste: {query}", searchMissContext: "Buscaste: {query}",
searchMissDiscussionsFallback: "No pudimos registrarlo. Abre una solicitud en Discussions.", searchMissDiscussionsFallback: "No pudimos registrarlo. Abre una solicitud en Discussions.",
offlineDescription:
"Esta instancia tiene la analítica desactivada, así que los comentarios no se registran aquí. Aún puedes contactar directamente con el equipo de SnapOtter.",
offlinePublicNote: "Las incidencias de GitHub son públicas.",
offlineGithubButton: "Abrir una incidencia en GitHub",
offlineEmailButton: "Escríbenos",
thanksTitle: "Thanks for the feedback.", thanksTitle: "Thanks for the feedback.",
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.", thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
quickThanks: "Thanks for the signal.", quickThanks: "Thanks for the signal.",
+6
View File
@@ -42,6 +42,7 @@ export const fr: TranslationKeys = {
}, },
feedback: { feedback: {
navLabel: "Share feedback", navLabel: "Share feedback",
navButtonLabel: "Commentaires",
dialogTitle: "Share feedback", dialogTitle: "Share feedback",
toolDialogTitle: "How did this tool work?", toolDialogTitle: "How did this tool work?",
failedDialogTitle: "Report a tool issue", failedDialogTitle: "Report a tool issue",
@@ -66,6 +67,11 @@ export const fr: TranslationKeys = {
searchMissContext: "Vous avez recherché : {query}", searchMissContext: "Vous avez recherché : {query}",
searchMissDiscussionsFallback: searchMissDiscussionsFallback:
"Nous n'avons pas pu l'enregistrer. Ouvrez une demande dans Discussions.", "Nous n'avons pas pu l'enregistrer. Ouvrez une demande dans Discussions.",
offlineDescription:
"Cette instance a l'analytique désactivée, donc les commentaires ne sont pas enregistrés ici. Vous pouvez tout de même contacter directement l'équipe SnapOtter.",
offlinePublicNote: "Les tickets GitHub sont publics.",
offlineGithubButton: "Ouvrir un ticket GitHub",
offlineEmailButton: "Écrivez-nous",
thanksTitle: "Thanks for the feedback.", thanksTitle: "Thanks for the feedback.",
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.", thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
quickThanks: "Thanks for the signal.", quickThanks: "Thanks for the signal.",
+6
View File
@@ -42,6 +42,7 @@ export const hi: TranslationKeys = {
}, },
feedback: { feedback: {
navLabel: "Share feedback", navLabel: "Share feedback",
navButtonLabel: "प्रतिक्रिया",
dialogTitle: "Share feedback", dialogTitle: "Share feedback",
toolDialogTitle: "How did this tool work?", toolDialogTitle: "How did this tool work?",
failedDialogTitle: "Report a tool issue", failedDialogTitle: "Report a tool issue",
@@ -65,6 +66,11 @@ export const hi: TranslationKeys = {
searchMissTitle: "टूल का अनुरोध करें", searchMissTitle: "टूल का अनुरोध करें",
searchMissContext: "आपने खोजा: {query}", searchMissContext: "आपने खोजा: {query}",
searchMissDiscussionsFallback: "हम इसे रिकॉर्ड नहीं कर सके. Discussions में अनुरोध खोलें.", searchMissDiscussionsFallback: "हम इसे रिकॉर्ड नहीं कर सके. Discussions में अनुरोध खोलें.",
offlineDescription:
"इस इंस्टेंस पर एनालिटिक्स बंद है, इसलिए प्रतिक्रिया यहाँ रिकॉर्ड नहीं होती. फिर भी आप SnapOtter टीम से सीधे संपर्क कर सकते हैं.",
offlinePublicNote: "GitHub issue सार्वजनिक होते हैं.",
offlineGithubButton: "GitHub issue खोलें",
offlineEmailButton: "हमें ईमेल करें",
thanksTitle: "Thanks for the feedback.", thanksTitle: "Thanks for the feedback.",
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.", thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
quickThanks: "Thanks for the signal.", quickThanks: "Thanks for the signal.",
+6
View File
@@ -42,6 +42,7 @@ export const id: TranslationKeys = {
}, },
feedback: { feedback: {
navLabel: "Share feedback", navLabel: "Share feedback",
navButtonLabel: "Masukan",
dialogTitle: "Share feedback", dialogTitle: "Share feedback",
toolDialogTitle: "How did this tool work?", toolDialogTitle: "How did this tool work?",
failedDialogTitle: "Report a tool issue", failedDialogTitle: "Report a tool issue",
@@ -65,6 +66,11 @@ export const id: TranslationKeys = {
searchMissTitle: "Minta alat", searchMissTitle: "Minta alat",
searchMissContext: "Anda mencari: {query}", searchMissContext: "Anda mencari: {query}",
searchMissDiscussionsFallback: "Kami tidak dapat mencatatnya. Buka permintaan di Discussions.", searchMissDiscussionsFallback: "Kami tidak dapat mencatatnya. Buka permintaan di Discussions.",
offlineDescription:
"Instance ini menonaktifkan analitik, jadi masukan tidak dicatat di sini. Anda tetap bisa menghubungi tim SnapOtter secara langsung.",
offlinePublicNote: "Issue GitHub bersifat publik.",
offlineGithubButton: "Buka issue GitHub",
offlineEmailButton: "Kirim email ke kami",
thanksTitle: "Thanks for the feedback.", thanksTitle: "Thanks for the feedback.",
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.", thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
quickThanks: "Thanks for the signal.", quickThanks: "Thanks for the signal.",
+6
View File
@@ -42,6 +42,7 @@ export const it: TranslationKeys = {
}, },
feedback: { feedback: {
navLabel: "Condividi feedback", navLabel: "Condividi feedback",
navButtonLabel: "Feedback",
dialogTitle: "Condividi feedback", dialogTitle: "Condividi feedback",
toolDialogTitle: "Come ha funzionato questo strumento?", toolDialogTitle: "Come ha funzionato questo strumento?",
failedDialogTitle: "Segnala un problema dello strumento", failedDialogTitle: "Segnala un problema dello strumento",
@@ -67,6 +68,11 @@ export const it: TranslationKeys = {
searchMissContext: "Hai cercato: {query}", searchMissContext: "Hai cercato: {query}",
searchMissDiscussionsFallback: searchMissDiscussionsFallback:
"Non siamo riusciti a registrarlo. Apri una richiesta in Discussions.", "Non siamo riusciti a registrarlo. Apri una richiesta in Discussions.",
offlineDescription:
"Questa istanza ha l'analisi disattivata, quindi il feedback non viene registrato qui. Puoi comunque contattare direttamente il team di SnapOtter.",
offlinePublicNote: "Le issue di GitHub sono pubbliche.",
offlineGithubButton: "Apri una issue su GitHub",
offlineEmailButton: "Scrivici",
thanksTitle: "Grazie per il feedback.", thanksTitle: "Grazie per il feedback.",
thanksDescription: thanksDescription:
"Ci aiuta a migliorare SnapOtter senza raccogliere file o contenuti privati.", "Ci aiuta a migliorare SnapOtter senza raccogliere file o contenuti privati.",
+6
View File
@@ -42,6 +42,7 @@ export const ja: TranslationKeys = {
}, },
feedback: { feedback: {
navLabel: "Share feedback", navLabel: "Share feedback",
navButtonLabel: "フィードバック",
dialogTitle: "Share feedback", dialogTitle: "Share feedback",
toolDialogTitle: "How did this tool work?", toolDialogTitle: "How did this tool work?",
failedDialogTitle: "Report a tool issue", failedDialogTitle: "Report a tool issue",
@@ -66,6 +67,11 @@ export const ja: TranslationKeys = {
searchMissContext: "検索した内容: {query}", searchMissContext: "検索した内容: {query}",
searchMissDiscussionsFallback: searchMissDiscussionsFallback:
"記録できませんでした。Discussions でリクエストを開いてください。", "記録できませんでした。Discussions でリクエストを開いてください。",
offlineDescription:
"このインスタンスは分析が無効になっているため、フィードバックはここには記録されません。SnapOtter チームに直接連絡することもできます。",
offlinePublicNote: "GitHub の Issue は公開されます。",
offlineGithubButton: "GitHub で Issue を作成",
offlineEmailButton: "メールで連絡",
thanksTitle: "Thanks for the feedback.", thanksTitle: "Thanks for the feedback.",
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.", thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
quickThanks: "Thanks for the signal.", quickThanks: "Thanks for the signal.",
+6
View File
@@ -42,6 +42,7 @@ export const ko: TranslationKeys = {
}, },
feedback: { feedback: {
navLabel: "Share feedback", navLabel: "Share feedback",
navButtonLabel: "피드백",
dialogTitle: "Share feedback", dialogTitle: "Share feedback",
toolDialogTitle: "How did this tool work?", toolDialogTitle: "How did this tool work?",
failedDialogTitle: "Report a tool issue", failedDialogTitle: "Report a tool issue",
@@ -65,6 +66,11 @@ export const ko: TranslationKeys = {
searchMissTitle: "도구 요청", searchMissTitle: "도구 요청",
searchMissContext: "검색한 내용: {query}", searchMissContext: "검색한 내용: {query}",
searchMissDiscussionsFallback: "기록하지 못했습니다. Discussions에서 요청을 열어 주세요.", searchMissDiscussionsFallback: "기록하지 못했습니다. Discussions에서 요청을 열어 주세요.",
offlineDescription:
"이 인스턴스는 분석이 꺼져 있어 피드백이 여기에 기록되지 않습니다. 그래도 SnapOtter 팀에 직접 연락할 수 있습니다.",
offlinePublicNote: "GitHub 이슈는 공개됩니다.",
offlineGithubButton: "GitHub 이슈 열기",
offlineEmailButton: "이메일 보내기",
thanksTitle: "Thanks for the feedback.", thanksTitle: "Thanks for the feedback.",
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.", thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
quickThanks: "Thanks for the signal.", quickThanks: "Thanks for the signal.",
+6
View File
@@ -42,6 +42,7 @@ export const nl: TranslationKeys = {
}, },
feedback: { feedback: {
navLabel: "Share feedback", navLabel: "Share feedback",
navButtonLabel: "Feedback",
dialogTitle: "Share feedback", dialogTitle: "Share feedback",
toolDialogTitle: "How did this tool work?", toolDialogTitle: "How did this tool work?",
failedDialogTitle: "Report a tool issue", failedDialogTitle: "Report a tool issue",
@@ -66,6 +67,11 @@ export const nl: TranslationKeys = {
searchMissContext: "Je zocht op: {query}", searchMissContext: "Je zocht op: {query}",
searchMissDiscussionsFallback: searchMissDiscussionsFallback:
"We konden dit niet vastleggen. Open een verzoek in Discussions.", "We konden dit niet vastleggen. Open een verzoek in Discussions.",
offlineDescription:
"Deze instantie heeft analyse uitgeschakeld, dus feedback wordt hier niet vastgelegd. Je kunt het SnapOtter-team nog steeds rechtstreeks bereiken.",
offlinePublicNote: "GitHub-issues zijn openbaar.",
offlineGithubButton: "Een GitHub-issue openen",
offlineEmailButton: "Mail ons",
thanksTitle: "Thanks for the feedback.", thanksTitle: "Thanks for the feedback.",
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.", thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
quickThanks: "Thanks for the signal.", quickThanks: "Thanks for the signal.",
+6
View File
@@ -42,6 +42,7 @@ export const pl: TranslationKeys = {
}, },
feedback: { feedback: {
navLabel: "Share feedback", navLabel: "Share feedback",
navButtonLabel: "Opinie",
dialogTitle: "Share feedback", dialogTitle: "Share feedback",
toolDialogTitle: "How did this tool work?", toolDialogTitle: "How did this tool work?",
failedDialogTitle: "Report a tool issue", failedDialogTitle: "Report a tool issue",
@@ -65,6 +66,11 @@ export const pl: TranslationKeys = {
searchMissTitle: "Poproś o narzędzie", searchMissTitle: "Poproś o narzędzie",
searchMissContext: "Szukano: {query}", searchMissContext: "Szukano: {query}",
searchMissDiscussionsFallback: "Nie udało się tego zapisać. Otwórz prośbę w Discussions.", searchMissDiscussionsFallback: "Nie udało się tego zapisać. Otwórz prośbę w Discussions.",
offlineDescription:
"Ta instancja ma wyłączoną analitykę, więc opinie nie są tu zapisywane. Nadal możesz skontaktować się bezpośrednio z zespołem SnapOtter.",
offlinePublicNote: "Zgłoszenia w GitHub są publiczne.",
offlineGithubButton: "Otwórz zgłoszenie w GitHub",
offlineEmailButton: "Napisz do nas",
thanksTitle: "Thanks for the feedback.", thanksTitle: "Thanks for the feedback.",
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.", thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
quickThanks: "Thanks for the signal.", quickThanks: "Thanks for the signal.",
+6
View File
@@ -42,6 +42,7 @@ export const ptBR: TranslationKeys = {
}, },
feedback: { feedback: {
navLabel: "Share feedback", navLabel: "Share feedback",
navButtonLabel: "Feedback",
dialogTitle: "Share feedback", dialogTitle: "Share feedback",
toolDialogTitle: "How did this tool work?", toolDialogTitle: "How did this tool work?",
failedDialogTitle: "Report a tool issue", failedDialogTitle: "Report a tool issue",
@@ -66,6 +67,11 @@ export const ptBR: TranslationKeys = {
searchMissContext: "Você pesquisou: {query}", searchMissContext: "Você pesquisou: {query}",
searchMissDiscussionsFallback: searchMissDiscussionsFallback:
"Não conseguimos registrar. Abra uma solicitação no Discussions.", "Não conseguimos registrar. Abra uma solicitação no Discussions.",
offlineDescription:
"Esta instância está com a análise desativada, então o feedback não é registrado aqui. Mesmo assim, você pode falar diretamente com a equipe do SnapOtter.",
offlinePublicNote: "As issues do GitHub são públicas.",
offlineGithubButton: "Abrir uma issue no GitHub",
offlineEmailButton: "Envie um e-mail",
thanksTitle: "Thanks for the feedback.", thanksTitle: "Thanks for the feedback.",
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.", thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
quickThanks: "Thanks for the signal.", quickThanks: "Thanks for the signal.",
+6
View File
@@ -42,6 +42,7 @@ export const ru: TranslationKeys = {
}, },
feedback: { feedback: {
navLabel: "Share feedback", navLabel: "Share feedback",
navButtonLabel: "Отзыв",
dialogTitle: "Share feedback", dialogTitle: "Share feedback",
toolDialogTitle: "How did this tool work?", toolDialogTitle: "How did this tool work?",
failedDialogTitle: "Report a tool issue", failedDialogTitle: "Report a tool issue",
@@ -65,6 +66,11 @@ export const ru: TranslationKeys = {
searchMissTitle: "Запросить инструмент", searchMissTitle: "Запросить инструмент",
searchMissContext: "Вы искали: {query}", searchMissContext: "Вы искали: {query}",
searchMissDiscussionsFallback: "Не удалось записать. Откройте запрос в Discussions.", searchMissDiscussionsFallback: "Не удалось записать. Откройте запрос в Discussions.",
offlineDescription:
"В этом экземпляре аналитика отключена, поэтому отзывы здесь не записываются. Вы всё равно можете напрямую связаться с командой SnapOtter.",
offlinePublicNote: "Issue на GitHub публичны.",
offlineGithubButton: "Открыть issue на GitHub",
offlineEmailButton: "Напишите нам",
thanksTitle: "Thanks for the feedback.", thanksTitle: "Thanks for the feedback.",
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.", thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
quickThanks: "Thanks for the signal.", quickThanks: "Thanks for the signal.",
+6
View File
@@ -42,6 +42,7 @@ export const sv: TranslationKeys = {
}, },
feedback: { feedback: {
navLabel: "Share feedback", navLabel: "Share feedback",
navButtonLabel: "Feedback",
dialogTitle: "Share feedback", dialogTitle: "Share feedback",
toolDialogTitle: "How did this tool work?", toolDialogTitle: "How did this tool work?",
failedDialogTitle: "Report a tool issue", failedDialogTitle: "Report a tool issue",
@@ -66,6 +67,11 @@ export const sv: TranslationKeys = {
searchMissContext: "Du sökte efter: {query}", searchMissContext: "Du sökte efter: {query}",
searchMissDiscussionsFallback: searchMissDiscussionsFallback:
"Vi kunde inte registrera det. Öppna en förfrågan i Discussions.", "Vi kunde inte registrera det. Öppna en förfrågan i Discussions.",
offlineDescription:
"Den här instansen har stängt av analysen, så feedback registreras inte här. Du kan ändå nå SnapOtter-teamet direkt.",
offlinePublicNote: "GitHub-ärenden är offentliga.",
offlineGithubButton: "Öppna ett GitHub-ärende",
offlineEmailButton: "Mejla oss",
thanksTitle: "Thanks for the feedback.", thanksTitle: "Thanks for the feedback.",
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.", thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
quickThanks: "Thanks for the signal.", quickThanks: "Thanks for the signal.",
+6
View File
@@ -42,6 +42,7 @@ export const th: TranslationKeys = {
}, },
feedback: { feedback: {
navLabel: "Share feedback", navLabel: "Share feedback",
navButtonLabel: "ความคิดเห็น",
dialogTitle: "Share feedback", dialogTitle: "Share feedback",
toolDialogTitle: "How did this tool work?", toolDialogTitle: "How did this tool work?",
failedDialogTitle: "Report a tool issue", failedDialogTitle: "Report a tool issue",
@@ -65,6 +66,11 @@ export const th: TranslationKeys = {
searchMissTitle: "ขอเครื่องมือ", searchMissTitle: "ขอเครื่องมือ",
searchMissContext: "คุณค้นหา: {query}", searchMissContext: "คุณค้นหา: {query}",
searchMissDiscussionsFallback: "เราบันทึกไม่ได้ เปิดคำขอใน Discussions", searchMissDiscussionsFallback: "เราบันทึกไม่ได้ เปิดคำขอใน Discussions",
offlineDescription:
"อินสแตนซ์นี้ปิดการวิเคราะห์ไว้ ความคิดเห็นจึงไม่ถูกบันทึกที่นี่ คุณยังสามารถติดต่อทีม SnapOtter ได้โดยตรง",
offlinePublicNote: "Issue บน GitHub เป็นสาธารณะ",
offlineGithubButton: "เปิด Issue บน GitHub",
offlineEmailButton: "ส่งอีเมลถึงเรา",
thanksTitle: "Thanks for the feedback.", thanksTitle: "Thanks for the feedback.",
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.", thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
quickThanks: "Thanks for the signal.", quickThanks: "Thanks for the signal.",
+6
View File
@@ -42,6 +42,7 @@ export const tr: TranslationKeys = {
}, },
feedback: { feedback: {
navLabel: "Share feedback", navLabel: "Share feedback",
navButtonLabel: "Geri bildirim",
dialogTitle: "Share feedback", dialogTitle: "Share feedback",
toolDialogTitle: "How did this tool work?", toolDialogTitle: "How did this tool work?",
failedDialogTitle: "Report a tool issue", failedDialogTitle: "Report a tool issue",
@@ -65,6 +66,11 @@ export const tr: TranslationKeys = {
searchMissTitle: "Araç iste", searchMissTitle: "Araç iste",
searchMissContext: "Şunu aradın: {query}", searchMissContext: "Şunu aradın: {query}",
searchMissDiscussionsFallback: "Bunu kaydedemedik. Discussions'ta bir istek aç.", searchMissDiscussionsFallback: "Bunu kaydedemedik. Discussions'ta bir istek aç.",
offlineDescription:
"Bu örnekte analiz kapalı, bu yüzden geri bildirim burada kaydedilmez. Yine de SnapOtter ekibine doğrudan ulaşabilirsin.",
offlinePublicNote: "GitHub issue'ları herkese açıktır.",
offlineGithubButton: "GitHub'da issue aç",
offlineEmailButton: "Bize e-posta gönder",
thanksTitle: "Thanks for the feedback.", thanksTitle: "Thanks for the feedback.",
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.", thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
quickThanks: "Thanks for the signal.", quickThanks: "Thanks for the signal.",
+6
View File
@@ -42,6 +42,7 @@ export const uk: TranslationKeys = {
}, },
feedback: { feedback: {
navLabel: "Share feedback", navLabel: "Share feedback",
navButtonLabel: "Відгук",
dialogTitle: "Share feedback", dialogTitle: "Share feedback",
toolDialogTitle: "How did this tool work?", toolDialogTitle: "How did this tool work?",
failedDialogTitle: "Report a tool issue", failedDialogTitle: "Report a tool issue",
@@ -65,6 +66,11 @@ export const uk: TranslationKeys = {
searchMissTitle: "Запитати інструмент", searchMissTitle: "Запитати інструмент",
searchMissContext: "Ви шукали: {query}", searchMissContext: "Ви шукали: {query}",
searchMissDiscussionsFallback: "Не вдалося записати. Відкрийте запит у Discussions.", searchMissDiscussionsFallback: "Не вдалося записати. Відкрийте запит у Discussions.",
offlineDescription:
"У цьому екземплярі аналітику вимкнено, тому відгуки тут не записуються. Ви все одно можете напряму звернутися до команди SnapOtter.",
offlinePublicNote: "Issue на GitHub є публічними.",
offlineGithubButton: "Відкрити issue на GitHub",
offlineEmailButton: "Напишіть нам",
thanksTitle: "Thanks for the feedback.", thanksTitle: "Thanks for the feedback.",
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.", thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
quickThanks: "Thanks for the signal.", quickThanks: "Thanks for the signal.",
+6
View File
@@ -42,6 +42,7 @@ export const vi: TranslationKeys = {
}, },
feedback: { feedback: {
navLabel: "Share feedback", navLabel: "Share feedback",
navButtonLabel: "Phản hồi",
dialogTitle: "Share feedback", dialogTitle: "Share feedback",
toolDialogTitle: "How did this tool work?", toolDialogTitle: "How did this tool work?",
failedDialogTitle: "Report a tool issue", failedDialogTitle: "Report a tool issue",
@@ -66,6 +67,11 @@ export const vi: TranslationKeys = {
searchMissContext: "Bạn đã tìm: {query}", searchMissContext: "Bạn đã tìm: {query}",
searchMissDiscussionsFallback: searchMissDiscussionsFallback:
"Chúng tôi không ghi lại được. Mở một yêu cầu trong Discussions.", "Chúng tôi không ghi lại được. Mở một yêu cầu trong Discussions.",
offlineDescription:
"Bản cài đặt này đã tắt phân tích, nên phản hồi không được ghi lại ở đây. Bạn vẫn có thể liên hệ trực tiếp với nhóm SnapOtter.",
offlinePublicNote: "Issue trên GitHub là công khai.",
offlineGithubButton: "Mở một issue trên GitHub",
offlineEmailButton: "Gửi email cho chúng tôi",
thanksTitle: "Thanks for the feedback.", thanksTitle: "Thanks for the feedback.",
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.", thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
quickThanks: "Thanks for the signal.", quickThanks: "Thanks for the signal.",
+6
View File
@@ -42,6 +42,7 @@ export const zhCN: TranslationKeys = {
}, },
feedback: { feedback: {
navLabel: "Share feedback", navLabel: "Share feedback",
navButtonLabel: "反馈",
dialogTitle: "Share feedback", dialogTitle: "Share feedback",
toolDialogTitle: "How did this tool work?", toolDialogTitle: "How did this tool work?",
failedDialogTitle: "Report a tool issue", failedDialogTitle: "Report a tool issue",
@@ -65,6 +66,11 @@ export const zhCN: TranslationKeys = {
searchMissTitle: "请求工具", searchMissTitle: "请求工具",
searchMissContext: "你搜索了:{query}", searchMissContext: "你搜索了:{query}",
searchMissDiscussionsFallback: "我们无法记录。请在 Discussions 中发起请求。", searchMissDiscussionsFallback: "我们无法记录。请在 Discussions 中发起请求。",
offlineDescription:
"此实例已关闭分析功能,因此反馈不会记录在这里。你仍然可以直接联系 SnapOtter 团队。",
offlinePublicNote: "GitHub issue 是公开的。",
offlineGithubButton: "创建 GitHub issue",
offlineEmailButton: "给我们发邮件",
thanksTitle: "Thanks for the feedback.", thanksTitle: "Thanks for the feedback.",
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.", thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
quickThanks: "Thanks for the signal.", quickThanks: "Thanks for the signal.",
+6
View File
@@ -42,6 +42,7 @@ export const zhTW: TranslationKeys = {
}, },
feedback: { feedback: {
navLabel: "Share feedback", navLabel: "Share feedback",
navButtonLabel: "意見回饋",
dialogTitle: "Share feedback", dialogTitle: "Share feedback",
toolDialogTitle: "How did this tool work?", toolDialogTitle: "How did this tool work?",
failedDialogTitle: "Report a tool issue", failedDialogTitle: "Report a tool issue",
@@ -65,6 +66,11 @@ export const zhTW: TranslationKeys = {
searchMissTitle: "請求工具", searchMissTitle: "請求工具",
searchMissContext: "你搜尋了:{query}", searchMissContext: "你搜尋了:{query}",
searchMissDiscussionsFallback: "我們無法記錄。請在 Discussions 中發起請求。", searchMissDiscussionsFallback: "我們無法記錄。請在 Discussions 中發起請求。",
offlineDescription:
"此執行個體已關閉分析功能,因此意見回饋不會記錄在這裡。你仍然可以直接聯絡 SnapOtter 團隊。",
offlinePublicNote: "GitHub issue 是公開的。",
offlineGithubButton: "建立 GitHub issue",
offlineEmailButton: "寄電子郵件給我們",
thanksTitle: "Thanks for the feedback.", thanksTitle: "Thanks for the feedback.",
thanksDescription: "It helps us improve SnapOtter without collecting files or private content.", thanksDescription: "It helps us improve SnapOtter without collecting files or private content.",
quickThanks: "Thanks for the signal.", quickThanks: "Thanks for the signal.",
@@ -0,0 +1,65 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { FeedbackDialog } from "@/components/feedback/feedback-dialog";
const submitFeedback = vi.hoisted(() => vi.fn());
vi.mock("@/lib/feedback", async (importOriginal) => {
const actual: Record<string, unknown> = await importOriginal();
return { ...actual, submitFeedback };
});
const MESSAGE_PLACEHOLDER = "Tell us what worked, what broke, or what would make SnapOtter better.";
beforeEach(() => {
submitFeedback.mockReset();
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("FeedbackDialog global off-state handoff", () => {
it("reveals GitHub and email handoff when the server does not record the feedback", async () => {
submitFeedback.mockResolvedValue({ ok: true, accepted: false });
render(<FeedbackDialog open source="global" onClose={vi.fn()} />);
fireEvent.change(screen.getByPlaceholderText(MESSAGE_PLACEHOLDER), {
target: { value: "The queue stalls on large PDFs" },
});
fireEvent.click(screen.getByRole("button", { name: "Send feedback" }));
const githubLink = await screen.findByRole("link", { name: "Open a GitHub issue" });
expect(githubLink.getAttribute("href")).toContain("template=feedback.yml");
expect(githubLink.getAttribute("href")).toContain("issues/new");
const emailLink = screen.getByRole("link", { name: "Email us" });
expect(emailLink.getAttribute("href")).toContain("mailto:contact@snapotter.com");
// The typed message must actually reach both handoff targets, not just the static parts.
const githubDetails = new URL(githubLink.getAttribute("href") ?? "").searchParams.get(
"details",
);
expect(githubDetails).toBe("The queue stalls on large PDFs");
const emailBody = new URL(emailLink.getAttribute("href") ?? "").searchParams.get("body");
expect(emailBody).toBe("The queue stalls on large PDFs");
expect(screen.queryByText("Thanks for the feedback.")).toBeNull();
});
it("shows the normal thanks when the feedback is recorded", async () => {
submitFeedback.mockResolvedValue({ ok: true, accepted: true });
render(<FeedbackDialog open source="global" onClose={vi.fn()} />);
fireEvent.change(screen.getByPlaceholderText(MESSAGE_PLACEHOLDER), {
target: { value: "Great tool" },
});
fireEvent.click(screen.getByRole("button", { name: "Send feedback" }));
expect(await screen.findByText("Thanks for the feedback.")).toBeDefined();
expect(screen.queryByRole("link", { name: "Open a GitHub issue" })).toBeNull();
});
});
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import {
buildFeedbackGithubUrl,
buildFeedbackMailtoUrl,
SNAPOTTER_FEEDBACK_EMAIL,
} from "@/lib/feedback";
describe("buildFeedbackGithubUrl", () => {
it("targets the feedback issue template and prefills the message", () => {
const url = buildFeedbackGithubUrl("The export button is slow");
expect(url).toContain("https://github.com/snapotter-hq/snapotter/issues/new");
expect(url).toContain("template=feedback.yml");
// URLSearchParams encodes spaces as "+"
expect(url).toContain("details=The+export+button+is+slow");
});
it("trims and clamps the message to 2000 characters", () => {
const url = buildFeedbackGithubUrl(` hi ${"x".repeat(2100)}`);
const details = new URL(url).searchParams.get("details") ?? "";
expect(details.startsWith("hi")).toBe(true);
expect(details.length).toBeLessThanOrEqual(2000);
});
});
describe("buildFeedbackMailtoUrl", () => {
it("builds a mailto to the project address with an encoded body", () => {
const url = buildFeedbackMailtoUrl("Love the app, one bug");
expect(url.startsWith(`mailto:${SNAPOTTER_FEEDBACK_EMAIL}`)).toBe(true);
expect(url).toContain("subject=SnapOtter%20feedback");
expect(url).toContain("body=Love%20the%20app%2C%20one%20bug");
});
it("uses contact@snapotter.com", () => {
expect(SNAPOTTER_FEEDBACK_EMAIL).toBe("contact@snapotter.com");
});
});