350 lines
13 KiB
TypeScript
350 lines
13 KiB
TypeScript
"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<string, unknown>;
|
||
};
|
||
|
||
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<string, unknown>) {
|
||
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("<!doctype html") ||
|
||
text.startsWith("<html") ||
|
||
text.includes("<html ") ||
|
||
text.includes("cloudflare")
|
||
);
|
||
}
|
||
|
||
function extractFeedbackErrorMessage(raw: string) {
|
||
try {
|
||
const parsed = JSON.parse(String(raw || "")) as {
|
||
detail?: unknown;
|
||
error?: unknown;
|
||
message?: unknown;
|
||
};
|
||
const value = [parsed.detail, parsed.error, parsed.message].find((item) => {
|
||
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<FeedbackCategory>("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 (
|
||
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-slate-950/30 px-4 backdrop-blur-[2px]">
|
||
<div className="w-full max-w-lg overflow-hidden rounded-lg border border-slate-200 bg-white shadow-2xl">
|
||
<div className="flex items-center justify-between border-b border-slate-200 px-4 py-3">
|
||
<div>
|
||
<div className="text-sm font-black text-slate-950">
|
||
{isEn ? "Send feedback" : "提交反馈"}
|
||
</div>
|
||
<div className="mt-0.5 text-xs text-slate-500">{contextPreview}</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className="grid h-8 w-8 place-items-center rounded border border-slate-200 text-slate-500 transition hover:bg-slate-50 hover:text-slate-900"
|
||
aria-label={isEn ? "Close feedback" : "关闭反馈"}
|
||
>
|
||
<X size={16} />
|
||
</button>
|
||
</div>
|
||
|
||
{submitted ? (
|
||
<div className="px-5 py-8 text-center">
|
||
<CheckCircle2 className="mx-auto h-8 w-8 text-emerald-500" />
|
||
<div className="mt-3 text-sm font-black text-slate-950">
|
||
{isEn ? "Feedback received" : "反馈已收到"}
|
||
</div>
|
||
<p className="mx-auto mt-2 max-w-sm text-xs leading-5 text-slate-500">
|
||
{isEn
|
||
? "We saved the report with the current terminal context. The bell icon will show handling updates."
|
||
: "已附带当前终端上下文保存。右上角铃铛会显示后续处理进度。"}
|
||
</p>
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className="mt-5 rounded bg-blue-600 px-4 py-2 text-xs font-black text-white transition hover:bg-blue-700"
|
||
>
|
||
{isEn ? "Done" : "完成"}
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-4 p-5">
|
||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||
{CATEGORY_OPTIONS.map(({ key, labelZh, labelEn, Icon }) => (
|
||
<button
|
||
key={key}
|
||
type="button"
|
||
onClick={() => setCategory(key)}
|
||
className={clsx(
|
||
"flex h-11 items-center justify-center gap-2 rounded border px-2 text-xs font-bold transition",
|
||
category === key
|
||
? "border-blue-300 bg-blue-50 text-blue-700"
|
||
: "border-slate-200 bg-white text-slate-600 hover:bg-slate-50",
|
||
)}
|
||
>
|
||
<Icon size={14} />
|
||
<span>{isEn ? labelEn : labelZh}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<label className="block">
|
||
<span className="text-xs font-bold text-slate-600">
|
||
{isEn ? "What happened?" : "问题或建议"}
|
||
</span>
|
||
<textarea
|
||
value={message}
|
||
onChange={(event) => setMessage(event.target.value)}
|
||
rows={5}
|
||
className="mt-1 w-full resize-none rounded border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900 outline-none transition focus:border-blue-400 focus:ring-2 focus:ring-blue-100"
|
||
placeholder={
|
||
isEn
|
||
? "Example: This chart keeps loading after I switch to Helsinki."
|
||
: "例如:切到 Helsinki 后图表一直加载。"
|
||
}
|
||
/>
|
||
</label>
|
||
|
||
<label className="block">
|
||
<span className="text-xs font-bold text-slate-600">
|
||
{loginEmailContact
|
||
? isEn ? "Contact (login email)" : "联系方式(登录邮箱)"
|
||
: isEn ? "Contact, optional" : "联系方式,可选"}
|
||
</span>
|
||
<input
|
||
value={contact}
|
||
onChange={(event) => {
|
||
if (!loginEmailContact) setContact(event.target.value);
|
||
}}
|
||
readOnly={Boolean(loginEmailContact)}
|
||
className={clsx(
|
||
"mt-1 h-9 w-full rounded border border-slate-200 px-3 text-sm text-slate-900 outline-none transition focus:border-blue-400 focus:ring-2 focus:ring-blue-100",
|
||
loginEmailContact ? "bg-slate-50 text-slate-600" : "bg-white",
|
||
)}
|
||
placeholder={isEn ? "Email or Telegram" : "邮箱或 Telegram"}
|
||
/>
|
||
</label>
|
||
|
||
<div className="rounded border border-slate-200 bg-slate-50 px-3 py-2 text-[11px] leading-4 text-slate-500">
|
||
{isEn
|
||
? "Terminal context is attached automatically: city, slot, source state, browser and session diagnostics."
|
||
: "会自动附带终端上下文:城市、槽位、数据源状态、浏览器和会话诊断信息。"}
|
||
</div>
|
||
|
||
<TurnstileWidget
|
||
action="feedback_submit"
|
||
onToken={setTurnstileToken}
|
||
resetKey={turnstileResetKey}
|
||
/>
|
||
|
||
{error && (
|
||
<div className="rounded border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
|
||
{isEn ? "Submit failed: " : "提交失败:"}{error}
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex justify-end gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className="h-9 rounded border border-slate-200 px-4 text-xs font-bold text-slate-600 transition hover:bg-slate-50"
|
||
>
|
||
{isEn ? "Cancel" : "取消"}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={submit}
|
||
disabled={!canSubmit}
|
||
className="h-9 rounded bg-blue-600 px-4 text-xs font-black text-white transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||
>
|
||
{submitting ? (isEn ? "Sending..." : "提交中...") : (isEn ? "Send" : "提交")}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|