PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.16
Yatra – Travel Booking & Tour Operator Software v3.0.16
3.0.16 3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 All 84 releases
yatra / resources / js / pages / EmailAutomation.tsx

EmailAutomation.tsx in Yatra – Travel Booking & Tour Operator Software 3.0.16, at resources/js/pages/EmailAutomation.tsx

1,036 lines 39.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import React, { useState, useMemo, useCallback } from "react";
2 import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
3 import { __ } from "../lib/i18n";
4 import {
5 deleteEmailSequence,
6 fetchEmailLogs,
7 fetchEmailSequences,
8 updateEmailSequenceStatus,
9 fetchEmailTemplates,
10 } from "../api/email-automation-api";
11 import { useToast } from "../components/ui/toast";
12 import {
13 Card,
14 CardContent,
15 CardFooter,
16 CardHeader,
17 CardTitle,
18 } from "../components/ui/card";
19 import { Button } from "../components/ui/button";
20 import { Badge } from "../components/ui/badge";
21 import { PageHeader } from "../components/common/PageHeader";
22 import { ConfirmationDialog } from "../components/ui/confirmation-dialog";
23 import { Modal } from "../components/ui/modal";
24 import { Alert } from "../components/ui/alert";
25 import { Label } from "../components/ui/label";
26 import PremiumUpgradeCard from "./premium-pages/EmailAutomation";
27 import {
28 Mail,
29 Edit,
30 Clock,
31 FileText,
32 Bell,
33 Plus,
34 Trash2,
35 Play,
36 Pause,
37 Zap,
38 GitBranch,
39 Loader2,
40 Save,
41 Settings2,
42 Send,
43 } from "lucide-react";
44 import {
45 isProPluginActive,
46 isEmailAutomationModuleEnabled,
47 } from "../lib/plugin-utils";
48 import { useEmailSettingsManager } from "../hooks/useEmailSettingsManager";
49 import { EmailDeliverySection } from "../components/settings/EmailDeliverySection";
50 import { EmailTemplatesList } from "../components/email/EmailTemplatesList";
51 import { Pagination } from "../components/shared";
52
53 interface EmailLog {
54 id: number;
55 template_id?: number | null;
56 template_key: string;
57 sequence_id?: number | null;
58 recipient_email: string;
59 recipient_name: string;
60 subject: string;
61 // Full HTML body — only loaded when the detail modal asks for it
62 // (the list query also returns it, but it can be large; the modal
63 // is the only consumer of this field).
64 body?: string;
65 context_type?: string | null;
66 context_id?: number | null;
67 status: "sent" | "failed" | "opened" | "clicked";
68 error_message?: string | null;
69 // Server-side JSON-encoded — may arrive as a string OR a parsed object
70 // depending on the response shape.
71 metadata?: string | Record<string, unknown> | null;
72 sent_at: string;
73 }
74
75 interface EmailSequence {
76 id: number;
77 name: string;
78 description: string;
79 trigger_type: string;
80 trigger_config: any;
81 status: "active" | "paused" | "draft";
82 applicable_to: string;
83 trip_ids: number[];
84 steps_count?: number;
85 }
86
87 const EmailSequencesList: React.FC = () => {
88 const queryClient = useQueryClient();
89 const { showToast } = useToast();
90
91 const { data: sequencesData, isLoading } = useQuery({
92 queryKey: ["email-sequences"],
93 queryFn: () => fetchEmailSequences(),
94 enabled: isEmailAutomationModuleEnabled(),
95 });
96
97 // Shared dialog rather than the browser's native confirm().
98 const [sequenceToDelete, setSequenceToDelete] = useState<number | null>(null);
99
100 const deleteMutation = useMutation({
101 mutationFn: async (id: number) => {
102 return await deleteEmailSequence(id);
103 },
104 onSuccess: () => {
105 queryClient.invalidateQueries({ queryKey: ["email-sequences"] });
106 showToast(__("Sequence deleted"), "success");
107 },
108 });
109
110 const toggleStatusMutation = useMutation({
111 mutationFn: async ({ id, status }: { id: number; status: string }) => {
112 return await updateEmailSequenceStatus(id, status);
113 },
114 onSuccess: () => {
115 queryClient.invalidateQueries({ queryKey: ["email-sequences"] });
116 showToast(__("Sequence status updated"), "success");
117 },
118 });
119
120 const sequences = (
121 Array.isArray(sequencesData) ? sequencesData : []
122 ) as EmailSequence[];
123
124 const navigateToCreate = () => {
125 window.location.href =
126 "admin.php?page=yatra&subpage=email-automation&tab=sequence&action=create";
127 };
128
129 const navigateToEdit = (id: number) => {
130 window.location.href = `admin.php?page=yatra&subpage=email-automation&tab=sequence&action=edit&id=${id}`;
131 };
132
133 if (isLoading) {
134 return (
135 <div className="space-y-6 animate-pulse">
136 {/* Header Skeleton */}
137 <div className="flex items-center justify-between">
138 <div>
139 <div className="h-6 w-36 bg-gray-200 dark:bg-gray-700 rounded mb-2"></div>
140 <div className="h-4 w-64 bg-gray-200 dark:bg-gray-700 rounded"></div>
141 </div>
142 <div className="h-10 w-36 bg-gray-200 dark:bg-gray-700 rounded"></div>
143 </div>
144
145 {/* Sequences List Skeleton */}
146 <div className="space-y-4">
147 {[1, 2, 3].map((i) => (
148 <Card key={i}>
149 <CardContent className="p-4">
150 <div className="flex items-center justify-between">
151 <div className="flex-1">
152 <div className="flex items-center gap-3 mb-2">
153 <div className="h-5 w-48 bg-gray-200 dark:bg-gray-700 rounded"></div>
154 <div className="h-5 w-16 bg-gray-200 dark:bg-gray-700 rounded"></div>
155 </div>
156 <div className="h-4 w-72 bg-gray-200 dark:bg-gray-700 rounded mb-2"></div>
157 <div className="flex items-center gap-4">
158 <div className="h-3 w-32 bg-gray-200 dark:bg-gray-700 rounded"></div>
159 <div className="h-3 w-20 bg-gray-200 dark:bg-gray-700 rounded"></div>
160 </div>
161 </div>
162 <div className="flex items-center gap-2">
163 <div className="h-8 w-8 bg-gray-200 dark:bg-gray-700 rounded"></div>
164 <div className="h-8 w-16 bg-gray-200 dark:bg-gray-700 rounded"></div>
165 <div className="h-8 w-8 bg-gray-200 dark:bg-gray-700 rounded"></div>
166 </div>
167 </div>
168 </CardContent>
169 </Card>
170 ))}
171 </div>
172 </div>
173 );
174 }
175
176 const getStatusBadge = (status: string) => {
177 switch (status) {
178 case "active":
179 return (
180 <Badge className="bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">
181 {__("Active")}
182 </Badge>
183 );
184 case "paused":
185 return (
186 <Badge className="bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400">
187 {__("Paused")}
188 </Badge>
189 );
190 default:
191 return (
192 <Badge className="bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400">
193 {__("Draft")}
194 </Badge>
195 );
196 }
197 };
198
199 const emailEvents = (window as any).yatraAdmin?.emailEvents || [];
200
201 const getTriggerLabel = (triggerType: string) => {
202 const ev = emailEvents.find((e: { key?: string }) => e.key === triggerType);
203 return ev?.name || triggerType;
204 };
205
206 return (
207 <div className="space-y-6">
208 {/* Header */}
209 <div className="flex items-center justify-between">
210 <div>
211 <h2 className="text-lg font-semibold text-gray-900 dark:text-white">
212 {__("Email Sequences")}
213 </h2>
214 <p className="text-sm text-gray-500 dark:text-gray-400">
215 {__("Create automated email workflows triggered by events")}
216 </p>
217 </div>
218 <Button
219 onClick={navigateToCreate}
220 className="bg-blue-600 hover:bg-blue-700 text-white"
221 >
222 <Plus className="w-4 h-4 mr-2" />
223 {__("Create Sequence")}
224 </Button>
225 </div>
226
227 {/* Sequences List */}
228 {sequences.length === 0 ? (
229 <Card>
230 <CardContent className="p-8 text-center">
231 <GitBranch className="w-12 h-12 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
232 <h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
233 {__("No Sequences Yet")}
234 </h3>
235 <p className="text-gray-500 dark:text-gray-400 mb-4">
236 {__(
237 "Create your first automated email sequence to engage customers at the right time.",
238 )}
239 </p>
240 <Button
241 onClick={navigateToCreate}
242 className="bg-blue-600 hover:bg-blue-700 text-white"
243 >
244 <Plus className="w-4 h-4 mr-2" />
245 {__("Create Your First Sequence")}
246 </Button>
247 </CardContent>
248 </Card>
249 ) : (
250 <div className="space-y-4">
251 {sequences.map((sequence) => (
252 <Card key={sequence.id}>
253 <CardContent className="p-4">
254 <div className="flex items-center justify-between">
255 <div className="flex-1">
256 <div className="flex items-center gap-3">
257 <h3 className="font-medium text-gray-900 dark:text-white">
258 {sequence.name}
259 </h3>
260 {getStatusBadge(sequence.status)}
261 </div>
262 <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
263 {sequence.description || __("No description")}
264 </p>
265 <div className="flex items-center gap-4 mt-2 text-xs text-gray-500 dark:text-gray-400">
266 <span className="flex items-center gap-1">
267 <Zap className="w-3 h-3" />
268 {getTriggerLabel(sequence.trigger_type)}
269 </span>
270 <span className="flex items-center gap-1">
271 <GitBranch className="w-3 h-3" />
272 {sequence.steps_count || 0} {__("steps")}
273 </span>
274 </div>
275 </div>
276
277 <div className="flex items-center gap-2">
278 {sequence.status === "active" ? (
279 <Button
280 variant="ghost"
281 size="sm"
282 onClick={() =>
283 toggleStatusMutation.mutate({
284 id: sequence.id,
285 status: "paused",
286 })
287 }
288 >
289 <Pause className="w-4 h-4 text-yellow-500" />
290 </Button>
291 ) : (
292 <Button
293 variant="ghost"
294 size="sm"
295 onClick={() =>
296 toggleStatusMutation.mutate({
297 id: sequence.id,
298 status: "active",
299 })
300 }
301 >
302 <Play className="w-4 h-4 text-green-500" />
303 </Button>
304 )}
305 <Button
306 variant="outline"
307 size="sm"
308 onClick={() => navigateToEdit(sequence.id)}
309 >
310 <Edit className="w-4 h-4 mr-1" />
311 {__("Edit")}
312 </Button>
313 <Button
314 variant="ghost"
315 size="sm"
316 onClick={() => setSequenceToDelete(sequence.id)}
317 >
318 <Trash2 className="w-4 h-4 text-red-500" />
319 </Button>
320 </div>
321 </div>
322 </CardContent>
323 </Card>
324 ))}
325 </div>
326 )}
327
328 <ConfirmationDialog
329 isOpen={sequenceToDelete !== null}
330 onClose={() => setSequenceToDelete(null)}
331 onConfirm={() => {
332 if (sequenceToDelete !== null) {
333 deleteMutation.mutate(sequenceToDelete);
334 }
335 setSequenceToDelete(null);
336 }}
337 title={__("Delete Sequence", "yatra")}
338 message={__(
339 "Are you sure you want to delete this sequence? This action cannot be undone.",
340 "yatra",
341 )}
342 confirmText={__("Delete", "yatra")}
343 cancelText={__("Cancel", "yatra")}
344 variant="danger"
345 isLoading={deleteMutation.isPending}
346 />
347 </div>
348 );
349 };
350
351 const EmailLogsList: React.FC = () => {
352 const [page, setPage] = useState(1);
353 const perPage = 20;
354 // Modal state for the per-row "View details" action. Holds the
355 // selected log row so the modal can render the full payload without
356 // a second API roundtrip — the list query already returns body +
357 // metadata.
358 const [viewingLog, setViewingLog] = useState<EmailLog | null>(null);
359
360 const { data: logsData, isLoading } = useQuery({
361 queryKey: ["email-logs", page, perPage],
362 queryFn: () => fetchEmailLogs({ page, per_page: perPage }),
363 enabled: isEmailAutomationModuleEnabled(),
364 });
365 // Template names for the log rows: an override's key is machine-generated
366 // ("booking_confirmation__ov_…"), so show "Global name · Override name".
367 const { data: templatesForNames } = useQuery({
368 queryKey: ["email-templates"],
369 queryFn: () => fetchEmailTemplates(),
370 enabled: isEmailAutomationModuleEnabled(),
371 });
372 const templateLabel = useCallback(
373 (key: string | null | undefined): string => {
374 if (!key) return "—";
375 const list = Array.isArray(templatesForNames)
376 ? (templatesForNames as any[])
377 : [];
378 const row = list.find((t) => t.template_key === key);
379 if (!row) {
380 // A deleted override: derive its global template from the key
381 // ("booking_confirmation__ov_1a2b3c4d") so the log stays readable.
382 const m = key.match(/^(.+)__ov_[a-z0-9]+$/);
383 if (m) {
384 const parent = list.find((t) => t.template_key === m[1]);
385 return `${parent?.name || m[1]} · ${__("Override (deleted)", "yatra")}`;
386 }
387 return key;
388 }
389 if (row.overrides) {
390 const parent = list.find((t) => t.template_key === row.overrides);
391 return `${parent?.name || row.overrides} · ${__("Override", "yatra")}: ${row.name}`;
392 }
393 return row.name || key;
394 },
395 [templatesForNames],
396 );
397
398 const logs = (logsData?.items ?? []) as EmailLog[];
399 const totalItems = logsData?.total ?? 0;
400 const totalPages = Math.max(1, Math.ceil(totalItems / perPage));
401
402 if (isLoading) {
403 return (
404 <Card>
405 <CardHeader>
406 <div className="h-6 w-28 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
407 </CardHeader>
408 <CardContent className="p-0">
409 <div className="overflow-x-auto">
410 <table className="w-full">
411 <thead className="bg-gray-50 dark:bg-gray-800">
412 <tr>
413 {[1, 2, 3, 4, 5].map((i) => (
414 <th key={i} className="px-4 py-3">
415 <div className="h-4 w-20 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
416 </th>
417 ))}
418 </tr>
419 </thead>
420 <tbody className="divide-y divide-gray-200 dark:divide-gray-700">
421 {[1, 2, 3, 4, 5].map((row) => (
422 <tr key={row}>
423 {[1, 2, 3, 4, 5].map((col) => (
424 <td key={col} className="px-4 py-3">
425 <div className="h-4 w-24 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
426 </td>
427 ))}
428 </tr>
429 ))}
430 </tbody>
431 </table>
432 </div>
433 </CardContent>
434 </Card>
435 );
436 }
437
438 return (
439 <Card>
440 <CardHeader>
441 <CardTitle className="flex items-center gap-2">
442 <Clock className="w-5 h-5 text-blue-500" />
443 {__("Email Logs")}
444 </CardTitle>
445 </CardHeader>
446 <CardContent className="p-0">
447 {logs.length === 0 ? (
448 <div className="p-8 text-center text-gray-500 dark:text-gray-400">
449 {__("No emails sent yet")}
450 </div>
451 ) : (
452 <div className="overflow-x-auto">
453 <table className="w-full">
454 <thead className="bg-gray-50 dark:bg-gray-800">
455 <tr>
456 <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
457 {__("Recipient")}
458 </th>
459 <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
460 {__("Subject")}
461 </th>
462 <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
463 {__("Template")}
464 </th>
465 <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
466 {__("Status")}
467 </th>
468 <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
469 {__("Sent")}
470 </th>
471 <th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
472 {__("Actions")}
473 </th>
474 </tr>
475 </thead>
476 <tbody className="divide-y divide-gray-200 dark:divide-gray-700">
477 {logs.map((log) => (
478 <tr
479 key={log.id}
480 className="hover:bg-gray-50 dark:hover:bg-gray-800/50"
481 >
482 <td className="px-4 py-3">
483 <div className="text-sm font-medium text-gray-900 dark:text-white">
484 {log.recipient_name || "-"}
485 </div>
486 <div className="text-sm text-gray-500 dark:text-gray-400">
487 {log.recipient_email}
488 </div>
489 </td>
490 <td className="px-4 py-3 text-sm text-gray-900 dark:text-white max-w-xs truncate">
491 {log.subject}
492 </td>
493 <td
494 className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400"
495 title={log.template_key}
496 >
497 {templateLabel(log.template_key)}
498 </td>
499 <td className="px-4 py-3">
500 <Badge
501 className={
502 log.status === "sent"
503 ? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
504 : log.status === "failed"
505 ? "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400"
506 : "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
507 }
508 >
509 {log.status}
510 </Badge>
511 </td>
512 <td className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
513 {log.sent_at
514 ? new Date(log.sent_at).toLocaleString()
515 : "—"}
516 </td>
517 <td className="px-4 py-3 text-right">
518 <button
519 type="button"
520 onClick={() => setViewingLog(log)}
521 className="text-xs font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 hover:underline"
522 >
523 {__("View details", "yatra")}
524 </button>
525 </td>
526 </tr>
527 ))}
528 </tbody>
529 </table>
530 </div>
531 )}
532 {logs.length > 0 && totalPages > 1 && (
533 <div className="border-t border-gray-200 px-4 py-3 dark:border-gray-700">
534 <Pagination
535 currentPage={page}
536 totalPages={totalPages}
537 totalItems={totalItems}
538 itemsPerPage={perPage}
539 onPageChange={setPage}
540 itemName={__("entries", "yatra")}
541 />
542 </div>
543 )}
544 </CardContent>
545
546 {viewingLog && (
547 <EmailLogDetailsModal
548 log={viewingLog}
549 onClose={() => setViewingLog(null)}
550 />
551 )}
552 </Card>
553 );
554 };
555
556 /* -------------------------------------------------------------------------- */
557 /* EmailLogDetailsModal */
558 /* */
559 /* Read-only inspector for a single email log row. Shows recipient, status, */
560 /* template, full HTML body, error message (if failed), and metadata JSON. */
561 /* */
562 /* Body is rendered inside a sandboxed iframe (srcdoc + sandbox="") so: */
563 /* 1. Operator-visible markup looks like the real email */
564 /* 2. The iframe can't navigate the parent, run scripts, or read storage */
565 /* 3. Plugin CSS doesn't leak into the preview (and vice versa) */
566 /* -------------------------------------------------------------------------- */
567
568 const EmailLogDetailsModal: React.FC<{
569 log: EmailLog;
570 onClose: () => void;
571 }> = ({ log, onClose }) => {
572 // Body view toggle — "preview" renders the HTML in a sandboxed
573 // iframe; "raw" shows the source markup in a <pre>. Operators
574 // debugging template variables / merge tags want the raw view to
575 // verify substitutions; visual proofing wants the preview.
576 const [bodyView, setBodyView] = useState<"preview" | "raw">("preview");
577 // Tracks whether the raw HTML has been copied to clipboard so we
578 // can flash a "Copied!" label briefly.
579 const [copied, setCopied] = useState(false);
580 const copyRawToClipboard = async () => {
581 if (!log.body) return;
582 try {
583 await navigator.clipboard.writeText(log.body);
584 setCopied(true);
585 window.setTimeout(() => setCopied(false), 1500);
586 } catch {
587 /* clipboard denied — silently skip */
588 }
589 };
590
591 // Parse metadata defensively — server stores it as a JSON string in
592 // longtext, but some code paths may deliver it as a parsed object.
593 const metadata: Record<string, unknown> | null = useMemo(() => {
594 if (!log.metadata) return null;
595 if (typeof log.metadata === "object")
596 return log.metadata as Record<string, unknown>;
597 try {
598 const parsed = JSON.parse(String(log.metadata));
599 return typeof parsed === "object" && parsed !== null ? parsed : null;
600 } catch {
601 return null;
602 }
603 }, [log.metadata]);
604
605 const statusClass =
606 log.status === "sent"
607 ? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
608 : log.status === "failed"
609 ? "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400"
610 : "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400";
611
612 return (
613 <Modal
614 isOpen
615 onClose={onClose}
616 title={
617 <div className="flex items-center gap-2">
618 <Mail className="w-5 h-5 text-blue-500" />
619 {__("Email details", "yatra")}
620 </div>
621 }
622 size="full"
623 footer={
624 <div className="flex justify-end">
625 <Button variant="outline" onClick={onClose}>
626 {__("Close", "yatra")}
627 </Button>
628 </div>
629 }
630 >
631 <div className="space-y-4">
632 {/* Header strip — status + sent_at + recipient at a glance */}
633 <div className="rounded-md border border-gray-200 dark:border-gray-700 p-3 space-y-2">
634 <div className="flex flex-wrap items-center gap-2">
635 <Badge className={statusClass}>{log.status}</Badge>
636 <span className="text-xs text-gray-500 dark:text-gray-400">
637 {log.sent_at ? new Date(log.sent_at).toLocaleString() : "—"}
638 </span>
639 </div>
640 <dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-2 text-sm">
641 <DetailRow label={__("Recipient", "yatra")}>
642 <div className="font-medium text-gray-900 dark:text-white">
643 {log.recipient_name || "—"}
644 </div>
645 <div className="text-gray-500 dark:text-gray-400 break-all">
646 {log.recipient_email}
647 </div>
648 </DetailRow>
649 <DetailRow label={__("Subject", "yatra")}>
650 <span className="text-gray-900 dark:text-white break-words">
651 {log.subject || "—"}
652 </span>
653 </DetailRow>
654 <DetailRow label={__("Template", "yatra")}>
655 <code className="text-xs bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded">
656 {log.template_key || "—"}
657 </code>
658 {log.template_id ? (
659 <span className="text-xs text-gray-500 ml-1">
660 (id {log.template_id})
661 </span>
662 ) : null}
663 </DetailRow>
664 <DetailRow label={__("Sequence", "yatra")}>
665 {log.sequence_id ? (
666 <span className="text-gray-900 dark:text-white">
667 #{log.sequence_id}
668 </span>
669 ) : (
670 <span className="text-xs text-gray-400">
671 {__("Transactional (no sequence)", "yatra")}
672 </span>
673 )}
674 </DetailRow>
675 {(log.context_type || log.context_id) && (
676 <DetailRow label={__("Context", "yatra")}>
677 <span className="text-gray-900 dark:text-white">
678 {log.context_type || "—"}
679 {log.context_id ? ` #${log.context_id}` : ""}
680 </span>
681 </DetailRow>
682 )}
683 </dl>
684 </div>
685
686 {/* Error block — only shown on failed status */}
687 {log.status === "failed" && log.error_message && (
688 <Alert variant="error" title={__("Send failed", "yatra")}>
689 <pre className="text-xs whitespace-pre-wrap break-words font-mono">
690 {log.error_message}
691 </pre>
692 </Alert>
693 )}
694
695 {/* Body section. Two view modes: */}
696 {/* - Preview: sandboxed iframe so the email's own CSS / inline */}
697 {/* styles render accurately without leaking into admin chrome. */}
698 {/* `sandbox=""` (empty string) is the strictest mode — no */}
699 {/* scripts, no forms, no top-nav, no clickable links. */}
700 {/* - Raw: HTML source in a <pre> so operators can verify merge */}
701 {/* tags, debug template variable substitution, copy for tickets. */}
702 <div>
703 <div className="flex flex-wrap items-center justify-between gap-2 mb-1.5">
704 <Label className="text-sm font-medium">
705 {__("Message body", "yatra")}
706 </Label>
707 <div className="flex items-center gap-2">
708 {/* View-toggle pills. Single-purpose styling so the active */}
709 {/* mode is unmistakable. */}
710 <div
711 role="tablist"
712 aria-label={__("Body view", "yatra")}
713 className="inline-flex rounded-md border border-gray-200 dark:border-gray-700 overflow-hidden text-xs"
714 >
715 <button
716 type="button"
717 role="tab"
718 aria-selected={bodyView === "preview"}
719 onClick={() => setBodyView("preview")}
720 className={`px-3 py-1 font-medium transition-colors ${
721 bodyView === "preview"
722 ? "bg-blue-600 text-white"
723 : "bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
724 }`}
725 >
726 {__("Preview", "yatra")}
727 </button>
728 <button
729 type="button"
730 role="tab"
731 aria-selected={bodyView === "raw"}
732 onClick={() => setBodyView("raw")}
733 className={`px-3 py-1 font-medium border-l border-gray-200 dark:border-gray-700 transition-colors ${
734 bodyView === "raw"
735 ? "bg-blue-600 text-white"
736 : "bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
737 }`}
738 >
739 {__("Raw HTML", "yatra")}
740 </button>
741 </div>
742 {bodyView === "raw" && log.body && (
743 <button
744 type="button"
745 onClick={copyRawToClipboard}
746 className="text-xs font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 hover:underline"
747 >
748 {copied ? __("Copied!", "yatra") : __("Copy", "yatra")}
749 </button>
750 )}
751 {bodyView === "preview" && (
752 <span className="text-xs text-gray-400 hidden sm:inline">
753 {__("Sandboxed — links not clickable", "yatra")}
754 </span>
755 )}
756 </div>
757 </div>
758 {log.body && log.body.trim() !== "" ? (
759 bodyView === "preview" ? (
760 <iframe
761 title={__("Email body preview", "yatra")}
762 srcDoc={log.body}
763 sandbox=""
764 className="w-full h-[480px] rounded-md border border-gray-200 dark:border-gray-700 bg-white"
765 />
766 ) : (
767 <pre className="w-full h-[480px] text-xs bg-gray-50 dark:bg-gray-900/40 border border-gray-200 dark:border-gray-700 rounded-md p-3 overflow-auto whitespace-pre-wrap break-all font-mono text-gray-800 dark:text-gray-200">
768 {log.body}
769 </pre>
770 )
771 ) : (
772 <div className="text-sm text-gray-400 italic rounded-md border border-dashed border-gray-200 dark:border-gray-700 p-4 text-center">
773 {__("(Body not recorded for this log entry.)", "yatra")}
774 </div>
775 )}
776 </div>
777
778 {/* Metadata JSON — collapsible-style block. Useful for debugging */}
779 {/* operator-side: queue id, retry count, headers, attachment names. */}
780 {metadata && Object.keys(metadata).length > 0 && (
781 <div>
782 <Label className="text-sm font-medium block mb-1.5">
783 {__("Metadata", "yatra")}
784 </Label>
785 <pre className="text-xs bg-gray-50 dark:bg-gray-900/40 border border-gray-200 dark:border-gray-700 rounded-md p-3 overflow-x-auto max-h-48 overflow-y-auto font-mono">
786 {JSON.stringify(metadata, null, 2)}
787 </pre>
788 </div>
789 )}
790 </div>
791 </Modal>
792 );
793 };
794
795 const DetailRow: React.FC<{ label: string; children: React.ReactNode }> = ({
796 label,
797 children,
798 }) => (
799 <div>
800 <dt className="text-[11px] uppercase tracking-wide text-gray-500 dark:text-gray-400 font-medium">
801 {label}
802 </dt>
803 <dd className="mt-0.5">{children}</dd>
804 </div>
805 );
806
807 type EmailHubTab = "delivery" | "templates" | "sequences" | "logs";
808
809 function getInitialEmailHubTab(): EmailHubTab {
810 if (typeof window === "undefined") return "delivery";
811 const tab = new URLSearchParams(window.location.search).get("tab");
812 if (tab === "sequences" || tab === "sequence") return "sequences";
813 if (tab === "template" || tab === "templates") return "templates";
814 if (tab === "logs") return "logs";
815 if (tab === "delivery" || tab === "core") return "delivery";
816 return "delivery";
817 }
818
819 const EmailAutomationModulePrompt: React.FC = () => (
820 <div className="rounded-xl border border-amber-200 bg-amber-50 p-6 dark:border-amber-800 dark:bg-amber-950/30">
821 <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
822 <div className="flex gap-3">
823 <Settings2 className="mt-0.5 h-6 w-6 shrink-0 text-amber-600 dark:text-amber-400" />
824 <div>
825 <h3 className="text-base font-semibold text-amber-900 dark:text-amber-100">
826 {__("Enable the Email Automation module", "yatra")}
827 </h3>
828 <p className="mt-1 text-sm text-amber-800/90 dark:text-amber-200/90">
829 {__(
830 "Yatra Pro is active. Turn on Email Automation under Modules to use custom templates, sequences, and send logs.",
831 "yatra",
832 )}
833 </p>
834 </div>
835 </div>
836 <Button
837 type="button"
838 variant="outline"
839 className="shrink-0 border-amber-300 bg-white hover:bg-amber-100 dark:border-amber-700 dark:bg-amber-950 dark:hover:bg-amber-900"
840 onClick={() => {
841 window.location.href = "admin.php?page=yatra&subpage=modules";
842 }}
843 >
844 {__("Open Modules", "yatra")}
845 </Button>
846 </div>
847 </div>
848 );
849
850 const EmailAutomation: React.FC = () => {
851 const emailMgr = useEmailSettingsManager();
852 const [activeTab, setActiveTab] = useState<EmailHubTab>(() => {
853 const t = getInitialEmailHubTab();
854 if (!isProPluginActive() && (t === "sequences" || t === "logs")) {
855 return "delivery";
856 }
857 return t;
858 });
859
860 /**
861 * Persist tab changes to the URL so the operator can:
862 * - bookmark / share a direct link to a specific tab
863 * - use the browser back button to step through tab history
864 * - reload the page and stay on the same tab
865 *
866 * Using `replaceState` (not `pushState`) for the initial sync so we
867 * don't add a bogus history entry on first mount; subsequent user-
868 * driven tab switches go through `switchTab` which uses
869 * `replaceState` too — back/forward should navigate AWAY from this
870 * page entirely, not cycle through its tabs.
871 */
872 React.useEffect(() => {
873 if (typeof window === "undefined") return;
874 const url = new URL(window.location.href);
875 if (url.searchParams.get("tab") === activeTab) return;
876 url.searchParams.set("tab", activeTab);
877 window.history.replaceState({}, "", url.toString());
878 }, [activeTab]);
879
880 const switchTab = (next: EmailHubTab) => setActiveTab(next);
881
882 const showProAutomationExtras = isProPluginActive();
883 const automationReady = isEmailAutomationModuleEnabled();
884
885 const tabs = [
886 {
887 key: "delivery" as const,
888 label: __("Delivery", "yatra"),
889 icon: Send,
890 },
891 {
892 key: "templates" as const,
893 label: __("Templates", "yatra"),
894 icon: FileText,
895 },
896 ...(showProAutomationExtras
897 ? [
898 {
899 key: "sequences" as const,
900 label: __("Sequences", "yatra"),
901 icon: GitBranch,
902 },
903 {
904 key: "logs" as const,
905 label: __("Email logs", "yatra"),
906 icon: Mail,
907 },
908 ]
909 : []),
910 ];
911
912 return (
913 <div className="space-y-6">
914 <PageHeader
915 description={__(
916 "Delivery (addresses & SMTP), core templates for everyone, and optional Pro sequences and logs.",
917 "yatra",
918 )}
919 />
920
921 <Card>
922 <div className="border-b border-gray-200 dark:border-gray-700">
923 <nav className="flex flex-wrap gap-1 px-4">
924 {tabs.map((tab) => (
925 <button
926 key={tab.key}
927 type="button"
928 onClick={() => switchTab(tab.key)}
929 className={`flex items-center gap-2 px-4 py-3 border-b-2 font-medium text-sm transition-colors ${
930 activeTab === tab.key
931 ? "border-blue-500 text-blue-600 dark:text-blue-400"
932 : "border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600"
933 }`}
934 >
935 <tab.icon className="w-4 h-4" />
936 {tab.label}
937 </button>
938 ))}
939 </nav>
940 </div>
941
942 <CardContent className="p-6">
943 {activeTab === "delivery" && (
944 <>
945 {!emailMgr.ready ? (
946 <div className="flex justify-center py-16">
947 <Loader2 className="h-10 w-10 animate-spin text-gray-400" />
948 </div>
949 ) : (
950 <EmailDeliverySection
951 values={emailMgr.values}
952 onFieldChange={emailMgr.handleFieldChange}
953 />
954 )}
955 </>
956 )}
957
958 {activeTab === "templates" && (
959 <>
960 {automationReady && (
961 <p className="mb-6 text-sm text-gray-600 dark:text-gray-400">
962 {__(
963 "Customer booking, payment, cancellation, and trip-reminder emails use the matching template when its body is filled; otherwise the free plugin defaults or settings HTML apply. Admin: New Booking sends when that template has a body (plain-text admin notices from checkout and notifications are skipped to avoid duplicates). Other booking.created templates send in addition to the customer email. Booking Confirmed uses the “Booking Confirmed” row when a booking moves to confirmed; Trip Completed uses its template instead of the generic completed email when the body is filled. Override templates replace their global template for the trips they target and are never sent in addition to it.",
964 "yatra",
965 )}
966 </p>
967 )}
968 {!emailMgr.ready && !automationReady ? (
969 <div className="flex justify-center py-16">
970 <Loader2 className="h-10 w-10 animate-spin text-gray-400" />
971 </div>
972 ) : (
973 <EmailTemplatesList
974 automationModuleActive={automationReady}
975 settingsBridge={
976 automationReady
977 ? undefined
978 : {
979 values: emailMgr.values,
980 onFieldChange: emailMgr.handleFieldChange,
981 }
982 }
983 />
984 )}
985 </>
986 )}
987
988 {activeTab === "sequences" &&
989 (automationReady ? (
990 <EmailSequencesList />
991 ) : isProPluginActive() ? (
992 <EmailAutomationModulePrompt />
993 ) : (
994 <PremiumUpgradeCard />
995 ))}
996
997 {activeTab === "logs" &&
998 (automationReady ? (
999 <EmailLogsList />
1000 ) : isProPluginActive() ? (
1001 <EmailAutomationModulePrompt />
1002 ) : (
1003 <PremiumUpgradeCard />
1004 ))}
1005 </CardContent>
1006
1007 {(activeTab === "delivery" || activeTab === "templates") &&
1008 emailMgr.ready && (
1009 <CardFooter className="flex justify-end border-t border-gray-100 pt-4 dark:border-gray-700/50">
1010 <Button
1011 type="button"
1012 onClick={emailMgr.save}
1013 disabled={!emailMgr.canSave}
1014 className="flex items-center gap-2"
1015 >
1016 {emailMgr.isSaving ? (
1017 <>
1018 <Loader2 className="h-4 w-4 animate-spin" />
1019 {__("Saving…", "yatra")}
1020 </>
1021 ) : (
1022 <>
1023 <Save className="h-4 w-4" />
1024 {__("Save email settings", "yatra")}
1025 </>
1026 )}
1027 </Button>
1028 </CardFooter>
1029 )}
1030 </Card>
1031 </div>
1032 );
1033 };
1034
1035 export default EmailAutomation;
1036