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
@@ -9,6 +9,8 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import {
buildFeedbackGithubUrl,
buildFeedbackMailtoUrl,
type FeedbackErrorCategory,
type FeedbackFrictionArea,
type FeedbackImportantArea,
@@ -115,6 +117,7 @@ export function FeedbackDialog({
const isAdminInstall = source === "admin_installer";
const isSearchMiss = source === "search_miss";
const isGlobal = source === "global";
const canSubmit = Boolean(
message.trim() || sentiment || feedbackType !== "other" || isAdminInstall,
);
@@ -203,7 +206,28 @@ export function FeedbackDialog({
{submitted ? (
<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">
<a
href={buildToolRequestDiscussionUrl(searchQuery ?? "")}
@@ -1,7 +1,6 @@
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";
@@ -22,10 +21,8 @@ export function AppLayout({ children, breadcrumb, navVariant }: AppLayoutProps)
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
@@ -51,7 +48,6 @@ export function AppLayout({ children, breadcrumb, navVariant }: AppLayoutProps)
onHelpClick={() => setHelpOpen(true)}
onFeedbackClick={() => setFeedbackOpen(true)}
onSettingsClick={() => setSettingsOpen(true)}
feedbackEnabled={feedbackEnabled}
/>
{/* Main content area */}
+4 -5
View File
@@ -28,7 +28,6 @@ interface TopNavProps {
onHelpClick: () => void;
onFeedbackClick?: () => void;
onSettingsClick: () => void;
feedbackEnabled?: boolean;
}
interface NavLinkItem {
@@ -59,7 +58,6 @@ export function TopNav({
onHelpClick,
onFeedbackClick,
onSettingsClick,
feedbackEnabled = false,
}: TopNavProps) {
const location = useLocation();
const isMobile = useMobile();
@@ -114,7 +112,7 @@ export function TopNav({
<div className="flex-1" />
{feedbackEnabled && onFeedbackClick && (
{onFeedbackClick && (
<button
type="button"
onClick={onFeedbackClick}
@@ -254,12 +252,12 @@ export function TopNav({
{!isMobile && <ThemeToggle isDark={isDark} />}
{!isMobile && <LanguageSelector isDark={isDark} />}
{feedbackEnabled && onFeedbackClick && (
{onFeedbackClick && (
<button
type="button"
onClick={onFeedbackClick}
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
? "text-[#aaa] hover:text-[#e0e0e0] hover:bg-[#333]"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
@@ -267,6 +265,7 @@ export function TopNav({
aria-label={t.feedback.navLabel}
>
<MessageSquare className="h-4 w-4" />
{t.feedback.navButtonLabel}
</button>
)}
+30
View File
@@ -155,3 +155,33 @@ export function shouldShowUsageSurvey({
export async function submitFeedback(payload: FeedbackPayload): Promise<FeedbackResponse> {
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}`;
}