Closes #67 Add opt-in Feedback Hub widget integrated into the Settings Logs card. Routes through a Rust command to bypass CORS and centralize privacy audit. First submission triggers a one-time consent dialog; three opt-in checkboxes (context, logs, identify with Maximus account) all unchecked by default. Wording and payload follow the cross-app conventions in la-compagnie-maximus/docs/feedback-hub-ops.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
239 lines
8.7 KiB
TypeScript
239 lines
8.7 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useLocation } from "react-router-dom";
|
|
import { X, MessageSquarePlus, CheckCircle, AlertCircle } from "lucide-react";
|
|
import { useFeedback } from "../../hooks/useFeedback";
|
|
import { useAuth } from "../../hooks/useAuth";
|
|
import { getRecentErrorLogs } from "../../services/logService";
|
|
import {
|
|
getFeedbackUserAgent,
|
|
type FeedbackContext,
|
|
} from "../../services/feedbackService";
|
|
|
|
const MAX_CONTENT_LENGTH = 2000;
|
|
const LOGS_SUFFIX_MAX = 800;
|
|
const RECENT_ERROR_LOGS_N = 20;
|
|
const AUTO_CLOSE_DELAY_MS = 2000;
|
|
|
|
interface FeedbackDialogProps {
|
|
onClose: () => void;
|
|
}
|
|
|
|
export default function FeedbackDialog({ onClose }: FeedbackDialogProps) {
|
|
const { t, i18n } = useTranslation();
|
|
const location = useLocation();
|
|
const { state: authState } = useAuth();
|
|
const { state: feedbackState, submit, reset } = useFeedback();
|
|
|
|
const [content, setContent] = useState("");
|
|
const [includeContext, setIncludeContext] = useState(false);
|
|
const [includeLogs, setIncludeLogs] = useState(false);
|
|
const [identify, setIdentify] = useState(false);
|
|
|
|
const isAuthenticated = authState.status === "authenticated";
|
|
const userEmail = authState.account?.email ?? null;
|
|
|
|
const trimmed = content.trim();
|
|
const isSending = feedbackState.status === "sending";
|
|
const isSuccess = feedbackState.status === "success";
|
|
const canSubmit = trimmed.length > 0 && !isSending && !isSuccess;
|
|
|
|
useEffect(() => {
|
|
function handleEscape(e: KeyboardEvent) {
|
|
if (e.key === "Escape" && !isSending) onClose();
|
|
}
|
|
document.addEventListener("keydown", handleEscape);
|
|
return () => document.removeEventListener("keydown", handleEscape);
|
|
}, [onClose, isSending]);
|
|
|
|
// Auto-close after success
|
|
useEffect(() => {
|
|
if (!isSuccess) return;
|
|
const timer = setTimeout(() => {
|
|
onClose();
|
|
reset();
|
|
}, AUTO_CLOSE_DELAY_MS);
|
|
return () => clearTimeout(timer);
|
|
}, [isSuccess, onClose, reset]);
|
|
|
|
const errorMessage = useMemo(() => {
|
|
if (feedbackState.status !== "error" || !feedbackState.errorCode) return null;
|
|
switch (feedbackState.errorCode) {
|
|
case "rate_limit":
|
|
return t("feedback.toast.error.429");
|
|
case "invalid":
|
|
return t("feedback.toast.error.400");
|
|
case "network_error":
|
|
case "server_error":
|
|
default:
|
|
return t("feedback.toast.error.generic");
|
|
}
|
|
}, [feedbackState, t]);
|
|
|
|
async function handleSubmit() {
|
|
if (!canSubmit) return;
|
|
|
|
// Compose content with optional logs suffix, keeping total ≤ 2000 chars
|
|
let body = trimmed;
|
|
if (includeLogs) {
|
|
const rawLogs = getRecentErrorLogs(RECENT_ERROR_LOGS_N);
|
|
if (rawLogs.length > 0) {
|
|
const trimmedLogs = rawLogs.slice(-LOGS_SUFFIX_MAX);
|
|
const suffix = `\n\n---\n${t("feedback.logsHeading")}\n${trimmedLogs}`;
|
|
const available = MAX_CONTENT_LENGTH - body.length;
|
|
if (available > suffix.length) {
|
|
body = body + suffix;
|
|
} else if (available > 50) {
|
|
body = body + suffix.slice(0, available);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Compose context if opted in
|
|
let context: FeedbackContext | undefined;
|
|
if (includeContext) {
|
|
let userAgent = "Simpl'Résultat";
|
|
try {
|
|
userAgent = await getFeedbackUserAgent();
|
|
} catch {
|
|
// fall back to a basic string if the Rust helper fails
|
|
}
|
|
context = {
|
|
page: location.pathname,
|
|
locale: i18n.language,
|
|
theme: document.documentElement.classList.contains("dark") ? "dark" : "light",
|
|
viewport: `${window.innerWidth}x${window.innerHeight}`,
|
|
userAgent,
|
|
timestamp: new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
const userId = identify && isAuthenticated ? userEmail : null;
|
|
|
|
await submit({ content: body, userId, context });
|
|
}
|
|
|
|
return createPortal(
|
|
<div
|
|
className="fixed inset-0 z-[200] flex items-center justify-center bg-black/50"
|
|
onClick={(e) => {
|
|
if (e.target === e.currentTarget && !isSending) onClose();
|
|
}}
|
|
>
|
|
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] shadow-2xl w-full max-w-lg mx-4">
|
|
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--border)]">
|
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
|
<MessageSquarePlus size={18} />
|
|
{t("feedback.dialog.title")}
|
|
</h2>
|
|
<button
|
|
onClick={onClose}
|
|
disabled={isSending}
|
|
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)] disabled:opacity-50"
|
|
aria-label={t("feedback.dialog.cancel")}
|
|
>
|
|
<X size={18} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="px-6 py-4 space-y-3">
|
|
{isSuccess ? (
|
|
<div className="flex items-center gap-2 text-[var(--positive)] py-8 justify-center">
|
|
<CheckCircle size={20} />
|
|
<span>{t("feedback.toast.success")}</span>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<textarea
|
|
value={content}
|
|
onChange={(e) =>
|
|
setContent(e.target.value.slice(0, MAX_CONTENT_LENGTH))
|
|
}
|
|
placeholder={t("feedback.dialog.placeholder")}
|
|
rows={6}
|
|
disabled={isSending}
|
|
className="w-full px-3 py-2 rounded-lg bg-[var(--background)] border border-[var(--border)] text-sm resize-y focus:outline-none focus:ring-2 focus:ring-[var(--primary)] disabled:opacity-50"
|
|
/>
|
|
<div className="flex justify-end text-xs text-[var(--muted-foreground)]">
|
|
{content.length}/{MAX_CONTENT_LENGTH}
|
|
</div>
|
|
|
|
<div className="space-y-2 text-sm">
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={includeContext}
|
|
onChange={(e) => setIncludeContext(e.target.checked)}
|
|
disabled={isSending}
|
|
className="h-4 w-4"
|
|
/>
|
|
<span>{t("feedback.checkbox.context")}</span>
|
|
</label>
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={includeLogs}
|
|
onChange={(e) => setIncludeLogs(e.target.checked)}
|
|
disabled={isSending}
|
|
className="h-4 w-4"
|
|
/>
|
|
<span>{t("feedback.checkbox.logs")}</span>
|
|
</label>
|
|
{isAuthenticated && (
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={identify}
|
|
onChange={(e) => setIdentify(e.target.checked)}
|
|
disabled={isSending}
|
|
className="h-4 w-4"
|
|
/>
|
|
<span>
|
|
{t("feedback.checkbox.identify")}
|
|
{userEmail && (
|
|
<span className="text-[var(--muted-foreground)]">
|
|
{" "}
|
|
({userEmail})
|
|
</span>
|
|
)}
|
|
</span>
|
|
</label>
|
|
)}
|
|
</div>
|
|
|
|
{errorMessage && (
|
|
<div className="flex items-start gap-2 text-sm text-[var(--negative)]">
|
|
<AlertCircle size={16} className="mt-0.5 shrink-0" />
|
|
<span>{errorMessage}</span>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{!isSuccess && (
|
|
<div className="flex justify-end gap-2 px-6 py-4 border-t border-[var(--border)]">
|
|
<button
|
|
onClick={onClose}
|
|
disabled={isSending}
|
|
className="px-4 py-2 text-sm border border-[var(--border)] rounded-lg hover:bg-[var(--border)] transition-colors disabled:opacity-50"
|
|
>
|
|
{t("feedback.dialog.cancel")}
|
|
</button>
|
|
<button
|
|
onClick={handleSubmit}
|
|
disabled={!canSubmit}
|
|
className="px-4 py-2 text-sm bg-[var(--primary)] text-[var(--primary-foreground)] rounded-lg hover:opacity-90 transition-opacity disabled:opacity-50"
|
|
>
|
|
{isSending
|
|
? t("feedback.dialog.sending")
|
|
: t("feedback.dialog.submit")}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
);
|
|
}
|