import React, { useCallback, useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Sparkles, KeyRound, ExternalLink, Loader2, Lock, CheckCircle2, XCircle, BarChart3, Plug, Eye, EyeOff, Trash2, Save, Pencil, RotateCcw, ChevronDown, ChevronUp, AlertTriangle, MessageCircle, Crown, Info, } from "lucide-react"; import { __ } from "../lib/i18n"; import { useToast } from "../components/ui/toast"; import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; import { Label } from "../components/ui/label"; import { Select } from "../components/ui/select"; import { ModulePageSkeleton, ModuleStatGridSkeleton, ModuleSectionSkeleton, } from "../components/ui/module-skeleton"; import { PageHeader } from "../components/common/PageHeader"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "../components/ui/card"; import { aiApi, type AiMeta, type AiBrandVoice, type AiChatLimits, type AiPromptRow, type AiUsageSummary, } from "../api/ai-api"; type SectionId = "keys" | "voice" | "prompts" | "usage"; interface SectionDef { id: SectionId; label: string; icon: React.ComponentType<{ className?: string }>; description: string; } const SECTIONS: SectionDef[] = [ { id: "keys", label: "API Keys", icon: KeyRound, description: "Bring your own OpenAI or Anthropic key. Keys are stored encrypted and never leave your server.", }, { id: "voice", label: "Brand Voice", icon: Sparkles, description: "Tone, examples, vocabulary preferences — applied to every generation so AI output sounds like your business.", }, { id: "prompts", label: "Prompts", icon: Pencil, description: "Inspect or edit every system + user prompt the assistant uses. Overrides fall back to defaults per-field.", }, { id: "usage", label: "Usage", icon: BarChart3, description: "Token usage by month, per provider, per feature.", }, ]; const DEFAULT_BRAND_VOICE: AiBrandVoice = { tone: "", examples: [], forbidden: [], required: [], language: "en-US", default_provider: "openai", default_model: "", }; /* -------------------------------------------------------------------------- */ /* Plan tier badge */ /* -------------------------------------------------------------------------- */ /** * Pill rendered in the AI Assistant page header so operators see at a * glance which license tier unlocks this page. Mirrors the badge style * used on the Modules page card so the language is consistent across * surfaces. Keep colors aligned with the Modules.tsx badges: * growth → emerald/teal gradient * agency → purple/indigo gradient */ const PlanBadge: React.FC<{ tier: "growth" | "agency" }> = ({ tier }) => { if (tier === "agency") { return ( {__("Scale plan", "yatra")} ); } return ( {__("Growth plan", "yatra")} ); }; /* -------------------------------------------------------------------------- */ /* Upgrade card */ /* -------------------------------------------------------------------------- */ const UpgradeCard: React.FC<{ meta?: AiMeta }> = ({ meta }) => { const upgradeUrl = meta?.upgrade_url || "https://wpyatra.com/pricing?module=ai-assistant"; const licensePageUrl = meta?.license_page_url || "admin.php?page=yatra&subpage=license"; return (
{__("AI Assistant", "yatra")}
{__( "AI Assistant unlocks on the Growth plan (or Scale). Bring your own OpenAI or Anthropic key — costs are paid directly to your provider, no per-call markup from the plugin.", "yatra", )}

{__( "Inline AI affordances on trip descriptions, SEO meta, included/excluded items, FAQs, and more — write less, finish more.", "yatra", )}

{meta?.is_pro_active && ( )}
); }; /* -------------------------------------------------------------------------- */ /* Settings page */ /* -------------------------------------------------------------------------- */ const AiAssistant: React.FC = () => { const { showToast } = useToast(); const queryClient = useQueryClient(); const [activeSection, setActiveSection] = useState("keys"); // ---------- meta ---------- const { data: meta, isLoading: metaLoading } = useQuery({ queryKey: ["ai-meta"], queryFn: () => aiApi.meta(), }); // ---------- brand voice ---------- const { data: bvResp } = useQuery<{ data: AiBrandVoice }>({ queryKey: ["ai-brand-voice"], queryFn: () => aiApi.getBrandVoice(), enabled: Boolean(meta?.is_ai_eligible), }); const [bv, setBv] = useState(DEFAULT_BRAND_VOICE); useEffect(() => { if (bvResp?.data) { setBv({ ...DEFAULT_BRAND_VOICE, ...bvResp.data }); } }, [bvResp]); const saveBvMutation = useMutation({ mutationFn: (data: AiBrandVoice) => aiApi.saveBrandVoice(data), onSuccess: () => { showToast(__("Brand voice saved.", "yatra"), "success"); queryClient.invalidateQueries({ queryKey: ["ai-brand-voice"] }); }, onError: (e: any) => showToast(extractError(e), "error"), }); // ---------- API keys ---------- const [keyDrafts, setKeyDrafts] = useState>({}); const [showKey, setShowKey] = useState>({}); const setKeyMutation = useMutation({ mutationFn: ({ provider, key }: { provider: string; key: string }) => aiApi.setKey(provider, key), onSuccess: (_resp, vars) => { showToast(__("API key saved.", "yatra"), "success"); setKeyDrafts((d) => ({ ...d, [vars.provider]: "" })); queryClient.invalidateQueries({ queryKey: ["ai-meta"] }); }, onError: (e: any) => showToast(extractError(e), "error"), }); const deleteKeyMutation = useMutation({ mutationFn: (provider: string) => aiApi.deleteKey(provider), onSuccess: () => { showToast(__("API key cleared.", "yatra"), "success"); queryClient.invalidateQueries({ queryKey: ["ai-meta"] }); }, onError: (e: any) => showToast(extractError(e), "error"), }); const testKeyMutation = useMutation({ mutationFn: (provider: string) => aiApi.testKey(provider), onSuccess: (resp) => { showToast( resp.message || __("Connection successful.", "yatra"), "success", ); }, onError: (e: any) => showToast(extractError(e), "error"), }); // ---------- public chat toggle ---------- const tripChatMutation = useMutation({ mutationFn: (enabled: boolean) => aiApi.setTripChatEnabled(enabled), onSuccess: (resp) => { showToast(resp.message, "success"); queryClient.invalidateQueries({ queryKey: ["ai-meta"] }); }, onError: (e: any) => showToast(extractError(e), "error"), }); // ---------- public chat rate-limits (operator-configurable) ---------- const [chatLimitsDraft, setChatLimitsDraft] = useState< Record >({ per_trip_day: "", per_ip_hour: "", per_session: "", per_session_booking: "", max_message_chars: "", history_turns: "", }); // Hydrate the form whenever the server values arrive / change. useEffect(() => { if (meta?.trip_chat_limits) { const l = meta.trip_chat_limits; setChatLimitsDraft({ per_trip_day: String(l.per_trip_day), per_ip_hour: String(l.per_ip_hour), per_session: String(l.per_session), per_session_booking: String(l.per_session_booking), max_message_chars: String(l.max_message_chars), history_turns: String(l.history_turns), }); } }, [meta?.trip_chat_limits]); const tripChatLimitsMutation = useMutation({ mutationFn: (patch: Partial) => aiApi.setTripChatLimits(patch), onSuccess: (resp) => { showToast(resp.message, "success"); queryClient.invalidateQueries({ queryKey: ["ai-meta"] }); }, onError: (e: any) => showToast(extractError(e), "error"), }); // ---------- usage ---------- const { data: usageResp } = useQuery<{ data: AiUsageSummary }>({ queryKey: ["ai-usage"], queryFn: () => aiApi.getUsage(), enabled: Boolean(meta?.is_ai_eligible) && activeSection === "usage", }); // ---------- prompts ---------- const { data: promptsResp, isLoading: promptsLoading } = useQuery<{ data: AiPromptRow[]; }>({ queryKey: ["ai-prompts"], queryFn: () => aiApi.listPrompts(), enabled: Boolean(meta?.is_ai_eligible) && activeSection === "prompts", }); const [expandedPrompt, setExpandedPrompt] = useState(null); const [promptDrafts, setPromptDrafts] = useState< Record< string, { system: string; user: string; max_tokens: string; temperature: string; } > >({}); // Hydrate drafts whenever the prompt list changes (e.g. after save/reset). useEffect(() => { if (!promptsResp?.data) return; const next: typeof promptDrafts = {}; for (const row of promptsResp.data) { const effSystem = row.override.system && row.override.system !== "" ? row.override.system : row.default.system; const effUser = row.override.user && row.override.user !== "" ? row.override.user : row.default.user; const effMaxTokens = row.override.max_tokens != null ? String(row.override.max_tokens) : String(row.default.max_tokens); const effTemperature = row.override.temperature != null ? String(row.override.temperature) : String(row.default.temperature); next[row.task] = { system: effSystem, user: effUser, max_tokens: effMaxTokens, temperature: effTemperature, }; } setPromptDrafts(next); }, [promptsResp]); const savePromptMutation = useMutation({ mutationFn: ({ task, override, }: { task: string; override: Parameters[1]; }) => aiApi.savePromptOverride(task, override), onSuccess: () => { showToast(__("Prompt saved.", "yatra"), "success"); queryClient.invalidateQueries({ queryKey: ["ai-prompts"] }); }, onError: (e: any) => showToast(extractError(e), "error"), }); const resetPromptMutation = useMutation({ mutationFn: (task: string) => aiApi.resetPrompt(task), onSuccess: () => { showToast(__("Prompt reset to default.", "yatra"), "success"); queryClient.invalidateQueries({ queryKey: ["ai-prompts"] }); }, onError: (e: any) => showToast(extractError(e), "error"), }); const resetAllPromptsMutation = useMutation({ mutationFn: () => aiApi.resetAllPrompts(), onSuccess: () => { showToast(__("All prompts reset to defaults.", "yatra"), "success"); queryClient.invalidateQueries({ queryKey: ["ai-prompts"] }); }, onError: (e: any) => showToast(extractError(e), "error"), }); /* --------------------------- early-out states --------------------------- */ if (metaLoading) { return ; } if (!meta || !meta.is_ai_eligible) { return (
); } const moduleEnabled = meta.is_module_enabled; /* ------------------------------- helpers ------------------------------- */ const updateBv = ( key: K, value: AiBrandVoice[K], ) => setBv((prev) => ({ ...prev, [key]: value })); const updateBvList = ( key: "examples" | "forbidden" | "required", raw: string, ) => { // Textarea → array of trimmed lines. Empty lines drop. const list = raw .split(/\r?\n/) .map((s) => s.trim()) .filter((s) => s !== ""); setBv((prev) => ({ ...prev, [key]: list })); }; const submitBv = (e: React.FormEvent) => { e.preventDefault(); saveBvMutation.mutate(bv); }; /* ------------------------------- renderers ------------------------------- */ // Per-provider docs metadata — explains why an operator needs the // key + where to find it + the canonical pricing page. Keyed by // provider id so adding a future provider is one entry. Source of // truth: each provider's published docs. const providerDocs: Record< string, { whyDescription: string; whereToFind: string; docsUrl: string; pricingUrl: string; } > = { openai: { whyDescription: __( "Authenticates every request the plugin sends to OpenAI on your behalf. OpenAI bills your account directly per request — the plugin adds no markup. Without a key, the AI sparkle affordances on Trip / SEO / Email editors stay disabled.", "yatra", ), whereToFind: __( "Sign in at platform.openai.com → API keys → Create new secret key. Copy the value (starts with sk-…) immediately — OpenAI shows it only once.", "yatra", ), docsUrl: "https://platform.openai.com/api-keys", pricingUrl: "https://openai.com/api/pricing/", }, anthropic: { whyDescription: __( "Authenticates requests to Anthropic's Claude API. Anthropic bills your account directly per request — no plugin markup. Used for the same in-editor sparkle features OpenAI handles; pick whichever provider your team prefers.", "yatra", ), whereToFind: __( "Sign in at console.anthropic.com → Settings → API Keys → Create Key. Copy the value (starts with sk-ant-…) immediately — Anthropic shows it only once.", "yatra", ), docsUrl: "https://console.anthropic.com/settings/keys", pricingUrl: "https://www.anthropic.com/pricing#anthropic-api", }, }; const renderKeys = () => ( {__("API Keys", "yatra")} {__( "Keys are encrypted at rest with libsodium and never sent to the browser. Generation requests are made from your server directly to your chosen provider — no third-party proxy is involved.", "yatra", )} {/* Setup walkthrough — same pattern WhatsApp uses, so an operator who configured one module knows what to expect on the other. Explains the model: bring your own key, usage is billed by the provider, the plugin adds no markup. */}
{__("How AI Assistant billing works", "yatra")}

{__( "You bring your own OpenAI or Anthropic key. Each AI generation (trip description, FAQ, email body, etc.) is a single API call your provider charges you for — usually fractions of a cent. The plugin never proxies traffic and adds no markup. You only need one key to start; you can save both and pick a default per task under the Defaults tab.", "yatra", )}

{__( 'Click "Where to find this" next to a provider for the exact steps in their dashboard.', "yatra", )}

{meta.providers.map((p) => { const status = meta.keys[p.id] ?? { configured: false, hint: "" }; const draft = keyDrafts[p.id] ?? ""; const visible = Boolean(showKey[p.id]); const docs = providerDocs[p.id]; return (
{p.label}
{status.configured ? ( {__("Configured", "yatra")} ) : ( {__("Not configured", "yatra")} )}
{status.configured && (
{status.hint}
)}
{/* Per-provider "why + where" callout. Two columns: a short why-needed paragraph + a where-to-find recipe with a deep link to the provider's keys page. Only renders when we have docs metadata for the provider — the fallback is silent so a future custom provider doesn't show a broken docs block. */} {docs && (
{__("Why it's needed:", "yatra")} {" "} {docs.whyDescription}
{__("Where to find it:", "yatra")} {" "} {docs.whereToFind}
)}
setKeyDrafts((d) => ({ ...d, [p.id]: e.target.value, })) } />
{status.configured && ( <> )}
); })} {!moduleEnabled && (
{__( "AI Assistant module is not enabled yet. Once a key is saved, enable the module from the Modules page so sparkle affordances appear in the editors.", "yatra", )}{" "} {__("Go to Modules →", "yatra")}
)} {/* Public chat-widget toggle — separate from the module switch so operators can opt-in to the front-of-house AI without touching their back-office editor affordances. */}
{__("Public Chat Widget", "yatra")}
{__( 'Adds a "Chat with AI about this trip" button below the Send Enquiry button on every single-trip page. Visitors can ask trip-specific questions. Rate-limited per IP/session/trip/day so a hostile visitor can\'t burn your provider budget.', "yatra", )}
{!moduleEnabled && (
{__( "Enable the AI Assistant module first, then turn this on.", "yatra", )}
)} {/* Rate-limit configuration. Only meaningful when the chat widget itself is enabled — but we render the form anyway so operators can pre-tune limits before flipping the toggle. Each field is clamped to its server-side schema range on save so a runaway number can't accidentally uncap the AI bill. */} {meta.trip_chat_enabled && meta.trip_chat_limits_schema && (
{__("Chat rate limits", "yatra")}
{__( "Caps on the public chat widget to protect your AI provider budget. Higher numbers = more freedom for visitors and more potential cost. Each field is clamped to a safe range on save.", "yatra", )}
)}
); const renderVoice = () => ( {__("Brand voice", "yatra")} {__( "Define your voice once — it flows into every generation across trips, SEO, and emails. The more specific, the more your AI output sounds like you.", "yatra", )} {/* Intro callout — explains the WHY of brand voice. Every AI prompt the plugin sends to OpenAI / Anthropic gets the brand-voice block auto-prepended as the first lines of the system prompt, so trip descriptions, enquiry replies, email subjects, and chat responses all share a consistent tone. */}
{__("How brand voice is used", "yatra")}

{__( "Everything you fill in here is automatically prepended to every prompt the plugin sends to your AI provider. Trip descriptions, enquiry replies, email templates, the public chat widget, and the back-office wizards all start with these instructions — so your output sounds like one consistent brand, not a different voice per feature.", "yatra", )}

{__( "The single most important field. 2-3 sentences describing how you'd describe your brand to a new copywriter. Plain English — not marketing jargon. AI reads this every generation and aims for this tone.", "yatra", )}