| 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 |
|