mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user