/* ========================================================= Chatbot — Fran AI assistant Floating button + chat window. Talks to the Claude API via the Cloudflare Worker at CHAT_API_URL below. On detailed/contact intents, redirects to WhatsApp. ─── Bot abuse / cost containment ─────────────────────── The chatbot proxies to Anthropic via a public Worker, so an abusive client can burn API budget. Hard limits live on the Worker (server/worker.js) — per-IP RPM/RPH, daily token cap, payload size, system-prompt injection. Client-side defences here are UX-level only: • Max MAX_MSGS_PER_SESSION user messages per browser session • Min MIN_INTERVAL_MS between sends (debounce open-mic abuse) • Max MAX_INPUT_CHARS per input (truncates instead of crashing) • Single in-flight request — abort the previous on new send • NO system prompt sent from client (Worker injects it). Removes the prompt from the JS bundle and stops prompt overrides via the open proxy. ========================================================= */ const { useEffect: useEf, useRef: useRf, useState: useSt } = React; const WHATSAPP = "+41767312517"; const WA_URL = (msg) => `https://wa.me/${WHATSAPP.replace(/[^0-9]/g, "")}?text=${encodeURIComponent(msg)}`; // Cloudflare Worker that proxies Claude. The Worker is responsible for // origin allowlisting, rate-limit, payload validation, daily token cap // and system-prompt injection. See server/worker.js for the contract. const CHAT_API_URL = "https://claude-chatbot-api.frannypunto.workers.dev"; // Client-side soft caps. None of these are security boundaries — a // determined attacker bypasses them by clearing storage or hitting the // Worker directly. They exist to keep accidental loops + button-mash // abuse from a real visitor in check. const MAX_MSGS_PER_SESSION = 25; const MIN_INTERVAL_MS = 1500; const MAX_INPUT_CHARS = 1000; const SESSION_KEY = "fran-chat-session-count"; async function callChatAPI({ lang, messages }, signal) { const res = await fetch(CHAT_API_URL, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ lang, messages }), signal, }); if (!res.ok) { throw new Error(`Chat API ${res.status}`); } // Try JSON first; fall back to plain text. Look in the common reply fields. const ct = res.headers.get("content-type") || ""; if (ct.includes("application/json")) { const data = await res.json(); const fromContent = Array.isArray(data?.content) ? data.content.map((c) => (typeof c === "string" ? c : c?.text || "")).join("").trim() : ""; return ( data?.reply || data?.text || data?.message || data?.completion || data?.output || fromContent || "" ); } return (await res.text()).trim(); } const CHAT_T = { en: { open: "Chat with Fran's AI", title: "Fran's AI assistant", sub: "Quick answers in seconds", online: "Online", placeholder: "Ask anything about my work, pricing, process…", send: "Send", welcome: "Hi 👋 I'm Fran's AI assistant. Ask me about services, pricing, process or anything else. For a detailed quote or booking a call, I'll send you to Fran directly on WhatsApp.", waCta: "Talk to Fran on WhatsApp", waNote: "For detailed quotes, project briefs or scheduling a call, it's faster to chat with Fran directly:", suggestions: ["What services do you offer?", "How much does a website cost?", "Do you work with clients in Switzerland?", "Talk to Fran directly"], thinking: "Thinking…", error: "Something went wrong. Want to message Fran on WhatsApp?", waSeed: "Hi Fran! I'm visiting your site and would like to chat about a project.", }, de: { open: "Chatte mit Frans KI", title: "Frans KI-Assistent", sub: "Schnelle Antworten in Sekunden", online: "Online", placeholder: "Frag alles zu Projekten, Preisen, Ablauf…", send: "Senden", welcome: "Hi 👋 Ich bin Frans KI-Assistent. Frag mich zu Leistungen, Preisen oder dem Ablauf. Für eine ausführliche Offerte oder einen Anruf verbinde ich dich direkt mit Fran auf WhatsApp.", waCta: "Mit Fran auf WhatsApp schreiben", waNote: "Für detaillierte Offerten oder einen Termin chattest du am besten direkt mit Fran:", suggestions: ["Welche Leistungen bietest du an?", "Was kostet eine Website?", "Arbeitest du mit Kunden in der Schweiz?", "Direkt mit Fran sprechen"], thinking: "Denkt nach…", error: "Etwas ist schiefgelaufen. Mit Fran auf WhatsApp schreiben?", waSeed: "Hallo Fran! Ich besuche deine Website und möchte über ein Projekt sprechen.", }, es: { open: "Chatea con la IA de Fran", title: "Asistente IA de Fran", sub: "Respuestas rápidas en segundos", online: "Online", placeholder: "Pregunta lo que quieras: servicios, precios, proceso…", send: "Enviar", welcome: "¡Hola! 👋 Soy el asistente IA de Fran. Pregúntame sobre servicios, precios o el proceso. Para presupuesto detallado o agendar una llamada, te paso directo a Fran por WhatsApp.", waCta: "Hablar con Fran por WhatsApp", waNote: "Para presupuestos detallados o agendar una llamada, lo más rápido es chatear con Fran directamente:", suggestions: ["¿Qué servicios ofreces?", "¿Cuánto cuesta una web?", "¿Trabajas en Suiza?", "Hablar con Fran directamente"], thinking: "Pensando…", error: "Algo ha fallado. ¿Te paso a Fran por WhatsApp?", waSeed: "¡Hola Fran! Estoy en tu web y me gustaría hablar de un proyecto.", }, }; /* System prompt now lives server-side in the Cloudflare Worker (see server/worker.js). Keeping it out of the client bundle means: • visitors can't read it via View Source or DevTools • prompt-injection via the open proxy is blocked (Worker ignores any `system` field from the client and always injects its own) • we can update the assistant's behaviour without shipping a new JS bundle — redeploy the Worker only. */ const ChatBot = ({ lang }) => { const ct = CHAT_T[lang] || CHAT_T.en; const [open, setOpen] = useSt(false); const [msgs, setMsgs] = useSt([{ role: "assistant", content: ct.welcome }]); const [input, setInput] = useSt(""); const [busy, setBusy] = useSt(false); const bodyRef = useRf(null); const inputRef = useRf(null); // Track last send for the MIN_INTERVAL_MS debounce + the in-flight // controller so we can abort if the user sends again mid-request. const lastSendRef = useRf(0); const abortRef = useRf(null); // Reset welcome message on language change useEf(() => { setMsgs([{ role: "assistant", content: ct.welcome }]); }, [lang]); // Autoscroll on new message useEf(() => { if (bodyRef.current) bodyRef.current.scrollTop = bodyRef.current.scrollHeight; }, [msgs, busy]); // Focus input when opening useEf(() => { if (open && inputRef.current) setTimeout(() => inputRef.current.focus(), 200); }, [open]); // Abort any in-flight request when the component unmounts. useEf(() => () => { if (abortRef.current) abortRef.current.abort(); }, []); // Per-session message counter. sessionStorage resets when the tab // closes — feels less hostile than a long-lived cap. In-memory fallback // if storage is blocked (private mode, strict cookie settings). const getSessionCount = () => { try { return parseInt(sessionStorage.getItem(SESSION_KEY) || "0", 10) || 0; } catch { return 0; } }; const bumpSessionCount = () => { try { const n = getSessionCount() + 1; sessionStorage.setItem(SESSION_KEY, String(n)); return n; } catch { return 0; } }; const send = async (text) => { let q = (text ?? input).trim(); if (!q || busy) return; // Truncate over-long input client-side. The Worker rejects bigger // payloads anyway; truncating here gives a nicer UX than a 400. if (q.length > MAX_INPUT_CHARS) q = q.slice(0, MAX_INPUT_CHARS); // Debounce: refuse if user is hammering Send faster than the cap. const now = Date.now(); if (now - lastSendRef.current < MIN_INTERVAL_MS) return; // Per-session ceiling — soft-redirect to WhatsApp once exhausted // rather than silently failing. if (getSessionCount() >= MAX_MSGS_PER_SESSION) { setMsgs((m) => [...m, { role: "user", content: q }, { role: "assistant", content: ct.waNote, whatsapp: true }]); setInput(""); return; } setInput(""); // If user clicks "talk to Fran" suggestion → straight to WhatsApp const lc = q.toLowerCase(); if (lc.includes("whatsapp") || lc === ct.suggestions[3].toLowerCase()) { setMsgs((m) => [...m, { role: "user", content: q }, { role: "assistant", content: ct.waNote, whatsapp: true }]); return; } setMsgs((m) => [...m, { role: "user", content: q }]); setBusy(true); lastSendRef.current = now; bumpSessionCount(); // Cancel any prior in-flight request — only one active fetch. if (abortRef.current) abortRef.current.abort(); const controller = new AbortController(); abortRef.current = controller; try { // Build messages array (use only recent history to keep prompt small). // The Worker injects the system prompt server-side; we only send // `lang` so it picks the right language variant. const history = [...msgs, { role: "user", content: q }] .slice(-8) .map((m) => ({ role: m.role, content: m.content })); const reply = await callChatAPI({ lang, messages: history }, controller.signal); const hasWa = /\[WHATSAPP\]/i.test(reply); const clean = reply.replace(/\[WHATSAPP\]/gi, "").trim(); setMsgs((m) => [...m, { role: "assistant", content: clean || ct.waNote, whatsapp: hasWa }]); } catch (e) { // Swallow abort errors silently — they're our own doing. if (e?.name !== "AbortError") { setMsgs((m) => [...m, { role: "assistant", content: ct.error, whatsapp: true }]); } } finally { if (abortRef.current === controller) abortRef.current = null; setBusy(false); } }; return ( <>