Add terminal feedback workflow
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
||||
ChevronRight,
|
||||
GraduationCap,
|
||||
Menu,
|
||||
MessageSquare,
|
||||
Search,
|
||||
UserRound,
|
||||
Users,
|
||||
@@ -55,6 +56,10 @@ import { KoyfinRowsTable } from "@/components/dashboard/scan-terminal/KoyfinRows
|
||||
import { rowName, pct, money, temp, edgeClass } from "@/components/dashboard/scan-terminal/utils";
|
||||
import { CitySelectorDropdown } from "@/components/dashboard/scan-terminal/CitySelectorDropdown";
|
||||
import { GridLayoutSelector } from "@/components/dashboard/scan-terminal/GridLayoutSelector";
|
||||
import {
|
||||
UserFeedbackModal,
|
||||
type FeedbackDraft,
|
||||
} from "@/components/dashboard/scan-terminal/UserFeedbackModal";
|
||||
import {
|
||||
mergeAccessStateWithAuthPayload,
|
||||
type AuthProfilePayload,
|
||||
@@ -441,10 +446,12 @@ function EmptySlotCard({
|
||||
const TerminalSidebar = memo(function TerminalSidebar({
|
||||
activeNavKey,
|
||||
isEn,
|
||||
onFeedbackClick,
|
||||
onSelectNav,
|
||||
}: {
|
||||
activeNavKey: string;
|
||||
isEn: boolean;
|
||||
onFeedbackClick: () => void;
|
||||
onSelectNav: (key: string) => void;
|
||||
}) {
|
||||
const [navExpanded, setNavExpanded] = useState(false);
|
||||
@@ -527,6 +534,25 @@ const TerminalSidebar = memo(function TerminalSidebar({
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className={clsx("mt-auto w-full border-t border-slate-200 pt-2", navExpanded ? "" : "px-0")}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onFeedbackClick}
|
||||
className={clsx(
|
||||
"flex items-center gap-3 rounded text-slate-500 transition-colors hover:bg-slate-50 hover:text-blue-700",
|
||||
navExpanded ? "h-9 w-full px-2 text-left" : "grid h-9 w-full place-items-center border-l-4 border-transparent",
|
||||
)}
|
||||
title={isEn ? "Feedback / Bug" : "反馈 / Bug"}
|
||||
>
|
||||
<MessageSquare size={16} className="shrink-0" />
|
||||
{navExpanded && (
|
||||
<span className="text-xs font-semibold whitespace-nowrap">
|
||||
{isEn ? "Feedback" : "反馈 / Bug"}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
});
|
||||
@@ -595,6 +621,7 @@ function PolyWeatherTerminal({
|
||||
}, [searchInputRef, setSearchQuery]);
|
||||
const [activeNavKey, setActiveNavKey] = useState<string>("thresholds");
|
||||
const [onlineCount, setOnlineCount] = useState<number | null>(null);
|
||||
const [feedbackDraft, setFeedbackDraft] = useState<FeedbackDraft | null>(null);
|
||||
const handleSelectNav = useCallback((key: string) => {
|
||||
setActiveNavKey(key);
|
||||
}, []);
|
||||
@@ -653,6 +680,47 @@ function PolyWeatherTerminal({
|
||||
const [activeSearchSlotIndex, setActiveSearchSlotIndex] = useState<number | null>(null);
|
||||
const visibleSlots = useMemo(() => slots.slice(0, totalSlots), [slots, totalSlots]);
|
||||
|
||||
const openTerminalFeedback = useCallback(() => {
|
||||
setFeedbackDraft({
|
||||
category: "bug",
|
||||
source: "terminal",
|
||||
context: {
|
||||
source: "terminal_sidebar",
|
||||
active_nav: activeNavKey,
|
||||
locale,
|
||||
grid: `${gridCols}x${gridRows}`,
|
||||
active_slot_index: activeSlotIndex,
|
||||
maximized_slot_index: maximizedSlotIndex,
|
||||
visible_slots: visibleSlots.filter(Boolean),
|
||||
selected_city: selectedCity || "",
|
||||
selected_region: selectedRegionKey,
|
||||
},
|
||||
});
|
||||
}, [
|
||||
activeNavKey,
|
||||
activeSlotIndex,
|
||||
gridCols,
|
||||
gridRows,
|
||||
locale,
|
||||
maximizedSlotIndex,
|
||||
selectedCity,
|
||||
selectedRegionKey,
|
||||
visibleSlots,
|
||||
]);
|
||||
|
||||
const openChartFeedback = useCallback((context: Record<string, unknown>) => {
|
||||
setFeedbackDraft({
|
||||
category: "bug",
|
||||
source: "chart",
|
||||
context: {
|
||||
...context,
|
||||
active_nav: activeNavKey,
|
||||
locale,
|
||||
grid: `${gridCols}x${gridRows}`,
|
||||
},
|
||||
});
|
||||
}, [activeNavKey, gridCols, gridRows, locale]);
|
||||
|
||||
const handleSetGridSize = (cols: number, rows: number) => {
|
||||
const safeCols = clampGridSide(cols);
|
||||
const safeRows = clampGridSide(rows);
|
||||
@@ -800,7 +868,12 @@ function PolyWeatherTerminal({
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full overflow-hidden bg-[#e9edf3] text-[#202833]">
|
||||
<TerminalSidebar activeNavKey={activeNavKey} isEn={isEn} onSelectNav={handleSelectNav} />
|
||||
<TerminalSidebar
|
||||
activeNavKey={activeNavKey}
|
||||
isEn={isEn}
|
||||
onFeedbackClick={openTerminalFeedback}
|
||||
onSelectNav={handleSelectNav}
|
||||
/>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<header className="flex h-12 shrink-0 items-center justify-between border-b border-[#d2d9e2] bg-white px-4 text-slate-800">
|
||||
@@ -872,6 +945,7 @@ function PolyWeatherTerminal({
|
||||
allRows={filteredRegionRows}
|
||||
compact={false}
|
||||
disableClose={true}
|
||||
onReportIssue={openChartFeedback}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -931,6 +1005,7 @@ function PolyWeatherTerminal({
|
||||
handleSelectCityForSlot(maximizedSlotIndex, null);
|
||||
setMaximizedSlotIndex(null);
|
||||
}}
|
||||
onReportIssue={openChartFeedback}
|
||||
isMaximized={true}
|
||||
disableClose={visibleSlots.filter(Boolean).length <= 1}
|
||||
/>
|
||||
@@ -1019,6 +1094,7 @@ function PolyWeatherTerminal({
|
||||
onClose={() => {
|
||||
handleSelectCityForSlot(slotIndex, null);
|
||||
}}
|
||||
onReportIssue={openChartFeedback}
|
||||
isMaximized={false}
|
||||
disableClose={visibleSlots.filter(Boolean).length <= 1}
|
||||
/>
|
||||
@@ -1046,6 +1122,11 @@ function PolyWeatherTerminal({
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
<UserFeedbackModal
|
||||
draft={feedbackDraft}
|
||||
isEn={isEn}
|
||||
onClose={() => setFeedbackDraft(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import clsx from "clsx";
|
||||
import { Bug } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
|
||||
import { useLatestPatch, useSseResyncVersion } from "@/hooks/use-sse-patches";
|
||||
@@ -166,6 +167,7 @@ export function LiveTemperatureThresholdChart({
|
||||
onSearchClick,
|
||||
onMaximize,
|
||||
onClose,
|
||||
onReportIssue,
|
||||
isMaximized = false,
|
||||
disableClose = false,
|
||||
isActive = !compact,
|
||||
@@ -178,6 +180,7 @@ export function LiveTemperatureThresholdChart({
|
||||
onSearchClick?: () => void;
|
||||
onMaximize?: () => void;
|
||||
onClose?: () => void;
|
||||
onReportIssue?: (context: Record<string, unknown>) => void;
|
||||
isMaximized?: boolean;
|
||||
disableClose?: boolean;
|
||||
isActive?: boolean;
|
||||
@@ -780,6 +783,54 @@ export function LiveTemperatureThresholdChart({
|
||||
setDetailRetryNonce((value) => value + 1);
|
||||
}, []);
|
||||
|
||||
const handleReportIssue = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
onReportIssue?.({
|
||||
source: "chart",
|
||||
city,
|
||||
display_city: row ? rowName(row) : "",
|
||||
row_id: row?.id || "",
|
||||
slot_index: slotIndex,
|
||||
compact,
|
||||
is_active: isActive,
|
||||
is_maximized: isMaximized,
|
||||
detail_error: detailError,
|
||||
is_hourly_loading: isHourlyLoading,
|
||||
showing_stale_detail: showingStaleDetail,
|
||||
target_resolution: targetResolution,
|
||||
view_mode: viewMode,
|
||||
loaded_local_date: chartLocalDate || "",
|
||||
has_runway_data: hasRunwayData,
|
||||
series: chartSeries.map((item) => item.key),
|
||||
visible_series: activeSeries.map((item) => item.key),
|
||||
live_temp: liveTemp,
|
||||
current_runway_temp: currentRunwayTemp,
|
||||
observed_high_metar: observedHighMetar,
|
||||
observed_high_runway: observedHighRunway,
|
||||
});
|
||||
}, [
|
||||
activeSeries,
|
||||
chartLocalDate,
|
||||
chartSeries,
|
||||
city,
|
||||
compact,
|
||||
currentRunwayTemp,
|
||||
detailError,
|
||||
hasRunwayData,
|
||||
isActive,
|
||||
isHourlyLoading,
|
||||
isMaximized,
|
||||
liveTemp,
|
||||
observedHighMetar,
|
||||
observedHighRunway,
|
||||
onReportIssue,
|
||||
row,
|
||||
showingStaleDetail,
|
||||
slotIndex,
|
||||
targetResolution,
|
||||
viewMode,
|
||||
]);
|
||||
|
||||
const panelTitle = row ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
@@ -842,8 +893,18 @@ export function LiveTemperatureThresholdChart({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(onMaximize || onClose) && (
|
||||
{(onMaximize || onClose || onReportIssue) && (
|
||||
<div className="flex items-center gap-1">
|
||||
{onReportIssue && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReportIssue}
|
||||
className="grid h-6 w-6 place-items-center rounded bg-white hover:bg-slate-50 border border-slate-200 text-slate-500 hover:text-amber-600 transition-colors shadow-sm"
|
||||
title={isEn ? "Report this chart" : "反馈此图表"}
|
||||
>
|
||||
<Bug size={13} />
|
||||
</button>
|
||||
)}
|
||||
{onMaximize && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
"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";
|
||||
|
||||
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(),
|
||||
};
|
||||
}
|
||||
|
||||
export function UserFeedbackModal({
|
||||
draft,
|
||||
isEn,
|
||||
onClose,
|
||||
}: {
|
||||
draft: FeedbackDraft | null;
|
||||
isEn: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const open = Boolean(draft);
|
||||
const [category, setCategory] = useState<FeedbackCategory>("bug");
|
||||
const [message, setMessage] = useState("");
|
||||
const [contact, setContact] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setCategory(draft?.category || "bug");
|
||||
setMessage("");
|
||||
setError("");
|
||||
setSubmitted(false);
|
||||
}, [open, draft?.category]);
|
||||
|
||||
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;
|
||||
|
||||
const submit = async () => {
|
||||
if (!canSubmit) return;
|
||||
setSubmitting(true);
|
||||
setError("");
|
||||
try {
|
||||
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 || {}),
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = (await res.text().catch(() => "")).slice(0, 200);
|
||||
throw new Error(detail || `HTTP ${res.status}`);
|
||||
}
|
||||
setSubmitted(true);
|
||||
} catch (err) {
|
||||
setError(String(err).slice(0, 220));
|
||||
} 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."
|
||||
: "已附带当前终端上下文保存,后续会在后台统一处理。"}
|
||||
</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">
|
||||
{isEn ? "Contact, optional" : "联系方式,可选"}
|
||||
</span>
|
||||
<input
|
||||
value={contact}
|
||||
onChange={(event) => setContact(event.target.value)}
|
||||
className="mt-1 h-9 w-full rounded border border-slate-200 bg-white px-3 text-sm text-slate-900 outline-none transition focus:border-blue-400 focus:ring-2 focus:ring-blue-100"
|
||||
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>
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
function assert(condition: unknown, message: string) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
export function runTests() {
|
||||
const projectRoot = process.cwd();
|
||||
const dashboardSource = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "dashboard", "ScanTerminalDashboard.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const chartSource = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "dashboard", "scan-terminal", "LiveTemperatureThresholdChart.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const modalPath = path.join(projectRoot, "components", "dashboard", "scan-terminal", "UserFeedbackModal.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");
|
||||
|
||||
assert(fs.existsSync(modalPath), "terminal must ship a user feedback modal component");
|
||||
const modalSource = fs.readFileSync(modalPath, "utf8");
|
||||
|
||||
assert(
|
||||
dashboardSource.includes("onFeedbackClick") &&
|
||||
dashboardSource.includes("<UserFeedbackModal") &&
|
||||
dashboardSource.includes("setFeedbackDraft"),
|
||||
"terminal sidebar must expose a feedback entry that opens the shared modal",
|
||||
);
|
||||
assert(
|
||||
chartSource.includes("onReportIssue") &&
|
||||
chartSource.includes("Bug") &&
|
||||
chartSource.includes("detailError"),
|
||||
"each chart must expose a report-this-chart action with chart loading/error context",
|
||||
);
|
||||
assert(
|
||||
modalSource.includes("/api/feedback") &&
|
||||
modalSource.includes("getAnalyticsClientId") &&
|
||||
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",
|
||||
);
|
||||
assert(
|
||||
opsSidebarSource.includes("/ops/feedback") &&
|
||||
opsApiSource.includes("feedback(") &&
|
||||
opsApiSource.includes("updateFeedbackStatus"),
|
||||
"ops must expose a feedback inbox in navigation and API client",
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user