| 1 |
import React, { useState } from "react"; |
| 2 |
import { Sparkles, Loader2, X, Check } from "lucide-react"; |
| 3 |
import { __ } from "../../lib/i18n"; |
| 4 |
import { aiApi } from "../../api/ai-api"; |
| 5 |
import { isAiReady } from "../../lib/ai-availability"; |
| 6 |
import { Modal } from "../ui/modal"; |
| 7 |
import { Button } from "../ui/button"; |
| 8 |
|
| 9 |
/** |
| 10 |
* AI generator for one email-template's subject + body. |
| 11 |
* |
| 12 |
* The caller (EmailTemplateForm) opens this with the template's |
| 13 |
* catalog metadata pre-filled — template_key, name, description, |
| 14 |
* recipient_type, merge_tags. The operator picks a tone, optionally |
| 15 |
* adds extra context, hits Generate. The backend agent emits one |
| 16 |
* `set_email(subject, body)` tool call so the response shape is |
| 17 |
* guaranteed structured (no regex parsing). |
| 18 |
* |
| 19 |
* On accept, the modal calls `onAccept({subject, body})` and the |
| 20 |
* form replaces its current values with the AI output. On cancel, |
| 21 |
* the existing subject/body stay untouched — the operator can try |
| 22 |
* again without losing what they had. |
| 23 |
*/ |
| 24 |
|
| 25 |
interface MergeTag { |
| 26 |
key: string; |
| 27 |
label?: string; |
| 28 |
} |
| 29 |
|
| 30 |
interface Props { |
| 31 |
open: boolean; |
| 32 |
onClose: () => void; |
| 33 |
templateKey: string; |
| 34 |
templateName?: string; |
| 35 |
templateDescription?: string; |
| 36 |
recipientType?: "customer" | "admin"; |
| 37 |
mergeTags: string[]; |
| 38 |
currentSubject: string; |
| 39 |
currentBody: string; |
| 40 |
onAccept: (result: { subject: string; body: string }) => void; |
| 41 |
} |
| 42 |
|
| 43 |
const TONE_PRESETS = [ |
| 44 |
{ id: "warm-professional", label: "Warm + professional" }, |
| 45 |
{ id: "friendly-casual", label: "Friendly + casual" }, |
| 46 |
{ id: "concise-formal", label: "Concise + formal" }, |
| 47 |
{ id: "excited-promotional", label: "Excited + promotional" }, |
| 48 |
]; |
| 49 |
|
| 50 |
export const AiEmailTemplateModal: React.FC<Props> = ({ |
| 51 |
open, |
| 52 |
onClose, |
| 53 |
templateKey, |
| 54 |
templateName, |
| 55 |
templateDescription, |
| 56 |
recipientType = "customer", |
| 57 |
mergeTags, |
| 58 |
currentSubject, |
| 59 |
currentBody, |
| 60 |
onAccept, |
| 61 |
}) => { |
| 62 |
const [tone, setTone] = useState<string>(TONE_PRESETS[0].id); |
| 63 |
const [extraContext, setExtraContext] = useState<string>(""); |
| 64 |
const [isGenerating, setIsGenerating] = useState<boolean>(false); |
| 65 |
const [error, setError] = useState<string | null>(null); |
| 66 |
const [draft, setDraft] = useState<{ subject: string; body: string } | null>( |
| 67 |
null, |
| 68 |
); |
| 69 |
|
| 70 |
// Reset when the modal opens/closes — operators should see a fresh |
| 71 |
// form each time they reopen the sparkle, not stale state. |
| 72 |
React.useEffect(() => { |
| 73 |
if (open) { |
| 74 |
setError(null); |
| 75 |
setDraft(null); |
| 76 |
setIsGenerating(false); |
| 77 |
} |
| 78 |
}, [open]); |
| 79 |
|
| 80 |
if (!open) return null; |
| 81 |
|
| 82 |
if (!isAiReady()) { |
| 83 |
return ( |
| 84 |
<Modal isOpen={open} onClose={onClose} size="md" hideHeader hideFooter> |
| 85 |
<div className="p-6"> |
| 86 |
<h3 className="font-semibold text-gray-900 dark:text-white"> |
| 87 |
{__("AI Assistant not configured", "yatra")} |
| 88 |
</h3> |
| 89 |
<p className="mt-1 text-sm text-gray-600 dark:text-gray-300"> |
| 90 |
{__( |
| 91 |
"Add an OpenAI or Anthropic key under Yatra → AI Assistant first.", |
| 92 |
"yatra", |
| 93 |
)} |
| 94 |
</p> |
| 95 |
<div className="mt-3 flex gap-2"> |
| 96 |
<Button asChild> |
| 97 |
<a href="admin.php?page=yatra&subpage=ai-assistant"> |
| 98 |
{__("Open AI settings", "yatra")} |
| 99 |
</a> |
| 100 |
</Button> |
| 101 |
<Button variant="outline" onClick={onClose}> |
| 102 |
{__("Cancel", "yatra")} |
| 103 |
</Button> |
| 104 |
</div> |
| 105 |
</div> |
| 106 |
</Modal> |
| 107 |
); |
| 108 |
} |
| 109 |
|
| 110 |
const generate = async () => { |
| 111 |
setIsGenerating(true); |
| 112 |
setError(null); |
| 113 |
try { |
| 114 |
const result = await aiApi.generateEmailTemplate({ |
| 115 |
template_key: templateKey, |
| 116 |
template_name: templateName, |
| 117 |
template_description: templateDescription, |
| 118 |
recipient_type: recipientType, |
| 119 |
merge_tags: mergeTags, |
| 120 |
current_subject: currentSubject, |
| 121 |
current_body: currentBody, |
| 122 |
tone: TONE_PRESETS.find((t) => t.id === tone)?.label ?? tone, |
| 123 |
extra_context: extraContext, |
| 124 |
}); |
| 125 |
setDraft({ subject: result.subject, body: result.body }); |
| 126 |
} catch (e: any) { |
| 127 |
const data = e?.response?.data ?? e?.data ?? null; |
| 128 |
const msg = |
| 129 |
data && typeof data === "object" && typeof data.message === "string" |
| 130 |
? data.message |
| 131 |
: e?.message || __("AI generation failed.", "yatra"); |
| 132 |
setError(msg); |
| 133 |
} finally { |
| 134 |
setIsGenerating(false); |
| 135 |
} |
| 136 |
}; |
| 137 |
|
| 138 |
const accept = () => { |
| 139 |
if (!draft) return; |
| 140 |
onAccept({ subject: draft.subject, body: draft.body }); |
| 141 |
onClose(); |
| 142 |
}; |
| 143 |
|
| 144 |
return ( |
| 145 |
<Modal isOpen={open} onClose={onClose} size="xl" hideHeader hideFooter> |
| 146 |
<div className="flex max-h-[90vh] min-h-[420px] min-w-[640px] max-w-[860px] flex-col"> |
| 147 |
{/* Header */} |
| 148 |
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4 dark:border-gray-700"> |
| 149 |
<div className="flex items-center gap-2"> |
| 150 |
<div className="rounded-md bg-gradient-to-br from-blue-600 to-blue-700 p-1.5 text-white"> |
| 151 |
<Sparkles className="h-4 w-4" /> |
| 152 |
</div> |
| 153 |
<div> |
| 154 |
<h2 className="text-base font-semibold text-gray-900 dark:text-white"> |
| 155 |
{__("Generate email with AI", "yatra")} |
| 156 |
</h2> |
| 157 |
<div className="text-xs text-gray-500 dark:text-gray-400"> |
| 158 |
{templateName || templateKey} |
| 159 |
</div> |
| 160 |
</div> |
| 161 |
</div> |
| 162 |
<button |
| 163 |
type="button" |
| 164 |
onClick={onClose} |
| 165 |
className="rounded text-gray-400 hover:text-gray-600" |
| 166 |
aria-label={__("Close", "yatra")} |
| 167 |
> |
| 168 |
<X className="h-5 w-5" /> |
| 169 |
</button> |
| 170 |
</div> |
| 171 |
|
| 172 |
{/* Body */} |
| 173 |
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4"> |
| 174 |
{!draft && ( |
| 175 |
<> |
| 176 |
<p className="text-sm text-gray-600 dark:text-gray-300"> |
| 177 |
{templateDescription || |
| 178 |
__( |
| 179 |
"AI will write a fresh subject and body using only the merge tags this template supports.", |
| 180 |
"yatra", |
| 181 |
)} |
| 182 |
</p> |
| 183 |
|
| 184 |
<div> |
| 185 |
<label className="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-200"> |
| 186 |
{__("Tone", "yatra")} |
| 187 |
</label> |
| 188 |
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4"> |
| 189 |
{TONE_PRESETS.map((t) => ( |
| 190 |
<button |
| 191 |
key={t.id} |
| 192 |
type="button" |
| 193 |
onClick={() => setTone(t.id)} |
| 194 |
className={`rounded-md border px-2.5 py-1.5 text-xs ${ |
| 195 |
tone === t.id |
| 196 |
? "border-blue-500 bg-blue-50 text-blue-700 dark:border-blue-400 dark:bg-blue-900/30 dark:text-blue-200" |
| 197 |
: "border-gray-200 bg-white text-gray-700 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200" |
| 198 |
}`} |
| 199 |
> |
| 200 |
{t.label} |
| 201 |
</button> |
| 202 |
))} |
| 203 |
</div> |
| 204 |
</div> |
| 205 |
|
| 206 |
<div> |
| 207 |
<label className="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-200"> |
| 208 |
{__("Anything else AI should know", "yatra")}{" "} |
| 209 |
<span className="text-[10px] font-normal text-gray-400"> |
| 210 |
{__("(optional)", "yatra")} |
| 211 |
</span> |
| 212 |
</label> |
| 213 |
<textarea |
| 214 |
rows={3} |
| 215 |
className="w-full rounded-md border border-gray-300 bg-white p-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 dark:border-gray-600 dark:bg-gray-800" |
| 216 |
placeholder={__( |
| 217 |
"e.g. Mention our 24/7 support hotline. Keep total length under 120 words. Emphasize peace of mind.", |
| 218 |
"yatra", |
| 219 |
)} |
| 220 |
value={extraContext} |
| 221 |
onChange={(e) => setExtraContext(e.target.value)} |
| 222 |
/> |
| 223 |
</div> |
| 224 |
|
| 225 |
{mergeTags.length > 0 && ( |
| 226 |
<div className="rounded-md border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800/40"> |
| 227 |
<div className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"> |
| 228 |
{__("Available merge tags", "yatra")} |
| 229 |
</div> |
| 230 |
<div className="flex flex-wrap gap-1.5"> |
| 231 |
{mergeTags.slice(0, 24).map((t) => ( |
| 232 |
<code |
| 233 |
key={t} |
| 234 |
className="rounded bg-white px-1.5 py-0.5 text-[10px] text-gray-700 dark:bg-gray-900 dark:text-gray-200" |
| 235 |
> |
| 236 |
{`{{${t}}}`} |
| 237 |
</code> |
| 238 |
))} |
| 239 |
{mergeTags.length > 24 && ( |
| 240 |
<span className="text-[10px] text-gray-500"> |
| 241 |
+{mergeTags.length - 24} {__("more", "yatra")} |
| 242 |
</span> |
| 243 |
)} |
| 244 |
</div> |
| 245 |
<p className="mt-1.5 text-[11px] text-gray-500 dark:text-gray-400"> |
| 246 |
{__( |
| 247 |
"AI will only use tags from this list; it won't invent new ones.", |
| 248 |
"yatra", |
| 249 |
)} |
| 250 |
</p> |
| 251 |
</div> |
| 252 |
)} |
| 253 |
</> |
| 254 |
)} |
| 255 |
|
| 256 |
{error && ( |
| 257 |
<div className="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-500/40 dark:bg-red-900/20 dark:text-red-300"> |
| 258 |
{error} |
| 259 |
</div> |
| 260 |
)} |
| 261 |
|
| 262 |
{draft && <DraftEditor draft={draft} onChange={(d) => setDraft(d)} />} |
| 263 |
</div> |
| 264 |
|
| 265 |
{/* Footer */} |
| 266 |
<div className="flex items-center justify-end gap-2 border-t border-gray-200 px-6 py-3 dark:border-gray-700"> |
| 267 |
{!draft && ( |
| 268 |
<> |
| 269 |
<Button |
| 270 |
variant="outline" |
| 271 |
onClick={onClose} |
| 272 |
disabled={isGenerating} |
| 273 |
> |
| 274 |
{__("Cancel", "yatra")} |
| 275 |
</Button> |
| 276 |
<Button onClick={generate} disabled={isGenerating}> |
| 277 |
{isGenerating ? ( |
| 278 |
<> |
| 279 |
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> |
| 280 |
{__("Generating…", "yatra")} |
| 281 |
</> |
| 282 |
) : ( |
| 283 |
<> |
| 284 |
<Sparkles className="mr-2 h-4 w-4" /> |
| 285 |
{__("Generate", "yatra")} |
| 286 |
</> |
| 287 |
)} |
| 288 |
</Button> |
| 289 |
</> |
| 290 |
)} |
| 291 |
{draft && ( |
| 292 |
<> |
| 293 |
<Button variant="outline" onClick={() => setDraft(null)}> |
| 294 |
{__("Discard, try again", "yatra")} |
| 295 |
</Button> |
| 296 |
<Button onClick={accept}> |
| 297 |
<Check className="mr-2 h-4 w-4" /> |
| 298 |
{__("Use this", "yatra")} |
| 299 |
</Button> |
| 300 |
</> |
| 301 |
)} |
| 302 |
</div> |
| 303 |
</div> |
| 304 |
</Modal> |
| 305 |
); |
| 306 |
}; |
| 307 |
|
| 308 |
/** |
| 309 |
* Subject + body editor with tabbed Preview / HTML views. The |
| 310 |
* Preview tab renders the agent's HTML in a sandboxed iframe so the |
| 311 |
* inline styles look like they will in a real email client; the |
| 312 |
* HTML tab is a plain textarea for power users who want to tweak |
| 313 |
* before accepting. |
| 314 |
* |
| 315 |
* iframe sandbox is important — operators paste the result into a |
| 316 |
* production email template, so we must NOT execute any script the |
| 317 |
* model might emit (scripts shouldn't be in the agent's output but |
| 318 |
* we treat that as defense-in-depth, not a guarantee). |
| 319 |
*/ |
| 320 |
const DraftEditor: React.FC<{ |
| 321 |
draft: { subject: string; body: string }; |
| 322 |
onChange: (d: { subject: string; body: string }) => void; |
| 323 |
}> = ({ draft, onChange }) => { |
| 324 |
const [tab, setTab] = useState<"preview" | "html">("preview"); |
| 325 |
return ( |
| 326 |
<div className="space-y-3"> |
| 327 |
<div> |
| 328 |
<label className="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-200"> |
| 329 |
{__("Subject", "yatra")} |
| 330 |
</label> |
| 331 |
<input |
| 332 |
type="text" |
| 333 |
className="w-full rounded-md border border-gray-300 bg-white p-2 font-mono text-sm dark:border-gray-600 dark:bg-gray-800" |
| 334 |
value={draft.subject} |
| 335 |
onChange={(e) => onChange({ ...draft, subject: e.target.value })} |
| 336 |
/> |
| 337 |
</div> |
| 338 |
<div> |
| 339 |
<div className="mb-1 flex items-center justify-between"> |
| 340 |
<label className="block text-xs font-medium text-gray-700 dark:text-gray-200"> |
| 341 |
{__("Body", "yatra")} |
| 342 |
</label> |
| 343 |
<div |
| 344 |
role="tablist" |
| 345 |
className="inline-flex overflow-hidden rounded-md border border-gray-200 dark:border-gray-700" |
| 346 |
> |
| 347 |
<button |
| 348 |
type="button" |
| 349 |
role="tab" |
| 350 |
aria-selected={tab === "preview"} |
| 351 |
onClick={() => setTab("preview")} |
| 352 |
className={`px-2.5 py-1 text-[11px] font-medium ${ |
| 353 |
tab === "preview" |
| 354 |
? "bg-blue-600 text-white" |
| 355 |
: "bg-white text-gray-700 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-200" |
| 356 |
}`} |
| 357 |
> |
| 358 |
{__("Preview", "yatra")} |
| 359 |
</button> |
| 360 |
<button |
| 361 |
type="button" |
| 362 |
role="tab" |
| 363 |
aria-selected={tab === "html"} |
| 364 |
onClick={() => setTab("html")} |
| 365 |
className={`px-2.5 py-1 text-[11px] font-medium ${ |
| 366 |
tab === "html" |
| 367 |
? "bg-blue-600 text-white" |
| 368 |
: "bg-white text-gray-700 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-200" |
| 369 |
}`} |
| 370 |
> |
| 371 |
{__("HTML", "yatra")} |
| 372 |
</button> |
| 373 |
</div> |
| 374 |
</div> |
| 375 |
|
| 376 |
{tab === "preview" ? ( |
| 377 |
<EmailPreview html={draft.body} /> |
| 378 |
) : ( |
| 379 |
<textarea |
| 380 |
rows={14} |
| 381 |
className="w-full rounded-md border border-gray-300 bg-white p-2 font-mono text-xs dark:border-gray-600 dark:bg-gray-800" |
| 382 |
value={draft.body} |
| 383 |
onChange={(e) => onChange({ ...draft, body: e.target.value })} |
| 384 |
/> |
| 385 |
)} |
| 386 |
<p className="mt-1 text-[11px] text-gray-500 dark:text-gray-400"> |
| 387 |
{__( |
| 388 |
"Preview shows the rendered email; HTML tab lets you edit. Merge tags like {{customer_name}} stay as-is — they're replaced when the email actually sends.", |
| 389 |
"yatra", |
| 390 |
)} |
| 391 |
</p> |
| 392 |
</div> |
| 393 |
</div> |
| 394 |
); |
| 395 |
}; |
| 396 |
|
| 397 |
/** |
| 398 |
* Sandboxed iframe that renders the agent's HTML body inside a |
| 399 |
* minimal shell — same neutral typography + light background that |
| 400 |
* Yatra's email shell will eventually wrap around. Sandboxed with |
| 401 |
* `allow-same-origin` only, so any <script> the model emits is |
| 402 |
* inert (scripts shouldn't exist; this is defense-in-depth). |
| 403 |
*/ |
| 404 |
const EmailPreview: React.FC<{ html: string }> = ({ html }) => { |
| 405 |
// Build a self-contained HTML doc. The agent's body is dropped in |
| 406 |
// raw — its inline styles are what we want to see. The wrapper |
| 407 |
// provides only the neutral background + the centered content |
| 408 |
// width that a real email client would render with. |
| 409 |
const doc = `<!DOCTYPE html> |
| 410 |
<html> |
| 411 |
<head> |
| 412 |
<meta charset="utf-8"> |
| 413 |
<meta name="viewport" content="width=device-width,initial-scale=1"> |
| 414 |
</head> |
| 415 |
<body style="margin:0;padding:24px 16px;background:#f3f4f6;font-family:Arial,Helvetica,sans-serif;"> |
| 416 |
<div style="max-width:600px;margin:0 auto;background:#ffffff;border:1px solid #e5e7eb;border-radius:10px;padding:28px 24px;"> |
| 417 |
${html} |
| 418 |
</div> |
| 419 |
</body> |
| 420 |
</html>`; |
| 421 |
|
| 422 |
return ( |
| 423 |
<iframe |
| 424 |
title="Email preview" |
| 425 |
sandbox="allow-same-origin" |
| 426 |
srcDoc={doc} |
| 427 |
className="w-full rounded-md border border-gray-200 bg-white dark:border-gray-700" |
| 428 |
style={{ height: "420px" }} |
| 429 |
/> |
| 430 |
); |
| 431 |
}; |
| 432 |
|
| 433 |
export default AiEmailTemplateModal; |
| 434 |
|