Add user feedback status notifications
This commit is contained in:
@@ -60,6 +60,7 @@ import {
|
||||
UserFeedbackModal,
|
||||
type FeedbackDraft,
|
||||
} from "@/components/dashboard/scan-terminal/UserFeedbackModal";
|
||||
import { UserFeedbackStatusButton } from "@/components/dashboard/scan-terminal/UserFeedbackStatusButton";
|
||||
import {
|
||||
mergeAccessStateWithAuthPayload,
|
||||
type AuthProfilePayload,
|
||||
@@ -663,6 +664,7 @@ function PolyWeatherTerminal({
|
||||
const [activeNavKey, setActiveNavKey] = useState<string>("thresholds");
|
||||
const [onlineCount, setOnlineCount] = useState<number | null>(null);
|
||||
const [feedbackDraft, setFeedbackDraft] = useState<FeedbackDraft | null>(null);
|
||||
const [feedbackRefreshKey, setFeedbackRefreshKey] = useState(0);
|
||||
const handleSelectNav = useCallback((key: string) => {
|
||||
setActiveNavKey(key);
|
||||
}, []);
|
||||
@@ -754,6 +756,10 @@ function PolyWeatherTerminal({
|
||||
});
|
||||
}, [activeNavKey, gridCols, gridRows, locale]);
|
||||
|
||||
const handleFeedbackSubmitted = useCallback(() => {
|
||||
setFeedbackRefreshKey((value) => value + 1);
|
||||
}, []);
|
||||
|
||||
const handleSetGridSize = (cols: number, rows: number) => {
|
||||
const safeCols = clampGridSide(cols);
|
||||
const safeRows = clampGridSide(rows);
|
||||
@@ -970,6 +976,7 @@ function PolyWeatherTerminal({
|
||||
>
|
||||
{isEn ? "中文" : "EN"}
|
||||
</button>
|
||||
<UserFeedbackStatusButton isEn={isEn} refreshKey={feedbackRefreshKey} />
|
||||
<Link
|
||||
href="/account"
|
||||
className="grid h-7 w-7 place-items-center rounded-full border border-slate-300 bg-white text-slate-500 transition-colors hover:bg-slate-50 hover:text-slate-900"
|
||||
@@ -1189,6 +1196,7 @@ function PolyWeatherTerminal({
|
||||
draft={feedbackDraft}
|
||||
isEn={isEn}
|
||||
onClose={() => setFeedbackDraft(null)}
|
||||
onSubmitted={handleFeedbackSubmitted}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getAnalyticsClientId,
|
||||
getAnalyticsSessionId,
|
||||
} from "@/lib/app-analytics";
|
||||
import type { UserFeedbackEntry } from "@/types/ops";
|
||||
|
||||
export type FeedbackCategory = "bug" | "data" | "idea" | "payment" | "account" | "other";
|
||||
|
||||
@@ -48,10 +49,12 @@ 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");
|
||||
@@ -103,7 +106,11 @@ export function UserFeedbackModal({
|
||||
const detail = (await res.text().catch(() => "")).slice(0, 200);
|
||||
throw new Error(detail || `HTTP ${res.status}`);
|
||||
}
|
||||
const payload = (await res.json().catch(() => null)) as {
|
||||
feedback?: UserFeedbackEntry;
|
||||
} | null;
|
||||
setSubmitted(true);
|
||||
onSubmitted?.(payload?.feedback);
|
||||
} catch (err) {
|
||||
setError(String(err).slice(0, 220));
|
||||
} finally {
|
||||
@@ -139,8 +146,8 @@ export function UserFeedbackModal({
|
||||
</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."
|
||||
: "已附带当前终端上下文保存,后续会在后台统一处理。"}
|
||||
? "We saved the report with the current terminal context. The bell icon will show handling updates."
|
||||
: "已附带当前终端上下文保存。右上角铃铛会显示后续处理进度。"}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Bell, RefreshCcw } from "lucide-react";
|
||||
import type { UserFeedbackEntry, UserFeedbackPayload } from "@/types/ops";
|
||||
import {
|
||||
buildFeedbackNotificationKey,
|
||||
countUnseenFeedbackUpdates,
|
||||
feedbackStatusLabel,
|
||||
feedbackStatusTone,
|
||||
} from "./feedback-status";
|
||||
|
||||
const FEEDBACK_STATUS_SEEN_KEY = "polyweather_feedback_status_seen_v1";
|
||||
const FEEDBACK_STATUS_POLL_MS = 60_000;
|
||||
|
||||
function loadSeenKeys() {
|
||||
if (typeof window === "undefined") return new Set<string>();
|
||||
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<string>();
|
||||
}
|
||||
}
|
||||
|
||||
function saveSeenKeys(keys: Set<string>) {
|
||||
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 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<UserFeedbackEntry[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [seenKeys, setSeenKeys] = useState<Set<string>>(() => loadSeenKeys());
|
||||
|
||||
const unseenCount = useMemo(
|
||||
() => countUnseenFeedbackUpdates(entries, seenKeys),
|
||||
[entries, seenKeys],
|
||||
);
|
||||
const emptyStateText = loading
|
||||
? isEn
|
||||
? "Loading..."
|
||||
: "加载中..."
|
||||
: isEn
|
||||
? "No submitted feedback yet."
|
||||
: "暂无已提交反馈。";
|
||||
|
||||
const load = useCallback(async (signal?: AbortSignal) => {
|
||||
if (typeof fetch !== "function") 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;
|
||||
setEntries(Array.isArray(payload.feedback) ? payload.feedback : []);
|
||||
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);
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible") 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 (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOpen((value) => {
|
||||
const next = !value;
|
||||
if (next) markVisibleAsSeen(entries);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
className="relative grid h-7 w-7 place-items-center rounded-full border border-slate-300 bg-white text-slate-500 transition-colors hover:bg-slate-50 hover:text-slate-900"
|
||||
title={isEn ? "Feedback status" : "反馈处理状态"}
|
||||
aria-label={isEn ? "Feedback status" : "反馈处理状态"}
|
||||
>
|
||||
<Bell size={13} />
|
||||
{unseenCount > 0 && (
|
||||
<span className="absolute -right-1 -top-1 grid h-4 min-w-4 place-items-center rounded-full bg-blue-600 px-1 text-[9px] font-black leading-none text-white ring-2 ring-white">
|
||||
{unseenCount > 9 ? "9+" : unseenCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 top-9 z-[70] w-[340px] max-w-[calc(100vw-1rem)] overflow-hidden rounded-md border border-slate-200 bg-white text-slate-800 shadow-xl">
|
||||
<div className="flex items-center justify-between border-b border-slate-200 px-3 py-2">
|
||||
<div>
|
||||
<div className="text-xs font-black text-slate-950">
|
||||
{isEn ? "Feedback updates" : "反馈处理动态"}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11px] text-slate-500">
|
||||
{isEn ? "Your submitted reports only" : "仅显示你提交过的反馈"}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void load()}
|
||||
disabled={loading}
|
||||
className="grid h-7 w-7 place-items-center rounded border border-slate-200 text-slate-500 transition hover:bg-slate-50 disabled:cursor-wait disabled:opacity-60"
|
||||
title={isEn ? "Refresh" : "刷新"}
|
||||
aria-label={isEn ? "Refresh feedback status" : "刷新反馈状态"}
|
||||
>
|
||||
<RefreshCcw size={13} className={loading ? "animate-spin" : ""} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="border-b border-amber-100 bg-amber-50 px-3 py-2 text-[11px] text-amber-700">
|
||||
{isEn ? "Status refresh failed: " : "状态刷新失败:"}{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-xs text-slate-500">
|
||||
{emptyStateText}
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-[360px] overflow-y-auto">
|
||||
{entries.map((entry) => (
|
||||
<div key={entry.id} className="border-b border-slate-100 px-3 py-2 last:border-b-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-xs font-bold text-slate-900">
|
||||
{entry.message || (isEn ? "Feedback" : "反馈")}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px] text-slate-500">
|
||||
<span>{categoryLabel(entry.category, isEn)}</span>
|
||||
{entry.created_at && <span>{compactDate(entry.created_at)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<span className={`shrink-0 rounded border px-1.5 py-0.5 text-[10px] font-black ${feedbackStatusTone(entry.status)}`}>
|
||||
{feedbackStatusLabel(entry.status, isEn)}
|
||||
</span>
|
||||
</div>
|
||||
{entry.updated_at && entry.updated_at !== entry.created_at && (
|
||||
<div className="mt-1 text-[11px] text-slate-500">
|
||||
{isEn ? "Updated " : "更新于 "}{compactDate(entry.updated_at)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
buildFeedbackNotificationKey,
|
||||
countUnseenFeedbackUpdates,
|
||||
feedbackStatusLabel,
|
||||
} from "@/components/dashboard/scan-terminal/feedback-status";
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
export function runTests() {
|
||||
assert(feedbackStatusLabel("open", false) === "已收到", "open feedback should read as received to users");
|
||||
assert(feedbackStatusLabel("triaged", false) === "已确认", "triaged feedback should read as confirmed to users");
|
||||
assert(feedbackStatusLabel("investigating", false) === "处理中", "investigating feedback should read as in progress");
|
||||
assert(feedbackStatusLabel("resolved", false) === "已解决", "resolved feedback should read as handled");
|
||||
assert(feedbackStatusLabel("closed", true) === "Closed", "closed feedback should have English copy");
|
||||
|
||||
const firstVersion = {
|
||||
id: 7,
|
||||
status: "open",
|
||||
updated_at: "2026-06-06T10:00:00",
|
||||
};
|
||||
const handledVersion = {
|
||||
...firstVersion,
|
||||
status: "resolved",
|
||||
updated_at: "2026-06-06T10:30:00",
|
||||
};
|
||||
const firstKey = buildFeedbackNotificationKey(firstVersion);
|
||||
const handledKey = buildFeedbackNotificationKey(handledVersion);
|
||||
|
||||
assert(firstKey !== handledKey, "status and updated_at changes should create a new notification key");
|
||||
assert(
|
||||
countUnseenFeedbackUpdates([firstVersion, handledVersion], new Set([firstKey])) === 1,
|
||||
"already seen feedback versions should not count, but later handled versions should",
|
||||
);
|
||||
assert(
|
||||
countUnseenFeedbackUpdates([firstVersion, handledVersion], new Set([firstKey, handledKey])) === 0,
|
||||
"opening the status panel should be able to clear all visible feedback update badges",
|
||||
);
|
||||
}
|
||||
@@ -16,14 +16,22 @@ export function runTests() {
|
||||
"utf8",
|
||||
);
|
||||
const modalPath = path.join(projectRoot, "components", "dashboard", "scan-terminal", "UserFeedbackModal.tsx");
|
||||
const statusButtonPath = path.join(projectRoot, "components", "dashboard", "scan-terminal", "UserFeedbackStatusButton.tsx");
|
||||
const opsSidebarSource = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "ops", "layout", "AdminSidebar.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const opsApiSource = fs.readFileSync(path.join(projectRoot, "lib", "ops-api.ts"), "utf8");
|
||||
const feedbackRouteSource = fs.readFileSync(path.join(projectRoot, "app", "api", "feedback", "route.ts"), "utf8");
|
||||
|
||||
assert(fs.existsSync(modalPath), "terminal must ship a user feedback modal component");
|
||||
assert(fs.existsSync(statusButtonPath), "terminal must ship a user-facing feedback status notification component");
|
||||
const modalSource = fs.readFileSync(modalPath, "utf8");
|
||||
const statusButtonSource = fs.readFileSync(statusButtonPath, "utf8");
|
||||
const statusHelperSource = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "dashboard", "scan-terminal", "feedback-status.ts"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert(
|
||||
dashboardSource.includes("onFeedbackClick") &&
|
||||
@@ -40,9 +48,30 @@ export function runTests() {
|
||||
assert(
|
||||
modalSource.includes("/api/feedback") &&
|
||||
modalSource.includes("getAnalyticsClientId") &&
|
||||
modalSource.includes("onSubmitted") &&
|
||||
modalSource.includes("navigator.userAgent") &&
|
||||
modalSource.includes("type=\"textarea\"") === false,
|
||||
"feedback modal must submit to the feedback API and attach client/session diagnostics without using invalid textarea input types",
|
||||
"feedback modal must submit to the feedback API, notify the dashboard after success, and attach client/session diagnostics without using invalid textarea input types",
|
||||
);
|
||||
assert(
|
||||
feedbackRouteSource.includes("export async function GET") &&
|
||||
feedbackRouteSource.includes("method: \"GET\"") &&
|
||||
feedbackRouteSource.includes("limit"),
|
||||
"feedback API proxy must expose a GET endpoint for the current user's feedback status list",
|
||||
);
|
||||
assert(
|
||||
dashboardSource.includes("<UserFeedbackStatusButton") &&
|
||||
dashboardSource.includes("feedbackRefreshKey") &&
|
||||
dashboardSource.includes("setFeedbackRefreshKey"),
|
||||
"terminal header must expose a small feedback status notification icon and refresh it after submissions",
|
||||
);
|
||||
assert(
|
||||
statusButtonSource.includes("/api/feedback") &&
|
||||
statusButtonSource.includes("localStorage") &&
|
||||
statusButtonSource.includes("Bell") &&
|
||||
statusHelperSource.includes("triaged") &&
|
||||
statusHelperSource.includes("resolved"),
|
||||
"feedback status notification must poll the user feedback API, badge unseen status changes, and translate handled states",
|
||||
);
|
||||
assert(
|
||||
opsSidebarSource.includes("/ops/feedback") &&
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { UserFeedbackEntry } from "@/types/ops";
|
||||
|
||||
export function feedbackStatusLabel(status: string | undefined, isEn: boolean) {
|
||||
const key = String(status || "open").toLowerCase();
|
||||
if (key === "triaged") return isEn ? "Confirmed" : "已确认";
|
||||
if (key === "investigating") return isEn ? "In progress" : "处理中";
|
||||
if (key === "resolved") return isEn ? "Resolved" : "已解决";
|
||||
if (key === "closed") return isEn ? "Closed" : "已关闭";
|
||||
return isEn ? "Received" : "已收到";
|
||||
}
|
||||
|
||||
export function feedbackStatusTone(status: string | undefined) {
|
||||
const key = String(status || "open").toLowerCase();
|
||||
if (key === "triaged") return "border-amber-200 bg-amber-50 text-amber-700";
|
||||
if (key === "investigating") return "border-blue-200 bg-blue-50 text-blue-700";
|
||||
if (key === "resolved") return "border-emerald-200 bg-emerald-50 text-emerald-700";
|
||||
if (key === "closed") return "border-slate-200 bg-slate-50 text-slate-500";
|
||||
return "border-red-200 bg-red-50 text-red-700";
|
||||
}
|
||||
|
||||
export function buildFeedbackNotificationKey(
|
||||
entry: Pick<UserFeedbackEntry, "id" | "status" | "updated_at" | "created_at">,
|
||||
) {
|
||||
return [
|
||||
Number(entry.id || 0),
|
||||
String(entry.status || "open").toLowerCase(),
|
||||
String(entry.updated_at || entry.created_at || ""),
|
||||
].join(":");
|
||||
}
|
||||
|
||||
export function countUnseenFeedbackUpdates(
|
||||
entries: Array<
|
||||
Pick<UserFeedbackEntry, "id" | "status" | "updated_at" | "created_at">
|
||||
>,
|
||||
seenKeys: Set<string>,
|
||||
) {
|
||||
return entries.reduce((count, entry) => {
|
||||
return count + (seenKeys.has(buildFeedbackNotificationKey(entry)) ? 0 : 1);
|
||||
}, 0);
|
||||
}
|
||||
Reference in New Issue
Block a user