PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.10
Yatra – Travel Booking & Tour Operator Software v3.0.10
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 2.0.10 All 83 releases
yatra / resources / js / pages / EmailAutomation.tsx

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

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