"use client"; import { useEffect, useMemo, useState } from "react"; import clsx from "clsx"; import { Bug, CheckCircle2, Lightbulb, MessageSquare, WalletCards, X } from "lucide-react"; import { getAnalyticsClientId, getAnalyticsSessionId, } from "@/lib/app-analytics"; import { getSupabaseBrowserClient, hasSupabasePublicEnv, } from "@/lib/supabase/client"; import { TurnstileWidget } from "@/components/security/TurnstileWidget"; import { getTurnstileTokenForAction, isTurnstileEnabled } from "@/lib/turnstile-client"; import type { UserFeedbackEntry } from "@/types/ops"; export type FeedbackCategory = "bug" | "data" | "idea" | "payment" | "account" | "other"; export type FeedbackDraft = { category?: FeedbackCategory; source?: string; context?: Record; }; const CATEGORY_OPTIONS: Array<{ key: FeedbackCategory; labelZh: string; labelEn: string; Icon: typeof Bug; }> = [ { key: "bug", labelZh: "Bug", labelEn: "Bug", Icon: Bug }, { key: "data", labelZh: "数据问题", labelEn: "Data issue", Icon: MessageSquare }, { key: "idea", labelZh: "功能建议", labelEn: "Suggestion", Icon: Lightbulb }, { key: "payment", labelZh: "支付问题", labelEn: "Payment", Icon: WalletCards }, ]; function buildRuntimeContext(extra: Record) { if (typeof window === "undefined") return extra; return { ...extra, client_id: getAnalyticsClientId() || "", session_id: getAnalyticsSessionId() || "", path: window.location.pathname, href: window.location.href, language: navigator.language || "", timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "", user_agent: navigator.userAgent || "", viewport: `${window.innerWidth || 0}x${window.innerHeight || 0}`, captured_at: new Date().toISOString(), }; } function looksLikeHtmlDocument(value: string) { const text = String(value || "").trim().toLowerCase(); return ( text.startsWith(" { return typeof item === "string" && item.trim(); }); if (typeof value !== "string") return ""; const message = value.replace(/\s+/g, " ").trim(); return looksLikeHtmlDocument(message) ? "" : message; } catch { return ""; } } function formatFeedbackSubmitError(status: number, raw: string, isEn: boolean) { const jsonMessage = extractFeedbackErrorMessage(raw); if (jsonMessage) return jsonMessage.slice(0, 180); if (looksLikeHtmlDocument(raw)) { return isEn ? `Service is temporarily unavailable. Please retry in a moment. (HTTP ${status})` : `服务暂时不可用,请稍后重试。(HTTP ${status})`; } const message = String(raw || "").replace(/\s+/g, " ").trim(); if (message) return `${message.slice(0, 180)} (HTTP ${status})`; return `HTTP ${status}`; } export function UserFeedbackModal({ draft, isEn, onClose, onSubmitted, }: { draft: FeedbackDraft | null; isEn: boolean; onClose: () => void; onSubmitted?: (entry?: UserFeedbackEntry) => void; }) { const open = Boolean(draft); const [category, setCategory] = useState("bug"); const [message, setMessage] = useState(""); const [contact, setContact] = useState(""); const [loginEmailContact, setLoginEmailContact] = useState(""); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(""); const [submitted, setSubmitted] = useState(false); const [turnstileToken, setTurnstileToken] = useState(""); const [turnstileResetKey, setTurnstileResetKey] = useState(0); useEffect(() => { if (!open) return; setCategory(draft?.category || "bug"); setMessage(""); setContact(""); setLoginEmailContact(""); setError(""); setSubmitted(false); setTurnstileToken(""); setTurnstileResetKey((value) => value + 1); }, [open, draft?.category]); useEffect(() => { if (!open || !hasSupabasePublicEnv()) return; let cancelled = false; const loadLoginEmail = async () => { try { const { data: { session }, } = await getSupabaseBrowserClient().auth.getSession(); const email = String(session?.user?.email || "").trim(); if (cancelled || !email) return; setLoginEmailContact(email); setContact(email); } catch { // Feedback can still be submitted; the backend will attach auth email when present. } }; void loadLoginEmail(); return () => { cancelled = true; }; }, [open]); const contextPreview = useMemo(() => { const context = draft?.context || {}; const city = String(context.city || context.display_city || "").trim(); const source = String(draft?.source || context.source || "terminal").trim(); if (city) { return isEn ? `Context: ${city} · ${source}` : `上下文:${city} · ${source}`; } return isEn ? `Context: ${source}` : `上下文:${source}`; }, [draft, isEn]); if (!open) return null; const canSubmit = message.trim().length >= 3 && !submitting && (!isTurnstileEnabled() || Boolean(turnstileToken)); const submit = async () => { if (!canSubmit) return; setSubmitting(true); setError(""); try { const turnstile_token = getTurnstileTokenForAction( turnstileToken, "feedback_submit", ); const res = await fetch("/api/feedback", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ category, message: message.trim(), contact: contact.trim() || undefined, source: draft?.source || "terminal", context: buildRuntimeContext(draft?.context || {}), turnstile_token, }), }); if (!res.ok) { const raw = await res.text().catch(() => ""); throw new Error(formatFeedbackSubmitError(res.status, raw, isEn)); } const payload = (await res.json().catch(() => null)) as { feedback?: UserFeedbackEntry; } | null; setSubmitted(true); onSubmitted?.(payload?.feedback); } catch (err) { const message = err instanceof Error ? err.message : String(err); setError(message.slice(0, 220)); setTurnstileToken(""); setTurnstileResetKey((value) => value + 1); } finally { setSubmitting(false); } }; return (
{isEn ? "Send feedback" : "提交反馈"}
{contextPreview}
{submitted ? (
{isEn ? "Feedback received" : "反馈已收到"}

{isEn ? "We saved the report with the current terminal context. The bell icon will show handling updates." : "已附带当前终端上下文保存。右上角铃铛会显示后续处理进度。"}

) : (
{CATEGORY_OPTIONS.map(({ key, labelZh, labelEn, Icon }) => ( ))}