PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.11
Yatra – Travel Booking & Tour Operator Software v3.0.11
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.11, at resources/js/pages/EmailAutomation.tsx

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