import * as PopoverPrimitive from "@radix-ui/react-popover"; import * as TooltipPrimitive from "@radix-ui/react-tooltip"; import { IconMessage2, IconCheck } from "@tabler/icons-react"; import { useState, useEffect, useRef, useCallback, type CSSProperties, type FormEvent, type ReactNode, } from "react"; import { DEFAULT_LOCALE, type LocaleCode } from "../localization/shared.js"; import { getFeedbackClientContext } from "./feedback-context.js"; import { useOptionalLocale } from "./i18n.js"; import { useSession } from "./use-session.js"; import { cn } from "./utils.js"; function isSyntheticAgentNativeAnonymousEmail( value: string | null | undefined, ): boolean { return /^anon-[^\s@]+@agent-native\.com$/i.test(value ?? ""); } interface ParsedTarget { endpoint: string; slug: string; } interface FormSchema { formId: string; fieldId: string; } const FEEDBACK_COPY: Record< LocaleCode, { label: string; placeholder: string; submit: string; submitting: string; success: string; loadError: string; invalidUrl: string; emptyError: string; sendError: string; keyboardHint: string; } > = { "en-US": { label: "Feedback", placeholder: "What's working, what's broken, or what would you change?", submit: "Send feedback", submitting: "Sending...", success: "Thanks for the feedback!", loadError: "Couldn't load feedback form", invalidUrl: "Invalid feedback URL", emptyError: "Please write something first", sendError: "Couldn't send feedback", keyboardHint: "{{shortcut}}+Enter to send", }, "zh-CN": { label: "反馈", placeholder: "哪些地方好用、哪里坏了,或你想改什么?", submit: "发送反馈", submitting: "正在发送...", success: "感谢你的反馈!", loadError: "无法加载反馈表单", invalidUrl: "反馈 URL 无效", emptyError: "请先写点内容", sendError: "无法发送反馈", keyboardHint: "{{shortcut}}+Enter 发送", }, "zh-TW": { label: "意見回饋", placeholder: "哪些地方好用、哪裡壞了,或你想改什麼?", submit: "送出意見回饋", submitting: "正在送出...", success: "感謝你的意見回饋。", loadError: "無法載入意見回饋表單", invalidUrl: "意見回饋 URL 無效", emptyError: "請先輸入內容", sendError: "無法送出意見回饋", keyboardHint: "{{shortcut}}+Enter 送出", }, "es-ES": { label: "Comentarios", placeholder: "¿Qué funciona, qué falla o qué cambiarías?", submit: "Enviar comentarios", submitting: "Enviando...", success: "Gracias por tus comentarios.", loadError: "No se pudo cargar el formulario", invalidUrl: "URL de comentarios no válida", emptyError: "Escribe algo primero", sendError: "No se pudieron enviar los comentarios", keyboardHint: "{{shortcut}}+Enter para enviar", }, "fr-FR": { label: "Retour", placeholder: "Qu'est-ce qui marche, casse ou devrait changer ?", submit: "Envoyer", submitting: "Envoi...", success: "Merci pour votre retour.", loadError: "Impossible de charger le formulaire", invalidUrl: "URL de retour invalide", emptyError: "Écrivez quelque chose d'abord", sendError: "Impossible d'envoyer le retour", keyboardHint: "{{shortcut}}+Entrée pour envoyer", }, "de-DE": { label: "Feedback", placeholder: "Was funktioniert, was ist kaputt, was würdest du ändern?", submit: "Feedback senden", submitting: "Wird gesendet...", success: "Danke für dein Feedback!", loadError: "Feedback-Formular konnte nicht geladen werden", invalidUrl: "Ungültige Feedback-URL", emptyError: "Bitte zuerst etwas schreiben", sendError: "Feedback konnte nicht gesendet werden", keyboardHint: "{{shortcut}}+Enter zum Senden", }, "ja-JP": { label: "フィードバック", placeholder: "良い点、問題点、変更したい点を教えてください。", submit: "送信", submitting: "送信中...", success: "フィードバックありがとうございます。", loadError: "フォームを読み込めませんでした", invalidUrl: "フィードバック URL が無効です", emptyError: "先に内容を入力してください", sendError: "送信できませんでした", keyboardHint: "{{shortcut}}+Enter で送信", }, "ko-KR": { label: "피드백", placeholder: "잘 되는 점, 깨진 점, 바꾸고 싶은 점을 알려주세요.", submit: "피드백 보내기", submitting: "보내는 중...", success: "피드백 감사합니다.", loadError: "피드백 양식을 불러올 수 없습니다", invalidUrl: "피드백 URL이 올바르지 않습니다", emptyError: "먼저 내용을 입력해 주세요", sendError: "피드백을 보낼 수 없습니다", keyboardHint: "{{shortcut}}+Enter로 보내기", }, "pt-BR": { label: "Feedback", placeholder: "O que funciona, quebrou ou você mudaria?", submit: "Enviar feedback", submitting: "Enviando...", success: "Obrigado pelo feedback!", loadError: "Não foi possível carregar o formulário", invalidUrl: "URL de feedback inválida", emptyError: "Escreva algo primeiro", sendError: "Não foi possível enviar o feedback", keyboardHint: "{{shortcut}}+Enter para enviar", }, "hi-IN": { label: "फ़ीडबैक", placeholder: "क्या काम कर रहा है, क्या टूटा है, या आप क्या बदलना चाहेंगे?", submit: "फ़ीडबैक भेजें", submitting: "भेजा जा रहा है...", success: "फ़ीडबैक के लिए धन्यवाद!", loadError: "फ़ीडबैक फ़ॉर्म लोड नहीं हुआ", invalidUrl: "फ़ीडबैक URL अमान्य है", emptyError: "पहले कुछ लिखें", sendError: "फ़ीडबैक भेजा नहीं जा सका", keyboardHint: "भेजने के लिए {{shortcut}}+Enter", }, "ar-SA": { label: "ملاحظات", placeholder: "ما الذي يعمل، وما المعطل، وما الذي تريد تغييره؟", submit: "إرسال الملاحظات", submitting: "جار الإرسال...", success: "شكرا لملاحظاتك!", loadError: "تعذر تحميل نموذج الملاحظات", invalidUrl: "رابط الملاحظات غير صالح", emptyError: "اكتب شيئا أولا", sendError: "تعذر إرسال الملاحظات", keyboardHint: "{{shortcut}}+Enter للإرسال", }, }; function parseTarget(url: string): ParsedTarget | null { try { const u = new URL(url); const idx = u.pathname.indexOf("/f/"); if (idx === -1) return null; const slug = u.pathname.slice(idx + 3).replace(/\/$/, ""); if (!slug) return null; return { endpoint: u.origin, slug }; } catch { return null; } } const schemaCache = new Map>(); async function loadSchema(target: ParsedTarget): Promise { const key = `${target.endpoint}|${target.slug}`; let pending = schemaCache.get(key); if (pending) return pending; pending = (async () => { const res = await fetch( `${target.endpoint}/api/forms/public/${encodeURIComponent(target.slug)}`, { headers: { Accept: "application/json" } }, ); if (!res.ok) throw new Error(`form fetch ${res.status}`); const body = (await res.json()) as { id: string; fields: Array<{ id: string; type: string }>; }; const field = body.fields.find((f) => f.type === "textarea") ?? body.fields.find((f) => f.type === "text") ?? body.fields[0]; if (!field) throw new Error("form has no fields"); return { formId: body.id, fieldId: field.id }; })(); pending.catch(() => schemaCache.delete(key)); schemaCache.set(key, pending); return pending; } export interface FeedbackButtonProps { /** * "sidebar" renders a full-width row with icon + label (for app left sidebars). * "icon" renders a small icon-only button (for dense toolbars, e.g. the agent panel header). * "outlined" renders an outlined pill button with icon + label (for top-nav bars, e.g. docs). */ variant?: "sidebar" | "icon" | "outlined"; label?: string; /** Defaults to VITE_AGENT_NATIVE_FEEDBACK_URL. Missing, blank, or null hides the control. */ url?: string | null; className?: string; /** Which side the popover opens on. Defaults match the variant. */ side?: "top" | "bottom" | "left" | "right"; align?: "start" | "center" | "end"; /** Placeholder text for the textarea. */ placeholder?: string; /** Optional text to prefill when the popover opens. */ initialValue?: string; /** Current chat session/thread id, when the host already knows it. */ chatSessionId?: string | null; /** Chat localStorage namespace, when the host uses per-app chat storage. */ chatStorageKey?: string | null; /** Controlled popover open state for hosts that trigger feedback from a menu. */ open?: boolean; onOpenChange?: (open: boolean) => void; /** Optional custom trigger element. */ trigger?: ReactNode; } const surfaceStyle: CSSProperties = { width: "min(380px, calc(100vw - 32px))", }; const honeypotStyle: CSSProperties = { position: "absolute", left: "-10000px", top: "auto", width: "1px", height: "1px", overflow: "hidden", }; function clientEnv(): Record | undefined { const importMetaEnv = ( import.meta as unknown as { env?: Record; } ).env; const processEnv = ( globalThis as typeof globalThis & { process?: { env?: Record }; } ).process?.env; if (importMetaEnv && processEnv) return { ...processEnv, ...importMetaEnv }; return importMetaEnv ?? processEnv; } export function resolveFeedbackUrl(url?: string | null): string | null { const value = url === undefined ? clientEnv()?.VITE_AGENT_NATIVE_FEEDBACK_URL : url; return typeof value === "string" && value.trim() ? value.trim() : null; } export function FeedbackButton(props: FeedbackButtonProps) { const url = resolveFeedbackUrl(props.url); if (!url) return null; return ; } function FeedbackPopoverButton({ variant = "sidebar", label, url, className, side, align = "end", placeholder, initialValue, chatSessionId, chatStorageKey, open: controlledOpen, onOpenChange, trigger: customTrigger, }: Omit & { url: string }) { const target = parseTarget(url); const { session } = useSession(); const localeContext = useOptionalLocale(); const locale = localeContext?.locale ?? DEFAULT_LOCALE; const copy = FEEDBACK_COPY[locale] ?? FEEDBACK_COPY[DEFAULT_LOCALE]; const resolvedLabel = label ?? copy.label; const [uncontrolledOpen, setUncontrolledOpen] = useState(false); const open = controlledOpen ?? uncontrolledOpen; const setOpen = useCallback( (nextOpen: boolean) => { if (controlledOpen === undefined) setUncontrolledOpen(nextOpen); onOpenChange?.(nextOpen); }, [controlledOpen, onOpenChange], ); const [value, setValue] = useState(""); const [honeypot, setHoneypot] = useState(""); const [submitting, setSubmitting] = useState(false); const [submitted, setSubmitted] = useState(false); const [error, setError] = useState(null); const [schema, setSchema] = useState(null); const openedAtRef = useRef(0); const closeTimerRef = useRef | null>(null); const textareaRef = useRef(null); // Reset transient state and kick off schema load on each open. useEffect(() => { if (!open) return; openedAtRef.current = Date.now(); setValue(initialValue ?? ""); setHoneypot(""); setSubmitting(false); setSubmitted(false); setError(null); setSchema(null); if (target) { loadSchema(target) .then((s) => setSchema(s)) .catch((err) => { console.error("[FeedbackButton] schema load failed", err); setError(copy.loadError); }); } else { setError(copy.invalidUrl); } const t = setTimeout(() => textareaRef.current?.focus(), 30); return () => { clearTimeout(t); if (closeTimerRef.current) { clearTimeout(closeTimerRef.current); closeTimerRef.current = null; } }; }, [copy.invalidUrl, copy.loadError, initialValue, open, url]); const submit = useCallback( async (e?: FormEvent) => { e?.preventDefault(); if (!target || submitting) return; const trimmed = value.trim(); if (!trimmed) { setError(copy.emptyError); return; } setSubmitting(true); setError(null); try { const resolvedSchema = schema ?? (await loadSchema(target)); if (!schema) setSchema(resolvedSchema); const submitterEmail = isSyntheticAgentNativeAnonymousEmail( session?.email, ) ? null : session?.email; const feedbackContext = getFeedbackClientContext({ chatSessionId, storageKey: chatStorageKey, }); const res = await fetch( `${target.endpoint}/api/submit/${encodeURIComponent(resolvedSchema.formId)}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ data: { [resolvedSchema.fieldId]: trimmed }, _t: openedAtRef.current, _hp: honeypot, _meta: { ...(submitterEmail ? { submitterEmail } : {}), ...feedbackContext, }, }), }, ); if (!res.ok) { const body = (await res.json().catch(() => ({}))) as { error?: string; }; throw new Error(body.error || `submit failed (${res.status})`); } setSubmitted(true); closeTimerRef.current = setTimeout(() => setOpen(false), 1400); } catch (err) { setSubmitting(false); setError(err instanceof Error ? err.message : copy.sendError); } }, [ target, schema, value, honeypot, submitting, session?.email, chatSessionId, chatStorageKey, setOpen, copy.emptyError, copy.sendError, ], ); let trigger; if (customTrigger) { trigger = ( {customTrigger} ); } else if (variant === "icon") { trigger = ( {resolvedLabel} ); } else if (variant === "outlined") { trigger = ( ); } else { trigger = ( ); } const resolvedSide = side ?? (variant === "sidebar" ? "top" : "bottom"); const resolvedPlaceholder = placeholder ?? copy.placeholder; return ( {trigger} {submitted ? (
{copy.success}
) : (