"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Bell, RefreshCcw } from "lucide-react"; import type { UserFeedbackEntry, UserFeedbackPayload } from "@/types/ops"; import { buildFeedbackNotificationKey, countUnseenFeedbackUpdates, FEEDBACK_STATUS_CACHE_TTL_MS, FEEDBACK_STATUS_POLL_MS, feedbackStatusLabel, feedbackStatusTone, } from "./feedback-status"; const FEEDBACK_STATUS_SEEN_KEY = "polyweather_feedback_status_seen_v1"; const FEEDBACK_STATUS_CACHE_KEY = "polyweather_feedback_status_cache_v1"; function loadSeenKeys() { if (typeof window === "undefined") return new Set(); try { const raw = window.localStorage.getItem(FEEDBACK_STATUS_SEEN_KEY); const parsed = raw ? JSON.parse(raw) : []; return new Set(Array.isArray(parsed) ? parsed.map(String) : []); } catch { return new Set(); } } function saveSeenKeys(keys: Set) { if (typeof window === "undefined") return; try { const trimmed = Array.from(keys).slice(-200); window.localStorage.setItem(FEEDBACK_STATUS_SEEN_KEY, JSON.stringify(trimmed)); } catch { // Ignore storage failures; the badge can reappear without breaking feedback status. } } function readFeedbackStatusCache(): { entries: UserFeedbackEntry[]; ts: number } | null { if (typeof window === "undefined") return null; try { const raw = window.localStorage.getItem(FEEDBACK_STATUS_CACHE_KEY); if (!raw) return null; const parsed = JSON.parse(raw); const ts = Number(parsed?.ts || 0); const entries = Array.isArray(parsed?.entries) ? parsed.entries : []; if (!ts || Date.now() - ts > FEEDBACK_STATUS_CACHE_TTL_MS) return null; return { entries, ts }; } catch { return null; } } function writeFeedbackStatusCache(entries: UserFeedbackEntry[]) { if (typeof window === "undefined") return; try { window.localStorage.setItem( FEEDBACK_STATUS_CACHE_KEY, JSON.stringify({ entries: entries.slice(0, 20), ts: Date.now() }), ); } catch { // Ignore storage failures; status will still refresh over the network. } } function compactDate(value?: string) { if (!value) return ""; return value.slice(0, 16).replace("T", " "); } function categoryLabel(value?: string, isEn = false) { const key = String(value || "").toLowerCase(); if (key === "bug") return "Bug"; if (key === "data") return isEn ? "Data" : "数据"; if (key === "idea") return isEn ? "Suggestion" : "建议"; if (key === "payment") return isEn ? "Payment" : "支付"; if (key === "account") return isEn ? "Account" : "账号"; return isEn ? "Other" : "其他"; } export function UserFeedbackStatusButton({ isEn, refreshKey = 0, }: { isEn: boolean; refreshKey?: number; }) { const [available, setAvailable] = useState(true); const [entries, setEntries] = useState(() => readFeedbackStatusCache()?.entries || []); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); const [open, setOpen] = useState(false); const [seenKeys, setSeenKeys] = useState>(() => loadSeenKeys()); const lastLoadedAtRef = useRef(readFeedbackStatusCache()?.ts || 0); const unseenCount = useMemo( () => countUnseenFeedbackUpdates(entries, seenKeys), [entries, seenKeys], ); const emptyStateText = loading ? isEn ? "Loading..." : "加载中..." : isEn ? "No submitted feedback yet." : "暂无已提交反馈。"; const load = useCallback(async ( signal?: AbortSignal, options?: { force?: boolean }, ) => { if (typeof fetch !== "function") return; const cached = readFeedbackStatusCache(); if (!options?.force && cached) { lastLoadedAtRef.current = cached.ts; setEntries(cached.entries); setAvailable(true); return; } const now = Date.now(); if ( !options?.force && lastLoadedAtRef.current && now - lastLoadedAtRef.current < FEEDBACK_STATUS_CACHE_TTL_MS ) { return; } setLoading(true); try { const res = await fetch("/api/feedback?limit=12", { cache: "no-store", headers: { Accept: "application/json" }, signal, }); if (res.status === 401 || res.status === 403) { setAvailable(false); setEntries([]); return; } if (!res.ok) { throw new Error(`HTTP ${res.status}`); } const payload = (await res.json()) as UserFeedbackPayload; const nextEntries = Array.isArray(payload.feedback) ? payload.feedback : []; setEntries(nextEntries); writeFeedbackStatusCache(nextEntries); lastLoadedAtRef.current = Date.now(); setAvailable(true); setError(""); } catch (err) { if (signal?.aborted) return; setError(String(err).slice(0, 120)); } finally { if (!signal?.aborted) setLoading(false); } }, []); const markVisibleAsSeen = useCallback((visibleEntries: UserFeedbackEntry[]) => { setSeenKeys((current) => { const next = new Set(current); visibleEntries.forEach((entry) => next.add(buildFeedbackNotificationKey(entry))); saveSeenKeys(next); return next; }); }, []); useEffect(() => { const controller = new AbortController(); void load(controller.signal, { force: refreshKey > 0 }); const handleVisibilityChange = () => { if ( document.visibilityState === "visible" && Date.now() - lastLoadedAtRef.current >= FEEDBACK_STATUS_CACHE_TTL_MS ) { void load(); } }; document.addEventListener("visibilitychange", handleVisibilityChange); const id = window.setInterval(() => { if (document.visibilityState === "visible") void load(); }, FEEDBACK_STATUS_POLL_MS); return () => { controller.abort(); document.removeEventListener("visibilitychange", handleVisibilityChange); window.clearInterval(id); }; }, [load, refreshKey]); useEffect(() => { if (open) markVisibleAsSeen(entries); }, [entries, markVisibleAsSeen, open]); if (!available) return null; return (
{open && (
{isEn ? "Feedback updates" : "反馈处理动态"}
{isEn ? "Your submitted reports only" : "仅显示你提交过的反馈"}
{error && (
{isEn ? "Status refresh failed: " : "状态刷新失败:"}{error}
)} {entries.length === 0 ? (
{emptyStateText}
) : (
{entries.map((entry) => (
{entry.message || (isEn ? "Feedback" : "反馈")}
{categoryLabel(entry.category, isEn)} {entry.created_at && {compactDate(entry.created_at)}}
{feedbackStatusLabel(entry.status, isEn)}
{entry.updated_at && entry.updated_at !== entry.created_at && (
{isEn ? "Updated " : "更新于 "}{compactDate(entry.updated_at)}
)}
))}
)}
)}
); }