PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.13
Yatra – Travel Booking & Tour Operator Software v3.0.13
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 / Whatsapp.tsx

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

2,881 lines 107.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import React, { useMemo, useState } from "react";
2 import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3 import {
4 MessageCircle,
5 KeyRound,
6 Send,
7 FileText,
8 Clock,
9 Settings2,
10 Loader2,
11 CheckCircle2,
12 XCircle,
13 ExternalLink,
14 ChevronDown,
15 Eye,
16 EyeOff,
17 Save,
18 ShieldCheck,
19 Code,
20 Copy,
21 Check,
22 Zap,
23 Info,
24 ArrowLeft,
25 Pencil,
26 Plus,
27 Trash2,
28 UserCircle,
29 Webhook,
30 Users,
31 History,
32 RotateCcw,
33 } from "lucide-react";
34 import { __, sprintf } from "../lib/i18n";
35 import { todayYmd } from "../lib/dateFormat";
36 import { PageHeader } from "../components/common/PageHeader";
37 import {
38 Card,
39 CardContent,
40 CardDescription,
41 CardFooter,
42 CardHeader,
43 CardTitle,
44 } from "../components/ui/card";
45 import { Button } from "../components/ui/button";
46 import { Input } from "../components/ui/input";
47 import {
48 ModulePageSkeleton,
49 ModuleFormSkeleton,
50 } from "../components/ui/module-skeleton";
51 import { Label } from "../components/ui/label";
52 import { Select } from "../components/ui/select";
53 import { Badge } from "../components/ui/badge";
54 import {
55 Table,
56 TableBody,
57 TableCell,
58 TableHead,
59 TableHeader,
60 TableRow,
61 } from "../components/ui/table";
62 import { Pagination } from "../components/shared/Pagination";
63 import { useToast } from "../components/ui/toast";
64 import { ConfirmationDialog } from "../components/ui/confirmation-dialog";
65 import { Modal } from "../components/ui/modal";
66 import { Alert } from "../components/ui/alert";
67 import {
68 whatsappApi,
69 type WhatsappMeta,
70 type WhatsappSettings,
71 type WhatsappSettingsResponse,
72 type WhatsappTemplate,
73 type WhatsappTemplateVersion,
74 type WhatsappEvent,
75 type WhatsappWidgetSettings,
76 } from "../api/whatsapp-api";
77
78 /**
79 * Yatra → WhatsApp Notifications hub.
80 *
81 * Mirrors EmailAutomation's tab strip + card pattern. Templates are
82 * now backed by the `yatra_whatsapp_templates` DB table:
83 *
84 * - System templates: editable body / meta_template_name / language /
85 * active; CANNOT change event_key, recipient_type, or be deleted
86 * - Custom templates: every field editable, including delete
87 */
88
89 type WhatsappTab = "delivery" | "templates" | "widget" | "logs" | "opt-ins";
90
91 function getInitialTab(): WhatsappTab {
92 if (typeof window === "undefined") return "delivery";
93 const tab = new URLSearchParams(window.location.search).get("tab");
94 if (tab === "templates" || tab === "template") return "templates";
95 if (tab === "widget") return "widget";
96 if (tab === "logs" || tab === "log") return "logs";
97 if (tab === "opt-ins" || tab === "optins") return "opt-ins";
98 return "delivery";
99 }
100
101 const Whatsapp: React.FC = () => {
102 const queryClient = useQueryClient();
103 const { showToast } = useToast();
104 const [activeTab, setActiveTab] = useState<WhatsappTab>(() =>
105 getInitialTab(),
106 );
107
108 const switchTab = (next: WhatsappTab) => {
109 setActiveTab(next);
110 if (typeof window !== "undefined") {
111 const url = new URL(window.location.href);
112 url.searchParams.set("tab", next);
113 window.history.replaceState({}, "", url.toString());
114 }
115 };
116
117 const { data: meta, isLoading: metaLoading } = useQuery<WhatsappMeta>({
118 queryKey: ["whatsapp-meta"],
119 queryFn: () => whatsappApi.getMeta(),
120 });
121
122 const { data: cfg } = useQuery<WhatsappSettingsResponse>({
123 queryKey: ["whatsapp-settings"],
124 queryFn: () => whatsappApi.getSettings(),
125 enabled: Boolean(meta?.is_eligible && meta?.is_module_enabled),
126 });
127
128 const saveSettings = useMutation({
129 mutationFn: (patch: Partial<WhatsappSettings>) =>
130 whatsappApi.updateSettings(patch),
131 onSuccess: () => {
132 queryClient.invalidateQueries({ queryKey: ["whatsapp-settings"] });
133 queryClient.invalidateQueries({ queryKey: ["whatsapp-meta"] });
134 showToast(__("WhatsApp settings saved.", "yatra"), "success");
135 },
136 onError: (e: any) => showToast(extractError(e), "error"),
137 });
138
139 const saveCredential = useMutation({
140 mutationFn: (vars: { field: string; value: string }) =>
141 whatsappApi.updateCredential("cloud_api", vars.field, vars.value),
142 onSuccess: () => {
143 queryClient.invalidateQueries({ queryKey: ["whatsapp-settings"] });
144 queryClient.invalidateQueries({ queryKey: ["whatsapp-meta"] });
145 showToast(__("Credential saved.", "yatra"), "success");
146 },
147 onError: (e: any) => showToast(extractError(e), "error"),
148 });
149
150 if (metaLoading) {
151 return <ModulePageSkeleton variant="tabs" />;
152 }
153
154 if (!meta || !meta.is_eligible) {
155 return (
156 <div className="space-y-6">
157 <PageHeader
158 description={__(
159 "Send booking + payment + reminder messages over WhatsApp.",
160 "yatra",
161 )}
162 />
163 <UpgradeCard meta={meta} />
164 </div>
165 );
166 }
167
168 const automationReady = Boolean(meta.is_module_enabled);
169
170 const tabs: Array<{ key: WhatsappTab; label: string; icon: any }> = [
171 { key: "delivery", label: __("Delivery", "yatra"), icon: Send },
172 { key: "templates", label: __("Templates", "yatra"), icon: FileText },
173 {
174 key: "widget",
175 label: __("Frontend widget", "yatra"),
176 icon: MessageCircle,
177 },
178 { key: "logs", label: __("Message logs", "yatra"), icon: MessageCircle },
179 { key: "opt-ins", label: __("Opt-ins", "yatra"), icon: Users },
180 ];
181
182 return (
183 <div className="space-y-6">
184 <PageHeader
185 description={__(
186 "WhatsApp credentials, system + custom templates, and message logs — bring your own Cloud API account.",
187 "yatra",
188 )}
189 />
190
191 <Card>
192 <div className="border-b border-gray-200 dark:border-gray-700">
193 <nav className="flex flex-wrap gap-1 px-4">
194 {tabs.map((tab) => {
195 const Icon = tab.icon;
196 const active = activeTab === tab.key;
197 return (
198 <button
199 key={tab.key}
200 type="button"
201 onClick={() => switchTab(tab.key)}
202 className={`flex items-center gap-2 px-4 py-3 border-b-2 font-medium text-sm transition-colors ${
203 active
204 ? "border-blue-500 text-blue-600 dark:text-blue-400"
205 : "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"
206 }`}
207 >
208 <Icon className="w-4 h-4" />
209 {tab.label}
210 </button>
211 );
212 })}
213 </nav>
214 </div>
215
216 <CardContent className="p-6">
217 {activeTab === "delivery" && (
218 <>
219 {!automationReady ? (
220 <WhatsappModulePrompt />
221 ) : !cfg ? (
222 <ModuleFormSkeleton rows={5} />
223 ) : (
224 <DeliverySection
225 cfg={cfg}
226 meta={meta}
227 onSaveCredential={(field, value) =>
228 saveCredential.mutate({ field, value })
229 }
230 onSaveSettings={(patch) => saveSettings.mutate(patch)}
231 saving={saveSettings.isPending || saveCredential.isPending}
232 />
233 )}
234 </>
235 )}
236
237 {activeTab === "templates" && (
238 <>
239 {!automationReady ? <WhatsappModulePrompt /> : <TemplatesTab />}
240 </>
241 )}
242
243 {activeTab === "widget" && (
244 <>
245 {!automationReady ? (
246 <WhatsappModulePrompt />
247 ) : !cfg ? (
248 <ModuleFormSkeleton rows={5} />
249 ) : (
250 <WidgetSection
251 settings={cfg.settings}
252 onSave={(patch) => saveSettings.mutate(patch)}
253 saving={saveSettings.isPending}
254 />
255 )}
256 </>
257 )}
258
259 {activeTab === "logs" &&
260 (automationReady ? <LogsList /> : <WhatsappModulePrompt />)}
261
262 {activeTab === "opt-ins" &&
263 (automationReady ? <OptInsList /> : <WhatsappModulePrompt />)}
264 </CardContent>
265 </Card>
266 </div>
267 );
268 };
269
270 /* -------------------------------------------------------------------------- */
271 /* Upgrade card / module prompt — same patterns as Email Automation */
272 /* -------------------------------------------------------------------------- */
273
274 const UpgradeCard: React.FC<{ meta?: WhatsappMeta }> = ({ meta }) => {
275 const upgradeUrl =
276 meta?.upgrade_url || "https://wpyatra.com/pricing?module=whatsapp";
277 return (
278 <Card className="max-w-3xl">
279 <CardHeader>
280 <div className="flex items-start gap-3">
281 <div className="rounded-md bg-green-100 p-2 text-green-600 dark:bg-green-900/30 dark:text-green-400">
282 <MessageCircle className="h-5 w-5" />
283 </div>
284 <div className="flex-1">
285 <CardTitle>{__("WhatsApp Notifications", "yatra")}</CardTitle>
286 <CardDescription className="mt-1">
287 {__(
288 "Available on the Growth plan (or Scale). Send booking + payment + reminder messages — bring your own Meta Cloud API credentials.",
289 "yatra",
290 )}
291 </CardDescription>
292 </div>
293 </div>
294 </CardHeader>
295 <CardContent>
296 <Button asChild>
297 <a href={upgradeUrl} target="_blank" rel="noopener noreferrer">
298 {__("Upgrade to Growth", "yatra")}
299 <ExternalLink className="ml-2 h-4 w-4" />
300 </a>
301 </Button>
302 </CardContent>
303 </Card>
304 );
305 };
306
307 const WhatsappModulePrompt: React.FC = () => (
308 <div className="rounded-xl border border-amber-200 bg-amber-50 p-6 dark:border-amber-800 dark:bg-amber-950/30">
309 <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
310 <div className="flex gap-3">
311 <Settings2 className="mt-0.5 h-6 w-6 shrink-0 text-amber-600 dark:text-amber-400" />
312 <div>
313 <h3 className="text-base font-semibold text-amber-900 dark:text-amber-100">
314 {__("Enable the WhatsApp Notifications module", "yatra")}
315 </h3>
316 <p className="mt-1 text-sm text-amber-800/90 dark:text-amber-200/90">
317 {__(
318 "Your license tier qualifies. Turn on WhatsApp Notifications under Modules to start configuring it.",
319 "yatra",
320 )}
321 </p>
322 </div>
323 </div>
324 <Button
325 type="button"
326 variant="outline"
327 asChild
328 className="border-amber-300 text-amber-800 hover:bg-amber-100 dark:border-amber-700 dark:text-amber-200 dark:hover:bg-amber-900/40"
329 >
330 <a href="admin.php?page=yatra&subpage=modules">
331 {__("Open Modules", "yatra")}
332 </a>
333 </Button>
334 </div>
335 </div>
336 );
337
338 /* -------------------------------------------------------------------------- */
339 /* Delivery section */
340 /* -------------------------------------------------------------------------- */
341
342 const DeliverySection: React.FC<{
343 cfg: WhatsappSettingsResponse;
344 meta: WhatsappMeta;
345 onSaveCredential: (field: string, value: string) => void;
346 onSaveSettings: (patch: Partial<WhatsappSettings>) => void;
347 saving: boolean;
348 }> = ({ cfg, meta, onSaveCredential, onSaveSettings, saving }) => {
349 const settings = cfg.settings;
350 const credStatus = cfg.credentials?.cloud_api ?? {};
351
352 const [tokenInput, setTokenInput] = useState("");
353 const [webhookInput, setWebhookInput] = useState("");
354 const [showToken, setShowToken] = useState(false);
355 const [showWebhook, setShowWebhook] = useState(false);
356
357 const [phoneNumberId, setPhoneNumberId] = useState(settings.phone_number_id);
358 const [businessAccountId, setBusinessAccountId] = useState(
359 settings.business_account_id,
360 );
361 const [defaultCountryCode, setDefaultCountryCode] = useState(
362 settings.default_country_code,
363 );
364 const [senderName, setSenderName] = useState(settings.sender_display_name);
365 const [adminPhone, setAdminPhone] = useState(settings.admin_phone);
366 const [optInRequired, setOptInRequired] = useState(settings.opt_in_required);
367 const [optInCopy, setOptInCopy] = useState(settings.opt_in_copy);
368 const [reminderBefore, setReminderBefore] = useState<number>(
369 settings.reminder_hours_before,
370 );
371 const [reviewAfter, setReviewAfter] = useState<number>(
372 settings.review_hours_after,
373 );
374
375 React.useEffect(() => {
376 setPhoneNumberId(settings.phone_number_id);
377 setBusinessAccountId(settings.business_account_id);
378 setDefaultCountryCode(settings.default_country_code);
379 setSenderName(settings.sender_display_name);
380 setAdminPhone(settings.admin_phone);
381 setOptInRequired(settings.opt_in_required);
382 setOptInCopy(settings.opt_in_copy);
383 setReminderBefore(settings.reminder_hours_before);
384 setReviewAfter(settings.review_hours_after);
385 }, [
386 settings.phone_number_id,
387 settings.business_account_id,
388 settings.default_country_code,
389 settings.sender_display_name,
390 settings.admin_phone,
391 settings.opt_in_required,
392 settings.opt_in_copy,
393 settings.reminder_hours_before,
394 settings.review_hours_after,
395 ]);
396
397 const dirty =
398 phoneNumberId !== settings.phone_number_id ||
399 businessAccountId !== settings.business_account_id ||
400 defaultCountryCode !== settings.default_country_code ||
401 senderName !== settings.sender_display_name ||
402 adminPhone !== settings.admin_phone ||
403 optInRequired !== settings.opt_in_required ||
404 optInCopy !== settings.opt_in_copy ||
405 reminderBefore !== settings.reminder_hours_before ||
406 reviewAfter !== settings.review_hours_after;
407
408 const saveAll = () =>
409 onSaveSettings({
410 phone_number_id: phoneNumberId,
411 business_account_id: businessAccountId,
412 sender_display_name: senderName,
413 default_country_code: defaultCountryCode,
414 admin_phone: adminPhone,
415 opt_in_required: optInRequired,
416 opt_in_copy: optInCopy,
417 reminder_hours_before: reminderBefore,
418 review_hours_after: reviewAfter,
419 });
420
421 return (
422 <div className="space-y-6">
423 {/* Setup walkthrough */}
424 <Card className="border-blue-200 bg-blue-50/40 dark:border-blue-800 dark:bg-blue-950/20">
425 <CardHeader>
426 <CardTitle className="flex items-center gap-2">
427 <Info className="h-5 w-5 text-blue-600 dark:text-blue-400" />
428 {__(
429 "Before you start — getting WhatsApp Cloud API access",
430 "yatra",
431 )}
432 </CardTitle>
433 <CardDescription className="mt-2">
434 {__(
435 "All values below come from Meta's WhatsApp Business Platform. Bring your own credentials — messages are billed directly by Meta, no markup from the plugin.",
436 "yatra",
437 )}
438 </CardDescription>
439 </CardHeader>
440 <CardContent>
441 <div className="flex flex-wrap gap-2">
442 <Button variant="outline" size="sm" asChild>
443 <a
444 href="https://developers.facebook.com/docs/whatsapp/cloud-api/get-started"
445 target="_blank"
446 rel="noopener noreferrer"
447 >
448 <ExternalLink className="mr-1.5 h-3.5 w-3.5" />
449 {__("Cloud API: Get started", "yatra")}
450 </a>
451 </Button>
452 <Button variant="outline" size="sm" asChild>
453 <a
454 href="https://business.facebook.com/wa/manage/home/"
455 target="_blank"
456 rel="noopener noreferrer"
457 >
458 <ExternalLink className="mr-1.5 h-3.5 w-3.5" />
459 {__("Open WhatsApp Manager", "yatra")}
460 </a>
461 </Button>
462 </div>
463 </CardContent>
464 </Card>
465
466 {/* Credentials */}
467 <Card>
468 <CardHeader>
469 <CardTitle className="flex items-center gap-2">
470 <KeyRound className="h-5 w-5 text-blue-500" />
471 {__("WhatsApp Cloud API credentials", "yatra")}
472 </CardTitle>
473 <CardDescription>
474 {__(
475 "Encrypted credentials Meta requires for the plugin to send messages on your behalf. Stored with libsodium AEAD; the browser only ever sees a masked last-4 hint.",
476 "yatra",
477 )}
478 </CardDescription>
479 </CardHeader>
480 <CardContent className="space-y-6">
481 <CredentialField
482 label={__("Permanent access token", "yatra")}
483 description={__(
484 "Required for every API call to Meta. Find it under Meta for Developers → your app → WhatsApp → API Setup, or generate a permanent token under Business Settings → System Users.",
485 "yatra",
486 )}
487 docsUrl="https://developers.facebook.com/docs/whatsapp/business-management-api/get-started#access-tokens"
488 placeholder={
489 credStatus.access_token?.configured
490 ? credStatus.access_token.hint
491 : "EAAB..."
492 }
493 configured={!!credStatus.access_token?.configured}
494 visible={showToken}
495 value={tokenInput}
496 onToggle={() => setShowToken((v) => !v)}
497 onChange={setTokenInput}
498 onSave={() => {
499 onSaveCredential("access_token", tokenInput);
500 setTokenInput("");
501 }}
502 onClear={() => onSaveCredential("access_token", "")}
503 saving={saving}
504 />
505 <CredentialField
506 label={__("Webhook verify secret (App Secret)", "yatra")}
507 description={__(
508 "Required only when receiving inbound replies. Find it under your app → Settings → Basic → App Secret.",
509 "yatra",
510 )}
511 docsUrl="https://developers.facebook.com/docs/whatsapp/cloud-api/guides/set-up-webhooks"
512 optional
513 placeholder={
514 credStatus.webhook_secret?.configured
515 ? credStatus.webhook_secret.hint
516 : __("Optional — only needed when receiving messages.", "yatra")
517 }
518 configured={!!credStatus.webhook_secret?.configured}
519 visible={showWebhook}
520 value={webhookInput}
521 onToggle={() => setShowWebhook((v) => !v)}
522 onChange={setWebhookInput}
523 onSave={() => {
524 onSaveCredential("webhook_secret", webhookInput);
525 setWebhookInput("");
526 }}
527 onClear={() => onSaveCredential("webhook_secret", "")}
528 saving={saving}
529 />
530 </CardContent>
531 </Card>
532
533 <WebhookSetupCard meta={meta} />
534
535 {/* Identifiers + admin phone */}
536 <Card>
537 <CardHeader>
538 <CardTitle>{__("Business identifiers", "yatra")}</CardTitle>
539 <CardDescription>
540 {__(
541 "Numeric IDs from Meta + the admin phone that receives operator-recipient templates.",
542 "yatra",
543 )}
544 </CardDescription>
545 </CardHeader>
546 <CardContent className="space-y-4">
547 <div className="grid gap-4 md:grid-cols-2">
548 <div className="space-y-2">
549 <Label>{__("Phone number ID", "yatra")}</Label>
550 <Input
551 value={phoneNumberId}
552 placeholder="123456789012345"
553 onChange={(e) => setPhoneNumberId(e.target.value)}
554 />
555 </div>
556 <div className="space-y-2">
557 <Label>{__("WhatsApp Business Account ID", "yatra")}</Label>
558 <Input
559 value={businessAccountId}
560 placeholder="987654321098765"
561 onChange={(e) => setBusinessAccountId(e.target.value)}
562 />
563 </div>
564 <div className="space-y-2">
565 <Label>{__("Sender display name", "yatra")}</Label>
566 <Input
567 value={senderName}
568 placeholder={__("Your tour brand", "yatra")}
569 onChange={(e) => setSenderName(e.target.value)}
570 />
571 </div>
572 <div className="space-y-2">
573 <Label>{__("Default country code", "yatra")}</Label>
574 <Input
575 value={defaultCountryCode}
576 placeholder="+977"
577 onChange={(e) => setDefaultCountryCode(e.target.value)}
578 />
579 </div>
580 </div>
581
582 {/* Admin phone — destination for recipient_type=admin templates */}
583 <div className="space-y-2 rounded-md border border-orange-200 bg-orange-50/40 p-3 dark:border-orange-700 dark:bg-orange-900/20">
584 <div className="flex items-center gap-1.5">
585 <UserCircle className="h-4 w-4 text-orange-600 dark:text-orange-400" />
586 <Label className="font-semibold text-orange-900 dark:text-orange-200">
587 {__("Admin phone (for operator notifications)", "yatra")}
588 </Label>
589 </div>
590 <p className="text-xs text-orange-900/80 dark:text-orange-200/80">
591 {__(
592 'Destination for any template with recipient type "Admin" — for example a "New booking" alert sent to the operator. Leave blank to skip those sends. Must be in E.164 format.',
593 "yatra",
594 )}
595 </p>
596 <Input
597 value={adminPhone}
598 placeholder="+9779800000000"
599 onChange={(e) => setAdminPhone(e.target.value)}
600 />
601 </div>
602 </CardContent>
603 </Card>
604
605 {/* Compliance */}
606 <Card>
607 <CardHeader>
608 <CardTitle className="flex items-center gap-2">
609 <ShieldCheck className="h-5 w-5 text-green-500" />
610 {__("Compliance & opt-in", "yatra")}
611 </CardTitle>
612 </CardHeader>
613 <CardContent className="space-y-4">
614 <label className="flex items-start gap-3 cursor-pointer">
615 <input
616 type="checkbox"
617 checked={optInRequired}
618 onChange={(e) => setOptInRequired(e.target.checked)}
619 className="mt-1"
620 />
621 <div>
622 <div className="text-sm font-medium text-gray-900 dark:text-white">
623 {__("Require opt-in before sending", "yatra")}
624 </div>
625 <div className="text-xs text-gray-500 dark:text-gray-400">
626 {__(
627 "Refuses sends to phone numbers that haven't explicitly opted in. Admin-recipient templates are exempt (the operator owns that number).",
628 "yatra",
629 )}
630 </div>
631 </div>
632 </label>
633 <div className="space-y-2">
634 <Label>{__("Opt-in checkbox copy", "yatra")}</Label>
635 <textarea
636 className="w-full rounded-md border border-gray-300 bg-white p-2 text-sm dark:border-gray-600 dark:bg-gray-800"
637 rows={3}
638 value={optInCopy}
639 onChange={(e) => setOptInCopy(e.target.value)}
640 placeholder={__(
641 "Send me booking updates and trip reminders on WhatsApp. Reply STOP to opt out anytime.",
642 "yatra",
643 )}
644 />
645 </div>
646 </CardContent>
647 </Card>
648
649 {/* Reminder schedule */}
650 <Card>
651 <CardHeader>
652 <CardTitle className="flex items-center gap-2">
653 <Clock className="h-5 w-5 text-blue-500" />
654 {__("Reminder schedule", "yatra")}
655 </CardTitle>
656 <CardDescription>
657 {__(
658 "How far before / after a trip the time-based templates fire. Clamped 1–168 hours.",
659 "yatra",
660 )}
661 </CardDescription>
662 </CardHeader>
663 <CardContent>
664 <div className="grid gap-4 md:grid-cols-2">
665 <div className="space-y-2">
666 <Label>{__("Departure reminder (hours before)", "yatra")}</Label>
667 <Input
668 type="number"
669 min={1}
670 max={168}
671 value={reminderBefore}
672 onChange={(e) => setReminderBefore(Number(e.target.value) || 0)}
673 />
674 </div>
675 <div className="space-y-2">
676 <Label>{__("Review request (hours after)", "yatra")}</Label>
677 <Input
678 type="number"
679 min={1}
680 max={168}
681 value={reviewAfter}
682 onChange={(e) => setReviewAfter(Number(e.target.value) || 0)}
683 />
684 </div>
685 </div>
686 </CardContent>
687 </Card>
688
689 {dirty && (
690 <CardFooter className="flex justify-end border-t border-gray-100 pt-4 dark:border-gray-700/50">
691 <Button onClick={saveAll} disabled={saving}>
692 {saving ? (
693 <>
694 <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
695 {__("Saving…", "yatra")}
696 </>
697 ) : (
698 <>
699 <Save className="mr-1.5 h-4 w-4" />
700 {__("Save WhatsApp settings", "yatra")}
701 </>
702 )}
703 </Button>
704 </CardFooter>
705 )}
706 </div>
707 );
708 };
709
710 /**
711 * Renders the read-only webhook configuration block — the operator
712 * copies these values into Meta's WhatsApp → Webhooks subscription:
713 * • Callback URL → this plugin's REST endpoint
714 * • Verify token → auto-generated on first settings save
715 * • App Secret status → visibility flag (the secret itself is
716 * encrypted at rest and never sent here)
717 */
718 const WebhookSetupCard: React.FC<{ meta: WhatsappMeta }> = ({ meta }) => {
719 const { showToast } = useToast();
720 const webhook = meta.webhook;
721 const [justCopied, setJustCopied] = useState<"url" | "token" | null>(null);
722
723 const copy = async (text: string, label: "url" | "token") => {
724 try {
725 if (typeof navigator !== "undefined" && navigator.clipboard) {
726 await navigator.clipboard.writeText(text);
727 } else {
728 // Fallback for non-secure contexts
729 const ta = document.createElement("textarea");
730 ta.value = text;
731 ta.style.position = "fixed";
732 ta.style.opacity = "0";
733 document.body.appendChild(ta);
734 ta.select();
735 document.execCommand("copy");
736 document.body.removeChild(ta);
737 }
738 setJustCopied(label);
739 window.setTimeout(() => setJustCopied(null), 2000);
740 } catch (_e) {
741 showToast(__("Failed to copy to clipboard.", "yatra"), "error");
742 }
743 };
744
745 const tokenMissing = !webhook?.verify_token;
746
747 return (
748 <Card>
749 <CardHeader>
750 <CardTitle className="flex items-center gap-2">
751 <Webhook className="h-4 w-4 text-blue-600 dark:text-blue-400" />
752 {__("Meta webhook setup", "yatra")}
753 </CardTitle>
754 <CardDescription>
755 {__(
756 "Paste these values into your Meta app → WhatsApp → Configuration → Webhooks. Once verified, Meta will deliver message status callbacks (sent / delivered / read / failed) and inbound replies here.",
757 "yatra",
758 )}{" "}
759 <a
760 href="https://developers.facebook.com/docs/whatsapp/cloud-api/guides/set-up-webhooks"
761 target="_blank"
762 rel="noopener noreferrer"
763 className="text-blue-600 hover:underline dark:text-blue-400"
764 >
765 {__("Setup guide", "yatra")}{" "}
766 <ExternalLink className="inline h-3 w-3" />
767 </a>
768 </CardDescription>
769 </CardHeader>
770 <CardContent className="space-y-4">
771 {/* Callback URL */}
772 <div>
773 <Label
774 htmlFor="whatsapp-webhook-url"
775 className="text-xs text-gray-500 dark:text-gray-400"
776 >
777 {__("Callback URL", "yatra")}
778 </Label>
779 <div className="mt-1 flex gap-2">
780 <Input
781 id="whatsapp-webhook-url"
782 readOnly
783 value={webhook?.url || ""}
784 className="font-mono text-sm"
785 onFocus={(e) => e.currentTarget.select()}
786 />
787 <Button
788 type="button"
789 variant="outline"
790 size="sm"
791 onClick={() => copy(webhook?.url || "", "url")}
792 disabled={!webhook?.url}
793 aria-label={__("Copy callback URL", "yatra")}
794 >
795 {justCopied === "url" ? (
796 <>
797 <Check className="mr-1.5 h-3.5 w-3.5" />
798 {__("Copied", "yatra")}
799 </>
800 ) : (
801 <>
802 <Copy className="mr-1.5 h-3.5 w-3.5" />
803 {__("Copy", "yatra")}
804 </>
805 )}
806 </Button>
807 </div>
808 </div>
809
810 {/* Verify token */}
811 <div>
812 <Label
813 htmlFor="whatsapp-verify-token"
814 className="text-xs text-gray-500 dark:text-gray-400"
815 >
816 {__("Verify token", "yatra")}
817 </Label>
818 <div className="mt-1 flex gap-2">
819 <Input
820 id="whatsapp-verify-token"
821 readOnly
822 value={webhook?.verify_token || ""}
823 placeholder={
824 tokenMissing
825 ? __(
826 "Will be generated when you save settings for the first time.",
827 "yatra",
828 )
829 : ""
830 }
831 className="font-mono text-sm"
832 onFocus={(e) => e.currentTarget.select()}
833 />
834 <Button
835 type="button"
836 variant="outline"
837 size="sm"
838 onClick={() => copy(webhook?.verify_token || "", "token")}
839 disabled={tokenMissing}
840 aria-label={__("Copy verify token", "yatra")}
841 >
842 {justCopied === "token" ? (
843 <>
844 <Check className="mr-1.5 h-3.5 w-3.5" />
845 {__("Copied", "yatra")}
846 </>
847 ) : (
848 <>
849 <Copy className="mr-1.5 h-3.5 w-3.5" />
850 {__("Copy", "yatra")}
851 </>
852 )}
853 </Button>
854 </div>
855 {tokenMissing && (
856 <p className="mt-1 text-xs text-amber-600 dark:text-amber-400">
857 {__(
858 "Save your settings once to generate a verify token, then come back to copy it.",
859 "yatra",
860 )}
861 </p>
862 )}
863 </div>
864
865 {/* App secret status (read-only) */}
866 <div className="rounded-md bg-gray-50 dark:bg-gray-800/50 px-3 py-2.5 text-sm">
867 {webhook?.app_secret_configured ? (
868 <span className="inline-flex items-center gap-2 text-green-700 dark:text-green-400">
869 <CheckCircle2 className="h-4 w-4" />
870 {__(
871 "App Secret is configured — webhook signatures will be verified.",
872 "yatra",
873 )}
874 </span>
875 ) : (
876 <span className="inline-flex items-center gap-2 text-amber-700 dark:text-amber-400">
877 <XCircle className="h-4 w-4" />
878 {__(
879 "App Secret not set — inbound webhook events will be rejected for security. Paste your Meta App Secret above as “Webhook verify secret”.",
880 "yatra",
881 )}
882 </span>
883 )}
884 </div>
885 </CardContent>
886 </Card>
887 );
888 };
889
890 const CredentialField: React.FC<{
891 label: string;
892 description?: string;
893 docsUrl?: string;
894 optional?: boolean;
895 placeholder: string;
896 configured: boolean;
897 visible: boolean;
898 value: string;
899 onToggle: () => void;
900 onChange: (v: string) => void;
901 onSave: () => void;
902 onClear: () => void;
903 saving: boolean;
904 }> = ({
905 label,
906 description,
907 docsUrl,
908 optional,
909 placeholder,
910 configured,
911 visible,
912 value,
913 onToggle,
914 onChange,
915 onSave,
916 onClear,
917 saving,
918 }) => (
919 <div className="space-y-2">
920 <div className="flex items-center justify-between gap-3">
921 <Label className="flex items-center gap-1.5">
922 {label}
923 {optional && (
924 <span className="text-[10px] uppercase tracking-wide text-gray-400">
925 {__("optional", "yatra")}
926 </span>
927 )}
928 </Label>
929 {docsUrl && (
930 <a
931 href={docsUrl}
932 target="_blank"
933 rel="noopener noreferrer"
934 className="inline-flex items-center gap-1 text-xs text-blue-600 hover:underline dark:text-blue-400"
935 >
936 {__("Where to find this", "yatra")}
937 <ExternalLink className="h-3 w-3" />
938 </a>
939 )}
940 </div>
941 {description && (
942 <p className="text-xs text-gray-500 dark:text-gray-400 flex items-start gap-1.5">
943 <Info className="h-3.5 w-3.5 mt-0.5 flex-shrink-0 text-gray-400" />
944 <span>{description}</span>
945 </p>
946 )}
947 <div className="flex gap-2">
948 <div className="relative flex-1">
949 <Input
950 type={visible ? "text" : "password"}
951 value={value}
952 placeholder={placeholder}
953 onChange={(e) => onChange(e.target.value)}
954 />
955 <button
956 type="button"
957 onClick={onToggle}
958 className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
959 >
960 {visible ? (
961 <EyeOff className="h-4 w-4" />
962 ) : (
963 <Eye className="h-4 w-4" />
964 )}
965 </button>
966 </div>
967 <Button onClick={onSave} disabled={saving || value.trim() === ""}>
968 <Save className="mr-1.5 h-4 w-4" />
969 {__("Save", "yatra")}
970 </Button>
971 {configured && (
972 <Button variant="outline" onClick={onClear} disabled={saving}>
973 {__("Clear", "yatra")}
974 </Button>
975 )}
976 </div>
977 {configured && (
978 <p className="text-xs text-green-600 dark:text-green-400">
979 <CheckCircle2 className="mr-1 inline h-3 w-3" />
980 {__("Configured", "yatra")}
981 </p>
982 )}
983 </div>
984 );
985
986 /* -------------------------------------------------------------------------- */
987 /* Templates tab — DB-driven list + create/edit */
988 /* -------------------------------------------------------------------------- */
989
990 const TemplatesTab: React.FC = () => {
991 const [editingId, setEditingId] = useState<number | "new" | null>(null);
992
993 const { data: tplData, isLoading: tplLoading } = useQuery({
994 queryKey: ["whatsapp-templates"],
995 queryFn: () => whatsappApi.listTemplates(),
996 });
997 const { data: eventsData, isLoading: eventsLoading } = useQuery({
998 queryKey: ["whatsapp-events"],
999 queryFn: () => whatsappApi.listEvents(),
1000 });
1001
1002 if (tplLoading || eventsLoading) {
1003 return (
1004 <div className="flex justify-center py-16">
1005 <Loader2 className="h-10 w-10 animate-spin text-gray-400" />
1006 </div>
1007 );
1008 }
1009
1010 const templates = tplData?.data ?? [];
1011 const events = eventsData?.data ?? [];
1012
1013 if (editingId !== null) {
1014 const existing =
1015 editingId === "new"
1016 ? null
1017 : (templates.find((t) => t.id === editingId) ?? null);
1018 return (
1019 <TemplateEditForm
1020 existing={existing}
1021 events={events}
1022 onClose={() => setEditingId(null)}
1023 />
1024 );
1025 }
1026
1027 return (
1028 <TemplatesList
1029 templates={templates}
1030 events={events}
1031 onEdit={(id) => setEditingId(id)}
1032 onCreate={() => setEditingId("new")}
1033 />
1034 );
1035 };
1036
1037 const TemplatesList: React.FC<{
1038 templates: WhatsappTemplate[];
1039 events: WhatsappEvent[];
1040 onEdit: (id: number) => void;
1041 onCreate: () => void;
1042 }> = ({ templates, events, onEdit, onCreate }) => {
1043 const queryClient = useQueryClient();
1044 const { showToast } = useToast();
1045 const eventName = (key: string) =>
1046 events.find((e) => e.key === key)?.name ?? key;
1047
1048 // Confirm-before-delete state, replaces native window.confirm()
1049 // for visual + accessibility consistency with the rest of the admin.
1050 const [pendingDelete, setPendingDelete] = useState<{
1051 id: number;
1052 name: string;
1053 } | null>(null);
1054 // Version-history dialog state. Holds the template the operator
1055 // clicked "History" on; null = dialog closed.
1056 const [historyFor, setHistoryFor] = useState<{
1057 id: number;
1058 name: string;
1059 isSystem: boolean;
1060 } | null>(null);
1061
1062 const deleteTpl = useMutation({
1063 mutationFn: (id: number) => whatsappApi.deleteTemplate(id),
1064 onSuccess: () => {
1065 queryClient.invalidateQueries({ queryKey: ["whatsapp-templates"] });
1066 showToast(__("Template deleted.", "yatra"), "success");
1067 setPendingDelete(null);
1068 },
1069 onError: (e: any) => {
1070 showToast(extractError(e), "error");
1071 setPendingDelete(null);
1072 },
1073 });
1074
1075 const restoreDefaults = useMutation({
1076 mutationFn: () => whatsappApi.restoreDefaults(),
1077 onSuccess: () => {
1078 queryClient.invalidateQueries({ queryKey: ["whatsapp-templates"] });
1079 showToast(__("Default templates restored.", "yatra"), "success");
1080 },
1081 onError: (e: any) => showToast(extractError(e), "error"),
1082 });
1083
1084 // First-install recovery — if no system templates exist (e.g. seed
1085 // didn't run because of a hook-order race) we surface a "Restore
1086 // default templates" button so the operator can recover without
1087 // dev intervention.
1088 const noSystemTemplates = templates.every((t) => !t.is_system);
1089
1090 return (
1091 <div className="space-y-4">
1092 <div className="flex items-center justify-between">
1093 <div>
1094 <h3 className="text-lg font-semibold text-gray-900 dark:text-white">
1095 {__("WhatsApp templates", "yatra")}
1096 </h3>
1097 <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
1098 {__(
1099 "System templates ship with the plugin — you can toggle them on/off but not edit or delete them. Add custom templates for any event you want.",
1100 "yatra",
1101 )}
1102 </p>
1103 </div>
1104 <div className="flex items-center gap-2">
1105 {noSystemTemplates && (
1106 <Button
1107 variant="outline"
1108 onClick={() => restoreDefaults.mutate()}
1109 disabled={restoreDefaults.isPending}
1110 >
1111 {restoreDefaults.isPending ? (
1112 <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
1113 ) : (
1114 <Plus className="mr-1.5 h-4 w-4" />
1115 )}
1116 {__("Restore default templates", "yatra")}
1117 </Button>
1118 )}
1119 <Button onClick={onCreate}>
1120 <Plus className="mr-1.5 h-4 w-4" />
1121 {__("Add custom template", "yatra")}
1122 </Button>
1123 </div>
1124 </div>
1125
1126 <Card>
1127 <CardContent className="p-0">
1128 <div className="overflow-x-auto">
1129 <table className="w-full">
1130 <thead className="bg-gray-50 dark:bg-gray-800">
1131 <tr>
1132 <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
1133 {__("Name", "yatra")}
1134 </th>
1135 <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
1136 {__("Event", "yatra")}
1137 </th>
1138 <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
1139 {__("Recipient", "yatra")}
1140 </th>
1141 <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
1142 {__("Type", "yatra")}
1143 </th>
1144 <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
1145 {__("Status", "yatra")}
1146 </th>
1147 <th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
1148 {__("Actions", "yatra")}
1149 </th>
1150 </tr>
1151 </thead>
1152 <tbody className="divide-y divide-gray-200 dark:divide-gray-700">
1153 {templates.length === 0 ? (
1154 <tr>
1155 <td
1156 colSpan={6}
1157 className="p-8 text-center text-gray-500 dark:text-gray-400"
1158 >
1159 {__(
1160 "No templates yet — click Add custom template.",
1161 "yatra",
1162 )}
1163 </td>
1164 </tr>
1165 ) : (
1166 templates.map((tpl) => (
1167 <tr
1168 key={tpl.id}
1169 className="hover:bg-gray-50 dark:hover:bg-gray-800/50"
1170 >
1171 <td className="px-4 py-3">
1172 <div className="flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
1173 <FileText className="h-4 w-4 text-blue-500" />
1174 {tpl.name}
1175 </div>
1176 {tpl.description && (
1177 <div className="mt-0.5 text-xs text-gray-500 dark:text-gray-400 line-clamp-1">
1178 {tpl.description}
1179 </div>
1180 )}
1181 </td>
1182 <td className="px-4 py-3">
1183 <span className="inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-400">
1184 <Zap className="w-3 h-3" />
1185 {eventName(tpl.event_key)}
1186 </span>
1187 </td>
1188 <td className="px-4 py-3">
1189 <Badge
1190 className={
1191 tpl.recipient_type === "admin"
1192 ? "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400"
1193 : "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
1194 }
1195 >
1196 {tpl.recipient_type === "admin"
1197 ? __("Admin", "yatra")
1198 : __("Customer", "yatra")}
1199 </Badge>
1200 </td>
1201 <td className="px-4 py-3">
1202 <Badge
1203 className={
1204 tpl.is_system
1205 ? "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300"
1206 : "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
1207 }
1208 >
1209 {tpl.is_system
1210 ? __("System", "yatra")
1211 : __("Custom", "yatra")}
1212 </Badge>
1213 </td>
1214 <td className="px-4 py-3">
1215 <Badge
1216 className={
1217 tpl.is_active
1218 ? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
1219 : "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-300"
1220 }
1221 >
1222 {tpl.is_active
1223 ? __("Active", "yatra")
1224 : __("Disabled", "yatra")}
1225 </Badge>
1226 </td>
1227 <td className="px-4 py-3 text-right">
1228 <div className="inline-flex gap-1">
1229 <Button
1230 variant="outline"
1231 size="sm"
1232 onClick={() => onEdit(tpl.id)}
1233 >
1234 <Pencil className="mr-1.5 h-3.5 w-3.5" />
1235 {__("Edit", "yatra")}
1236 </Button>
1237 <Button
1238 variant="outline"
1239 size="sm"
1240 onClick={() =>
1241 setHistoryFor({
1242 id: tpl.id,
1243 name: tpl.name,
1244 isSystem: tpl.is_system,
1245 })
1246 }
1247 aria-label={__("Version history", "yatra")}
1248 title={__("Version history", "yatra")}
1249 >
1250 <History className="h-3.5 w-3.5" />
1251 </Button>
1252 {!tpl.is_system && (
1253 <Button
1254 variant="outline"
1255 size="sm"
1256 className="border-red-200 text-red-700 hover:bg-red-50 dark:border-red-700 dark:text-red-300 dark:hover:bg-red-900/30"
1257 onClick={() =>
1258 setPendingDelete({ id: tpl.id, name: tpl.name })
1259 }
1260 disabled={deleteTpl.isPending}
1261 aria-label={__("Delete template", "yatra")}
1262 >
1263 <Trash2 className="h-3.5 w-3.5" />
1264 </Button>
1265 )}
1266 </div>
1267 </td>
1268 </tr>
1269 ))
1270 )}
1271 </tbody>
1272 </table>
1273 </div>
1274 </CardContent>
1275 </Card>
1276 <ConfirmationDialog
1277 isOpen={pendingDelete !== null}
1278 onClose={() => {
1279 if (!deleteTpl.isPending) setPendingDelete(null);
1280 }}
1281 onConfirm={() => {
1282 if (pendingDelete) deleteTpl.mutate(pendingDelete.id);
1283 }}
1284 title={__("Delete template?", "yatra")}
1285 description={
1286 pendingDelete
1287 ? __(
1288 "Delete the “{name}” template? This cannot be undone, and any events bound to it will stop sending until you create a replacement.",
1289 "yatra",
1290 ).replace("{name}", pendingDelete.name)
1291 : ""
1292 }
1293 confirmText={__("Delete template", "yatra")}
1294 cancelText={__("Cancel", "yatra")}
1295 variant="danger"
1296 isLoading={deleteTpl.isPending}
1297 />
1298
1299 <TemplateHistoryDialog
1300 template={historyFor}
1301 onClose={() => setHistoryFor(null)}
1302 />
1303 </div>
1304 );
1305 };
1306
1307 /* -------------------------------------------------------------------------- */
1308 /* Template version-history dialog */
1309 /* */
1310 /* Lists every prior snapshot of a template newest-first. Each row shows */
1311 /* the version number, who edited, when, and a short summary of what they */
1312 /* changed. Clicking a row expands its body so the operator can compare */
1313 /* before/after at a glance. "Restore this version" rewrites the live row */
1314 /* with the snapshot's fields (honoring the system-row immutability rule — */
1315 /* for system templates only `is_active` restores). */
1316 /* -------------------------------------------------------------------------- */
1317
1318 const TemplateHistoryDialog: React.FC<{
1319 template: { id: number; name: string; isSystem: boolean } | null;
1320 onClose: () => void;
1321 }> = ({ template, onClose }) => {
1322 const queryClient = useQueryClient();
1323 const { showToast } = useToast();
1324 const [expandedId, setExpandedId] = useState<number | null>(null);
1325 const [pendingRestore, setPendingRestore] =
1326 useState<WhatsappTemplateVersion | null>(null);
1327
1328 const enabled = template !== null;
1329 const { data, isLoading } = useQuery({
1330 queryKey: ["whatsapp-template-versions", template?.id],
1331 queryFn: () => whatsappApi.listTemplateVersions(template!.id),
1332 enabled,
1333 });
1334
1335 const restore = useMutation({
1336 mutationFn: (versionId: number) =>
1337 whatsappApi.restoreTemplateVersion(template!.id, versionId),
1338 onSuccess: (r) => {
1339 showToast(r.message, "success");
1340 void queryClient.invalidateQueries({ queryKey: ["whatsapp-templates"] });
1341 void queryClient.invalidateQueries({
1342 queryKey: ["whatsapp-template-versions", template!.id],
1343 });
1344 setPendingRestore(null);
1345 },
1346 onError: (e: any) => {
1347 showToast(extractError(e), "error");
1348 setPendingRestore(null);
1349 },
1350 });
1351
1352 if (!template) return null;
1353 const versions = data?.data ?? [];
1354
1355 return (
1356 <Modal
1357 isOpen
1358 onClose={onClose}
1359 title={sprintf(
1360 /* translators: %s: template name */
1361 __("Version history — %s", "yatra"),
1362 template.name,
1363 )}
1364 size="xl"
1365 >
1366 <div className="space-y-3">
1367 {template.isSystem && (
1368 <Alert variant="info">
1369 {__(
1370 "Restoring a system template only resets its Active/Disabled toggle. Body, event binding, and recipient type are managed by the plugin and can't be reverted from history.",
1371 "yatra",
1372 )}
1373 </Alert>
1374 )}
1375
1376 {isLoading ? (
1377 <div className="flex justify-center py-8">
1378 <Loader2 className="h-6 w-6 animate-spin text-gray-400" />
1379 </div>
1380 ) : versions.length === 0 ? (
1381 <div className="text-center py-10">
1382 <Clock className="w-10 h-10 text-gray-400 mx-auto" />
1383 <p className="mt-3 text-sm text-gray-600 dark:text-gray-400">
1384 {__(
1385 "No prior versions yet. Make an edit and history starts building from the next save.",
1386 "yatra",
1387 )}
1388 </p>
1389 </div>
1390 ) : (
1391 <div className="space-y-2 max-h-[60vh] overflow-y-auto">
1392 {versions.map((v) => {
1393 const isExpanded = expandedId === v.id;
1394 return (
1395 <div
1396 key={v.id}
1397 className="rounded-md border border-gray-200 dark:border-gray-700"
1398 >
1399 <button
1400 type="button"
1401 className="w-full flex items-start gap-3 px-3 py-2 text-left hover:bg-gray-50 dark:hover:bg-gray-800/40"
1402 onClick={() => setExpandedId(isExpanded ? null : v.id)}
1403 >
1404 <Badge className="bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300 mt-0.5">
1405 v{v.version_number}
1406 </Badge>
1407 <div className="flex-1 min-w-0">
1408 <div className="text-sm text-gray-900 dark:text-white">
1409 {v.change_summary || __("(no diff captured)", "yatra")}
1410 </div>
1411 <div className="text-xs text-gray-500 mt-0.5">
1412 {v.created_at}
1413 {v.created_by !== null && (
1414 <span>
1415 {" · "}
1416 {sprintf(
1417 /* translators: %d: WP user id of the editor */
1418 __("by user #%d", "yatra"),
1419 v.created_by,
1420 )}
1421 </span>
1422 )}
1423 </div>
1424 </div>
1425 <ChevronDown
1426 className={`h-4 w-4 text-gray-400 mt-1 transition-transform ${
1427 isExpanded ? "rotate-180" : ""
1428 }`}
1429 />
1430 </button>
1431 {isExpanded && (
1432 <div className="border-t border-gray-200 dark:border-gray-700 p-3 space-y-2 text-xs">
1433 <div className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-gray-600 dark:text-gray-300">
1434 <div>
1435 <div className="font-medium text-gray-500">
1436 {__("Name", "yatra")}
1437 </div>
1438 <div>{v.snapshot.name || "—"}</div>
1439 </div>
1440 <div>
1441 <div className="font-medium text-gray-500">
1442 {__("Event", "yatra")}
1443 </div>
1444 <div className="font-mono">
1445 {v.snapshot.event_key || "—"}
1446 </div>
1447 </div>
1448 <div>
1449 <div className="font-medium text-gray-500">
1450 {__("Recipient", "yatra")}
1451 </div>
1452 <div>{v.snapshot.recipient_type || "—"}</div>
1453 </div>
1454 <div>
1455 <div className="font-medium text-gray-500">
1456 {__("Active", "yatra")}
1457 </div>
1458 <div>
1459 {v.snapshot.is_active
1460 ? __("Yes", "yatra")
1461 : __("No", "yatra")}
1462 </div>
1463 </div>
1464 </div>
1465 <div>
1466 <div className="font-medium text-gray-500">
1467 {__("Body", "yatra")}
1468 </div>
1469 <pre className="mt-1 whitespace-pre-wrap rounded bg-gray-50 dark:bg-gray-800/50 p-2 font-mono text-[11px] max-h-48 overflow-y-auto">
1470 {v.snapshot.body || "—"}
1471 </pre>
1472 </div>
1473 <div className="flex justify-end pt-1">
1474 <Button
1475 size="sm"
1476 variant="outline"
1477 onClick={() => setPendingRestore(v)}
1478 >
1479 <RotateCcw className="h-3.5 w-3.5 mr-1" />
1480 {__("Restore this version", "yatra")}
1481 </Button>
1482 </div>
1483 </div>
1484 )}
1485 </div>
1486 );
1487 })}
1488 </div>
1489 )}
1490 </div>
1491
1492 <ConfirmationDialog
1493 isOpen={pendingRestore !== null}
1494 onClose={() => {
1495 if (!restore.isPending) setPendingRestore(null);
1496 }}
1497 onConfirm={() => {
1498 if (pendingRestore) restore.mutate(pendingRestore.id);
1499 }}
1500 title={__("Restore this version?", "yatra")}
1501 description={
1502 pendingRestore
1503 ? sprintf(
1504 __(
1505 "This rewrites the live template with v%d's contents. The current state is saved as a new version first so you can roll back this restore later.",
1506 "yatra",
1507 ),
1508 pendingRestore.version_number,
1509 )
1510 : ""
1511 }
1512 confirmText={__("Restore version", "yatra")}
1513 cancelText={__("Cancel", "yatra")}
1514 isLoading={restore.isPending}
1515 />
1516 </Modal>
1517 );
1518 };
1519
1520 const TemplateEditForm: React.FC<{
1521 existing: WhatsappTemplate | null;
1522 events: WhatsappEvent[];
1523 onClose: () => void;
1524 }> = ({ existing, events, onClose }) => {
1525 const queryClient = useQueryClient();
1526 const { showToast } = useToast();
1527 const isCreate = existing === null;
1528 const isSystem = !!existing?.is_system;
1529
1530 const [name, setName] = useState(existing?.name ?? "");
1531 const [description, setDescription] = useState(existing?.description ?? "");
1532 const [eventKey, setEventKey] = useState(existing?.event_key ?? "");
1533 const [recipient, setRecipient] = useState<"customer" | "admin">(
1534 existing?.recipient_type ?? "customer",
1535 );
1536 const [isActive, setIsActive] = useState(existing?.is_active ?? true);
1537 const [metaTemplateName, setMetaTemplateName] = useState(
1538 existing?.meta_template_name ?? "",
1539 );
1540 const [languageCode, setLanguageCode] = useState(
1541 existing?.language_code ?? "en_US",
1542 );
1543 const [body, setBody] = useState(existing?.body ?? "");
1544 const [copiedVar, setCopiedVar] = useState<string | null>(null);
1545 const [showEventDropdown, setShowEventDropdown] = useState(false);
1546
1547 const bodyRef = React.useRef<HTMLTextAreaElement | null>(null);
1548
1549 // Variables list re-derived whenever the selected event changes —
1550 // each event declares its own variables array on the server.
1551 const selectedEvent = useMemo(
1552 () => events.find((e) => e.key === eventKey) ?? null,
1553 [events, eventKey],
1554 );
1555 const variables = selectedEvent?.variables ?? [];
1556
1557 const save = useMutation({
1558 mutationFn: () => {
1559 // System rows accept ONLY is_active. Sending everything else
1560 // would be no-ops server-side but the smaller payload is
1561 // clearer in network logs and matches the locked-UI contract.
1562 const payload: Partial<WhatsappTemplate> = isSystem
1563 ? { is_active: isActive }
1564 : {
1565 name,
1566 description,
1567 event_key: eventKey,
1568 recipient_type: recipient,
1569 is_active: isActive,
1570 body,
1571 settings: {
1572 meta_template_name: metaTemplateName,
1573 language_code: languageCode,
1574 },
1575 };
1576 return isCreate
1577 ? whatsappApi.createTemplate(payload)
1578 : whatsappApi.updateTemplate(existing!.id, payload);
1579 },
1580 onSuccess: () => {
1581 queryClient.invalidateQueries({ queryKey: ["whatsapp-templates"] });
1582 showToast(
1583 isCreate
1584 ? __("Template created.", "yatra")
1585 : __("Template saved.", "yatra"),
1586 "success",
1587 );
1588 onClose();
1589 },
1590 onError: (e: any) => showToast(extractError(e), "error"),
1591 });
1592
1593 const copyVariable = (placeholder: string) => {
1594 try {
1595 navigator.clipboard?.writeText(placeholder);
1596 setCopiedVar(placeholder);
1597 setTimeout(() => setCopiedVar(null), 1500);
1598 } catch {
1599 /* silent */
1600 }
1601 };
1602
1603 const insertVariable = (placeholder: string) => {
1604 const el = bodyRef.current;
1605 if (!el) {
1606 setBody((prev) => prev + placeholder);
1607 return;
1608 }
1609 const start = el.selectionStart ?? body.length;
1610 const end = el.selectionEnd ?? body.length;
1611 const next = body.slice(0, start) + placeholder + body.slice(end);
1612 setBody(next);
1613 requestAnimationFrame(() => {
1614 if (!bodyRef.current) return;
1615 const cursor = start + placeholder.length;
1616 bodyRef.current.focus();
1617 bodyRef.current.setSelectionRange(cursor, cursor);
1618 });
1619 };
1620
1621 return (
1622 <div className="space-y-6">
1623 <div className="flex items-center justify-between gap-4">
1624 <div className="flex items-center gap-3">
1625 <Button variant="outline" size="sm" onClick={onClose}>
1626 <ArrowLeft className="mr-1.5 h-4 w-4" />
1627 {__("Back to templates", "yatra")}
1628 </Button>
1629 <div>
1630 <h2 className="flex items-center gap-2 text-xl font-semibold text-gray-900 dark:text-white">
1631 <FileText className="h-5 w-5 text-blue-500" />
1632 {isCreate ? __("Add custom template", "yatra") : existing!.name}
1633 {isSystem && (
1634 <Badge className="bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300">
1635 {__("System", "yatra")}
1636 </Badge>
1637 )}
1638 </h2>
1639 {existing?.description && (
1640 <p className="mt-0.5 text-sm text-gray-500 dark:text-gray-400">
1641 {existing.description}
1642 </p>
1643 )}
1644 </div>
1645 </div>
1646 <Button
1647 onClick={() => save.mutate()}
1648 disabled={save.isPending || name.trim() === ""}
1649 >
1650 {save.isPending ? (
1651 <>
1652 <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
1653 {__("Saving…", "yatra")}
1654 </>
1655 ) : (
1656 <>
1657 <Save className="mr-1.5 h-4 w-4" />
1658 {isCreate
1659 ? __("Create template", "yatra")
1660 : __("Save changes", "yatra")}
1661 </>
1662 )}
1663 </Button>
1664 </div>
1665
1666 <div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
1667 <div className="space-y-6 lg:col-span-2">
1668 <Card>
1669 <CardHeader>
1670 <CardTitle className="flex items-center justify-between">
1671 <span>{__("Template details", "yatra")}</span>
1672 <label className="flex items-center gap-2 text-sm font-normal">
1673 <input
1674 type="checkbox"
1675 checked={isActive}
1676 onChange={(e) => setIsActive(e.target.checked)}
1677 />
1678 <Badge
1679 className={
1680 isActive
1681 ? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
1682 : "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-300"
1683 }
1684 >
1685 {isActive ? __("Active", "yatra") : __("Disabled", "yatra")}
1686 </Badge>
1687 </label>
1688 </CardTitle>
1689 </CardHeader>
1690 <CardContent className="space-y-4">
1691 {isSystem && (
1692 <div className="rounded-md border border-blue-200 bg-blue-50/60 p-3 dark:border-blue-700 dark:bg-blue-900/20">
1693 <div className="flex items-start gap-2">
1694 <Info className="mt-0.5 h-4 w-4 shrink-0 text-blue-600 dark:text-blue-400" />
1695 <div className="text-xs text-blue-900 dark:text-blue-200">
1696 {__(
1697 "This is a system template. You can toggle it on or off but every other field is locked — that way plugin updates can safely refresh the copy. Create a custom template if you need different content for this event.",
1698 "yatra",
1699 )}
1700 </div>
1701 </div>
1702 </div>
1703 )}
1704
1705 <div className="space-y-2">
1706 <Label>{__("Template name", "yatra")}</Label>
1707 <Input
1708 value={name}
1709 onChange={(e) => setName(e.target.value)}
1710 readOnly={isSystem}
1711 className={isSystem ? "bg-gray-50 dark:bg-gray-900/40" : ""}
1712 placeholder={__("e.g. Welcome to your trip", "yatra")}
1713 />
1714 </div>
1715
1716 <div className="space-y-2">
1717 <Label>{__("Description", "yatra")}</Label>
1718 <Input
1719 value={description ?? ""}
1720 onChange={(e) => setDescription(e.target.value)}
1721 readOnly={isSystem}
1722 className={isSystem ? "bg-gray-50 dark:bg-gray-900/40" : ""}
1723 placeholder={__(
1724 "Short description shown in the template list",
1725 "yatra",
1726 )}
1727 />
1728 </div>
1729
1730 {/* Trigger Event — read-only chip for system, dropdown for custom */}
1731 <div>
1732 <label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
1733 {__("Trigger Event", "yatra")}
1734 </label>
1735 {isSystem ? (
1736 <div className="space-y-2">
1737 <div className="flex items-center gap-2">
1738 <span className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-400 border border-indigo-200 dark:border-indigo-800">
1739 <Zap className="w-4 h-4" />
1740 {selectedEvent?.name ?? eventKey}
1741 </span>
1742 <span className="text-xs text-gray-500 dark:text-gray-400">
1743 {__("System templates can't change events", "yatra")}
1744 </span>
1745 </div>
1746 {selectedEvent?.description && (
1747 <div className="p-3 bg-indigo-50 dark:bg-indigo-900/20 rounded-lg border border-indigo-200 dark:border-indigo-800">
1748 <p className="text-sm text-indigo-800 dark:text-indigo-300">
1749 {selectedEvent.description}
1750 </p>
1751 </div>
1752 )}
1753 </div>
1754 ) : (
1755 <div className="relative">
1756 <button
1757 type="button"
1758 onClick={() => setShowEventDropdown((v) => !v)}
1759 className="w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-left flex items-center justify-between hover:border-gray-400 dark:hover:border-gray-500 transition-colors"
1760 >
1761 {selectedEvent ? (
1762 <div className="flex items-center gap-2">
1763 <Zap className="w-4 h-4 text-blue-500" />
1764 <span className="text-sm font-medium text-gray-900 dark:text-white">
1765 {selectedEvent.name}
1766 </span>
1767 <code className="text-xs px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 rounded">
1768 {selectedEvent.key}
1769 </code>
1770 </div>
1771 ) : (
1772 <div className="flex items-center gap-2">
1773 <Zap className="w-4 h-4 text-gray-400" />
1774 <span className="text-sm text-gray-500 dark:text-gray-400">
1775 {__("Pick an event…", "yatra")}
1776 </span>
1777 </div>
1778 )}
1779 <ChevronDown
1780 className={`w-4 h-4 text-gray-400 transition-transform ${
1781 showEventDropdown ? "rotate-180" : ""
1782 }`}
1783 />
1784 </button>
1785 {showEventDropdown && (
1786 <div className="absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg max-h-80 overflow-y-auto">
1787 <div className="py-1">
1788 {events.map((event) => {
1789 const active = event.key === eventKey;
1790 return (
1791 <button
1792 key={event.key}
1793 type="button"
1794 onClick={() => {
1795 setEventKey(event.key);
1796 setShowEventDropdown(false);
1797 }}
1798 className={`w-full px-3 py-3 text-left hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors border-b border-gray-100 dark:border-gray-700 last:border-b-0 ${
1799 active
1800 ? "bg-indigo-50 dark:bg-indigo-900/20"
1801 : ""
1802 }`}
1803 >
1804 <div className="flex items-start gap-2">
1805 <Zap
1806 className={`w-4 h-4 mt-0.5 flex-shrink-0 ${
1807 active ? "text-blue-500" : "text-gray-400"
1808 }`}
1809 />
1810 <div className="flex-1 min-w-0">
1811 <div className="flex items-center gap-2">
1812 <span
1813 className={`text-sm font-medium ${
1814 active
1815 ? "text-blue-800 dark:text-blue-400"
1816 : "text-gray-900 dark:text-white"
1817 }`}
1818 >
1819 {event.name}
1820 </span>
1821 <code className="text-xs px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 rounded">
1822 {event.key}
1823 </code>
1824 </div>
1825 <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 line-clamp-2">
1826 {event.description}
1827 </p>
1828 </div>
1829 {active && (
1830 <Check className="w-4 h-4 text-blue-500 flex-shrink-0" />
1831 )}
1832 </div>
1833 </button>
1834 );
1835 })}
1836 </div>
1837 </div>
1838 )}
1839 </div>
1840 )}
1841 {selectedEvent && (
1842 <p className="text-xs text-gray-500 dark:text-gray-400 mt-1 italic">
1843 {selectedEvent.description}
1844 </p>
1845 )}
1846 </div>
1847
1848 {/* Recipient type */}
1849 <div className="space-y-2">
1850 <Label>{__("Recipient", "yatra")}</Label>
1851 <div className="flex gap-2">
1852 <label
1853 className={`flex-1 flex items-center gap-2 px-3 py-2 rounded-lg border cursor-pointer ${
1854 recipient === "customer"
1855 ? "border-blue-300 bg-blue-50 dark:border-blue-700 dark:bg-blue-900/20"
1856 : "border-gray-300 dark:border-gray-600"
1857 } ${isSystem ? "opacity-60 cursor-not-allowed" : ""}`}
1858 >
1859 <input
1860 type="radio"
1861 checked={recipient === "customer"}
1862 onChange={() => !isSystem && setRecipient("customer")}
1863 disabled={isSystem}
1864 />
1865 <UserCircle className="h-4 w-4 text-blue-600" />
1866 <div>
1867 <div className="text-sm font-medium">
1868 {__("Customer", "yatra")}
1869 </div>
1870 <div className="text-xs text-gray-500 dark:text-gray-400">
1871 {__("Sends to the booking customer", "yatra")}
1872 </div>
1873 </div>
1874 </label>
1875 <label
1876 className={`flex-1 flex items-center gap-2 px-3 py-2 rounded-lg border cursor-pointer ${
1877 recipient === "admin"
1878 ? "border-orange-300 bg-orange-50 dark:border-orange-700 dark:bg-orange-900/20"
1879 : "border-gray-300 dark:border-gray-600"
1880 } ${isSystem ? "opacity-60 cursor-not-allowed" : ""}`}
1881 >
1882 <input
1883 type="radio"
1884 checked={recipient === "admin"}
1885 onChange={() => !isSystem && setRecipient("admin")}
1886 disabled={isSystem}
1887 />
1888 <UserCircle className="h-4 w-4 text-orange-600" />
1889 <div>
1890 <div className="text-sm font-medium">
1891 {__("Admin", "yatra")}
1892 </div>
1893 <div className="text-xs text-gray-500 dark:text-gray-400">
1894 {__(
1895 "Sends to the admin phone in Delivery settings",
1896 "yatra",
1897 )}
1898 </div>
1899 </div>
1900 </label>
1901 </div>
1902 {isSystem && (
1903 <p className="text-xs text-gray-500 dark:text-gray-400 italic">
1904 {__(
1905 "System templates can't change recipient type.",
1906 "yatra",
1907 )}
1908 </p>
1909 )}
1910 </div>
1911
1912 {/* Meta provider settings */}
1913 <div className="grid gap-4 md:grid-cols-2">
1914 <div className="space-y-2">
1915 <Label>{__("Meta template name", "yatra")}</Label>
1916 <Input
1917 value={metaTemplateName}
1918 onChange={(e) => setMetaTemplateName(e.target.value)}
1919 readOnly={isSystem}
1920 className={isSystem ? "bg-gray-50 dark:bg-gray-900/40" : ""}
1921 placeholder="yatra_booking_confirmed"
1922 />
1923 </div>
1924 <div className="space-y-2">
1925 <Label>{__("Language code", "yatra")}</Label>
1926 <Input
1927 value={languageCode}
1928 onChange={(e) => setLanguageCode(e.target.value)}
1929 readOnly={isSystem}
1930 className={isSystem ? "bg-gray-50 dark:bg-gray-900/40" : ""}
1931 placeholder="en_US"
1932 />
1933 </div>
1934 </div>
1935
1936 <div>
1937 <label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
1938 {__("Message body", "yatra")}
1939 </label>
1940 <textarea
1941 ref={bodyRef}
1942 value={body}
1943 onChange={(e) => setBody(e.target.value)}
1944 rows={12}
1945 readOnly={isSystem}
1946 className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg font-mono text-sm text-gray-900 dark:text-white ${
1947 isSystem
1948 ? "bg-gray-50 dark:bg-gray-900/40"
1949 : "bg-white dark:bg-gray-800"
1950 }`}
1951 placeholder={__(
1952 "Use {{variable}} syntax — variables come from the trigger event.",
1953 "yatra",
1954 )}
1955 />
1956 <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
1957 {isSystem
1958 ? __(
1959 "System template body is locked. Create a custom template to author your own copy for this event.",
1960 "yatra",
1961 )
1962 : __(
1963 "Click a variable on the right to copy, double-click to insert at the cursor.",
1964 "yatra",
1965 )}
1966 </p>
1967 </div>
1968 </CardContent>
1969 </Card>
1970 </div>
1971
1972 {/* Variables sidebar — filtered by selected event */}
1973 <div className="space-y-6">
1974 <Card>
1975 <CardHeader>
1976 <CardTitle className="flex items-center gap-2">
1977 <Code className="w-5 h-5 text-blue-500" />
1978 {__("Available Variables", "yatra")}
1979 </CardTitle>
1980 </CardHeader>
1981 <CardContent className="space-y-3">
1982 {!selectedEvent ? (
1983 <p className="text-sm text-gray-500 dark:text-gray-400">
1984 {__(
1985 "Pick a Trigger Event above to see the variables you can use here.",
1986 "yatra",
1987 )}
1988 </p>
1989 ) : variables.length === 0 ? (
1990 <p className="text-sm text-gray-500 dark:text-gray-400">
1991 {__("This event has no variables.", "yatra")}
1992 </p>
1993 ) : (
1994 <>
1995 <p className="text-xs text-gray-500 dark:text-gray-400">
1996 {__(
1997 "Click to copy, double-click to insert at cursor",
1998 "yatra",
1999 )}
2000 </p>
2001 <div className="space-y-1">
2002 {variables.map((v) => {
2003 const placeholder = `{{${v.key}}}`;
2004 return (
2005 <button
2006 key={v.key}
2007 type="button"
2008 onClick={() => copyVariable(placeholder)}
2009 onDoubleClick={() => insertVariable(placeholder)}
2010 className="w-full text-left px-2 py-1.5 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors group"
2011 >
2012 <div className="flex items-center justify-between">
2013 <code className="text-xs text-blue-600 dark:text-blue-400">
2014 {placeholder}
2015 </code>
2016 {copiedVar === placeholder ? (
2017 <Check className="w-3 h-3 text-green-500" />
2018 ) : (
2019 <Copy className="w-3 h-3 text-gray-400 opacity-0 group-hover:opacity-100" />
2020 )}
2021 </div>
2022 {v.description && (
2023 <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
2024 {v.description}
2025 </p>
2026 )}
2027 </button>
2028 );
2029 })}
2030 </div>
2031 </>
2032 )}
2033 </CardContent>
2034 </Card>
2035
2036 {!isCreate && (
2037 <TemplateTestSendCard
2038 templateId={existing!.id}
2039 recipient={recipient}
2040 />
2041 )}
2042 </div>
2043 </div>
2044 </div>
2045 );
2046 };
2047
2048 const TemplateTestSendCard: React.FC<{
2049 templateId: number;
2050 recipient: "customer" | "admin";
2051 }> = ({ templateId, recipient }) => {
2052 const { showToast } = useToast();
2053 const [phone, setPhone] = useState("");
2054 const [sending, setSending] = useState(false);
2055 const [result, setResult] = useState<{ ok: boolean; message: string } | null>(
2056 null,
2057 );
2058
2059 const run = async () => {
2060 if (recipient === "customer" && phone.trim() === "") {
2061 showToast(__("Enter a destination phone number.", "yatra"), "error");
2062 return;
2063 }
2064 setSending(true);
2065 setResult(null);
2066 try {
2067 const res = await whatsappApi.testSendTemplate(templateId, {
2068 phone: phone.trim(),
2069 });
2070 setResult({
2071 ok: res.ok,
2072 message: res.ok
2073 ? __("Sent — check your WhatsApp.", "yatra") +
2074 (res.provider_message_id ? ` (id: ${res.provider_message_id})` : "")
2075 : res.error || __("Send failed.", "yatra"),
2076 });
2077 } catch (e: any) {
2078 setResult({ ok: false, message: extractError(e) });
2079 } finally {
2080 setSending(false);
2081 }
2082 };
2083
2084 return (
2085 <Card>
2086 <CardHeader>
2087 <CardTitle className="flex items-center gap-2">
2088 <Send className="h-5 w-5 text-blue-500" />
2089 {__("Send test message", "yatra")}
2090 </CardTitle>
2091 <CardDescription>
2092 {recipient === "admin"
2093 ? __(
2094 "Admin-recipient templates send to the admin phone in Delivery settings.",
2095 "yatra",
2096 )
2097 : __("Sends this template to the phone you enter below.", "yatra")}
2098 </CardDescription>
2099 </CardHeader>
2100 <CardContent className="space-y-3">
2101 {recipient === "customer" && (
2102 <div className="space-y-2">
2103 <Label>{__("Phone (E.164)", "yatra")}</Label>
2104 <Input
2105 value={phone}
2106 placeholder="+9779800000000"
2107 onChange={(e) => setPhone(e.target.value)}
2108 />
2109 </div>
2110 )}
2111 <Button onClick={run} disabled={sending} className="w-full">
2112 {sending ? (
2113 <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
2114 ) : (
2115 <Send className="mr-1.5 h-4 w-4" />
2116 )}
2117 {__("Send test", "yatra")}
2118 </Button>
2119 {result && (
2120 <div
2121 className={`flex items-start gap-2 rounded-md p-3 text-sm ${
2122 result.ok
2123 ? "bg-green-50 text-green-800 dark:bg-green-900/20 dark:text-green-200"
2124 : "bg-red-50 text-red-800 dark:bg-red-900/20 dark:text-red-200"
2125 }`}
2126 >
2127 {result.ok ? (
2128 <CheckCircle2 className="mt-0.5 h-4 w-4" />
2129 ) : (
2130 <XCircle className="mt-0.5 h-4 w-4" />
2131 )}
2132 <span>{result.message}</span>
2133 </div>
2134 )}
2135 </CardContent>
2136 </Card>
2137 );
2138 };
2139
2140 /* -------------------------------------------------------------------------- */
2141 /* Frontend widget settings */
2142 /* -------------------------------------------------------------------------- */
2143
2144 /**
2145 * "Frontend widget" tab — configures the public-facing click-to-
2146 * WhatsApp affordances rendered by `Services/Widget.php`:
2147 * - Floating button (wp_footer)
2148 * - Single-trip sidebar CTA
2149 * - `[yatra_whatsapp_button]` shortcode
2150 *
2151 * The widget uses wa.me deep linking (free) — no Cloud API call on
2152 * tap, so this tab is independent of the credentials in Delivery.
2153 */
2154 const WidgetSection: React.FC<{
2155 settings: WhatsappSettings;
2156 onSave: (patch: Partial<WhatsappSettings>) => void;
2157 saving: boolean;
2158 }> = ({ settings, onSave, saving }) => {
2159 const w: WhatsappWidgetSettings = settings.widget;
2160 const [enabled, setEnabled] = useState(w.enabled);
2161 const [contactPhone, setContactPhone] = useState(w.contact_phone);
2162 const [prefilled, setPrefilled] = useState(w.prefilled_message);
2163 const [displayOn, setDisplayOn] = useState<"all" | "trips_only">(
2164 w.display_on,
2165 );
2166 const [position, setPosition] = useState<"bottom-right" | "bottom-left">(
2167 w.position,
2168 );
2169 const [label, setLabel] = useState(w.label);
2170 const [showOnSidebar, setShowOnSidebar] = useState(w.show_on_trip_sidebar);
2171
2172 React.useEffect(() => {
2173 setEnabled(w.enabled);
2174 setContactPhone(w.contact_phone);
2175 setPrefilled(w.prefilled_message);
2176 setDisplayOn(w.display_on);
2177 setPosition(w.position);
2178 setLabel(w.label);
2179 setShowOnSidebar(w.show_on_trip_sidebar);
2180 }, [
2181 w.enabled,
2182 w.contact_phone,
2183 w.prefilled_message,
2184 w.display_on,
2185 w.position,
2186 w.label,
2187 w.show_on_trip_sidebar,
2188 ]);
2189
2190 const dirty =
2191 enabled !== w.enabled ||
2192 contactPhone !== w.contact_phone ||
2193 prefilled !== w.prefilled_message ||
2194 displayOn !== w.display_on ||
2195 position !== w.position ||
2196 label !== w.label ||
2197 showOnSidebar !== w.show_on_trip_sidebar;
2198
2199 const save = () =>
2200 onSave({
2201 widget: {
2202 enabled,
2203 contact_phone: contactPhone,
2204 prefilled_message: prefilled,
2205 display_on: displayOn,
2206 position,
2207 label,
2208 show_on_trip_sidebar: showOnSidebar,
2209 },
2210 });
2211
2212 // Live preview of the wa.me URL the button will open against.
2213 const previewUrl = useMemo(() => {
2214 const digits = contactPhone.replace(/[^0-9]/g, "");
2215 const msg = prefilled.replace(/\{\{trip_name\}\}/g, "Demo Trip");
2216 return digits
2217 ? `https://wa.me/${digits}?text=${encodeURIComponent(msg)}`
2218 : "";
2219 }, [contactPhone, prefilled]);
2220
2221 return (
2222 <div className="space-y-6">
2223 <Card>
2224 <CardHeader>
2225 <CardTitle className="flex items-center gap-2">
2226 <MessageCircle className="h-5 w-5 text-green-500" />
2227 {__("Public WhatsApp widget", "yatra")}
2228 </CardTitle>
2229 <CardDescription>
2230 {__(
2231 'Adds a "Chat on WhatsApp" affordance to your public site. Free for the operator — uses wa.me deep linking, no per-tap charges. Independent of the Cloud API credentials you configured under Delivery.',
2232 "yatra",
2233 )}
2234 </CardDescription>
2235 </CardHeader>
2236 <CardContent className="space-y-5">
2237 {/* Master toggle */}
2238 <label className="flex items-start gap-3 cursor-pointer">
2239 <input
2240 type="checkbox"
2241 checked={enabled}
2242 onChange={(e) => setEnabled(e.target.checked)}
2243 className="mt-1"
2244 />
2245 <div>
2246 <div className="text-sm font-medium text-gray-900 dark:text-white">
2247 {__("Enable widget", "yatra")}
2248 </div>
2249 <div className="text-xs text-gray-500 dark:text-gray-400">
2250 {__(
2251 "Off by default. When on, renders per the display rules below.",
2252 "yatra",
2253 )}
2254 </div>
2255 </div>
2256 </label>
2257
2258 {/* Contact phone */}
2259 <div className="space-y-2">
2260 <Label>{__("Contact phone (E.164)", "yatra")}</Label>
2261 <Input
2262 value={contactPhone}
2263 placeholder="+9779800000000"
2264 onChange={(e) => setContactPhone(e.target.value)}
2265 />
2266 <p className="text-xs text-gray-500 dark:text-gray-400">
2267 {__(
2268 "Visitors land on a chat with THIS number. Can be any WhatsApp-enabled phone — doesn't have to be the Cloud-API phone you use for outbound automation.",
2269 "yatra",
2270 )}
2271 </p>
2272 </div>
2273
2274 {/* Prefilled message */}
2275 <div className="space-y-2">
2276 <Label>{__("Pre-filled message", "yatra")}</Label>
2277 <textarea
2278 className="w-full rounded-md border border-gray-300 bg-white p-2 text-sm dark:border-gray-600 dark:bg-gray-800"
2279 rows={3}
2280 value={prefilled}
2281 onChange={(e) => setPrefilled(e.target.value)}
2282 placeholder={__(
2283 "Hi! I'd like to know more about {{trip_name}}.",
2284 "yatra",
2285 )}
2286 />
2287 <p className="text-xs text-gray-500 dark:text-gray-400">
2288 {__(
2289 "Use {{trip_name}} to insert the current trip's name when the widget renders on a single-trip page. Replaced with the actual title at render time.",
2290 "yatra",
2291 )}
2292 </p>
2293 </div>
2294
2295 {/* Display rules */}
2296 <div className="grid gap-4 md:grid-cols-2">
2297 <div className="space-y-2">
2298 <Label>{__("Show on", "yatra")}</Label>
2299 <div className="space-y-1">
2300 <label className="flex items-center gap-2 text-sm cursor-pointer">
2301 <input
2302 type="radio"
2303 checked={displayOn === "trips_only"}
2304 onChange={() => setDisplayOn("trips_only")}
2305 />
2306 {__("Single trip pages only", "yatra")}
2307 </label>
2308 <label className="flex items-center gap-2 text-sm cursor-pointer">
2309 <input
2310 type="radio"
2311 checked={displayOn === "all"}
2312 onChange={() => setDisplayOn("all")}
2313 />
2314 {__("Every frontend page", "yatra")}
2315 </label>
2316 </div>
2317 </div>
2318 <div className="space-y-2">
2319 <Label>{__("Position", "yatra")}</Label>
2320 <div className="space-y-1">
2321 <label className="flex items-center gap-2 text-sm cursor-pointer">
2322 <input
2323 type="radio"
2324 checked={position === "bottom-right"}
2325 onChange={() => setPosition("bottom-right")}
2326 />
2327 {__("Bottom right", "yatra")}
2328 </label>
2329 <label className="flex items-center gap-2 text-sm cursor-pointer">
2330 <input
2331 type="radio"
2332 checked={position === "bottom-left"}
2333 onChange={() => setPosition("bottom-left")}
2334 />
2335 {__("Bottom left", "yatra")}
2336 </label>
2337 </div>
2338 </div>
2339 </div>
2340
2341 {/* Label + trip-sidebar CTA */}
2342 <div className="space-y-2">
2343 <Label>{__("Button label", "yatra")}</Label>
2344 <Input
2345 value={label}
2346 placeholder={__("Chat on WhatsApp", "yatra")}
2347 onChange={(e) => setLabel(e.target.value)}
2348 />
2349 <p className="text-xs text-gray-500 dark:text-gray-400">
2350 {__("Leave blank to show only the WhatsApp icon.", "yatra")}
2351 </p>
2352 </div>
2353
2354 <label className="flex items-start gap-3 cursor-pointer">
2355 <input
2356 type="checkbox"
2357 checked={showOnSidebar}
2358 onChange={(e) => setShowOnSidebar(e.target.checked)}
2359 className="mt-1"
2360 />
2361 <div>
2362 <div className="text-sm font-medium text-gray-900 dark:text-white">
2363 {__(
2364 "Also show a full-width CTA in the single-trip sidebar",
2365 "yatra",
2366 )}
2367 </div>
2368 <div className="text-xs text-gray-500 dark:text-gray-400">
2369 {__(
2370 'Renders an "Ask about this trip on WhatsApp" button next to the booking form, separate from the floating button.',
2371 "yatra",
2372 )}
2373 </div>
2374 </div>
2375 </label>
2376
2377 {/* Preview + shortcode hint */}
2378 {previewUrl && (
2379 <div className="rounded-md border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800/40">
2380 <div className="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">
2381 {__("Preview URL the button will open", "yatra")}
2382 </div>
2383 <a
2384 href={previewUrl}
2385 target="_blank"
2386 rel="noopener noreferrer"
2387 className="text-xs text-blue-600 break-all hover:underline dark:text-blue-400"
2388 >
2389 {previewUrl}
2390 </a>
2391 </div>
2392 )}
2393
2394 <div className="rounded-md border border-blue-200 bg-blue-50/60 p-3 dark:border-blue-700 dark:bg-blue-900/20">
2395 <div className="flex items-start gap-2">
2396 <Info className="mt-0.5 h-4 w-4 shrink-0 text-blue-600 dark:text-blue-400" />
2397 <div className="text-xs text-blue-900 dark:text-blue-200">
2398 {__(
2399 'Drop the button anywhere using the shortcode [yatra_whatsapp_button label="Chat with us"]. Supports label, message, and trip_name attributes.',
2400 "yatra",
2401 )}
2402 </div>
2403 </div>
2404 </div>
2405 </CardContent>
2406 {dirty && (
2407 <CardFooter className="flex justify-end border-t border-gray-100 pt-4 dark:border-gray-700/50">
2408 <Button onClick={save} disabled={saving}>
2409 {saving ? (
2410 <>
2411 <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
2412 {__("Saving…", "yatra")}
2413 </>
2414 ) : (
2415 <>
2416 <Save className="mr-1.5 h-4 w-4" />
2417 {__("Save widget settings", "yatra")}
2418 </>
2419 )}
2420 </Button>
2421 </CardFooter>
2422 )}
2423 </Card>
2424 </div>
2425 );
2426 };
2427
2428 /* -------------------------------------------------------------------------- */
2429 /* Logs */
2430 /* -------------------------------------------------------------------------- */
2431
2432 const LogsList: React.FC = () => {
2433 const [page, setPage] = useState(1);
2434 const [statusFilter, setStatusFilter] = useState<string>("");
2435 const [phoneFilter, setPhoneFilter] = useState<string>("");
2436 // Debounced phone filter so we don't re-query on every keystroke.
2437 const [phoneFilterApplied, setPhoneFilterApplied] = useState<string>("");
2438 const perPage = 20;
2439
2440 React.useEffect(() => {
2441 const t = window.setTimeout(
2442 () => setPhoneFilterApplied(phoneFilter.trim()),
2443 350,
2444 );
2445 return () => window.clearTimeout(t);
2446 }, [phoneFilter]);
2447
2448 // Reset to page 1 whenever a filter changes.
2449 React.useEffect(() => {
2450 setPage(1);
2451 }, [statusFilter, phoneFilterApplied]);
2452
2453 const { data, isLoading } = useQuery({
2454 queryKey: [
2455 "whatsapp-messages",
2456 page,
2457 perPage,
2458 statusFilter,
2459 phoneFilterApplied,
2460 ],
2461 queryFn: () =>
2462 whatsappApi.listMessages({
2463 page,
2464 per_page: perPage,
2465 ...(statusFilter ? { status: statusFilter } : {}),
2466 ...(phoneFilterApplied ? { phone: phoneFilterApplied } : {}),
2467 }),
2468 refetchInterval: 15000,
2469 placeholderData: (prev) => prev, // smoother pagination — keep last page visible while loading
2470 });
2471
2472 const rows = data?.data ?? [];
2473 const totalItems = data?.total ?? 0;
2474 const totalPages = Math.max(1, Math.ceil(totalItems / perPage));
2475
2476 if (isLoading) {
2477 return (
2478 <Card>
2479 <CardContent className="p-12 flex justify-center">
2480 <Loader2 className="h-6 w-6 animate-spin text-gray-400" />
2481 </CardContent>
2482 </Card>
2483 );
2484 }
2485
2486 const hasFilters = !!(statusFilter || phoneFilter);
2487
2488 return (
2489 <div className="space-y-4">
2490 <div>
2491 <h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
2492 <Clock className="w-5 h-5 text-blue-500" />
2493 {__("Message logs", "yatra")}
2494 </h3>
2495 <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
2496 {__(
2497 "Every outbound send and inbound reply, filterable by status or phone. Updated every 15 seconds.",
2498 "yatra",
2499 )}
2500 </p>
2501 </div>
2502
2503 {/* Canonical Yatra filter row — same pattern as Discounts / Bookings */}
2504 <Card>
2505 <CardContent className="p-3">
2506 <div className="flex flex-col lg:flex-row lg:items-center gap-2">
2507 <div className="flex-1 min-w-0">
2508 <Input
2509 type="text"
2510 value={phoneFilter}
2511 onChange={(e) => setPhoneFilter(e.target.value)}
2512 placeholder={__("Search by phone…", "yatra")}
2513 aria-label={__("Search by phone", "yatra")}
2514 className="w-full"
2515 />
2516 </div>
2517 <Select
2518 value={statusFilter}
2519 onChange={(e) => setStatusFilter(e.target.value)}
2520 aria-label={__("Filter by status", "yatra")}
2521 className="w-full lg:w-48 lg:flex-none"
2522 >
2523 <option value="">{__("All statuses", "yatra")}</option>
2524 <option value="queued">{__("Queued", "yatra")}</option>
2525 <option value="sent">{__("Sent", "yatra")}</option>
2526 <option value="delivered">{__("Delivered", "yatra")}</option>
2527 <option value="read">{__("Read", "yatra")}</option>
2528 <option value="failed">{__("Failed", "yatra")}</option>
2529 <option value="received">
2530 {__("Received (inbound)", "yatra")}
2531 </option>
2532 </Select>
2533 {hasFilters && (
2534 <Button
2535 type="button"
2536 variant="outline"
2537 onClick={() => {
2538 setStatusFilter("");
2539 setPhoneFilter("");
2540 }}
2541 className="w-full lg:w-auto"
2542 >
2543 {__("Reset", "yatra")}
2544 </Button>
2545 )}
2546 </div>
2547 </CardContent>
2548 </Card>
2549
2550 <Card>
2551 <CardContent className="p-0">
2552 {rows.length === 0 ? (
2553 <div className="p-8 text-center text-gray-500 dark:text-gray-400">
2554 {totalItems === 0 && !statusFilter && !phoneFilterApplied
2555 ? __("No messages sent yet.", "yatra")
2556 : __("No messages match your filters.", "yatra")}
2557 </div>
2558 ) : (
2559 <Table>
2560 <TableHeader className="bg-gray-50 dark:bg-gray-800/50">
2561 <TableRow>
2562 <TableHead className="w-28">
2563 {__("Direction", "yatra")}
2564 </TableHead>
2565 <TableHead className="w-44">{__("Phone", "yatra")}</TableHead>
2566 <TableHead>{__("Body / Error", "yatra")}</TableHead>
2567 <TableHead className="w-48">
2568 {__("Template", "yatra")}
2569 </TableHead>
2570 <TableHead className="w-28">
2571 {__("Status", "yatra")}
2572 </TableHead>
2573 <TableHead className="w-44 whitespace-nowrap">
2574 {__("Sent", "yatra")}
2575 </TableHead>
2576 </TableRow>
2577 </TableHeader>
2578 <TableBody>
2579 {rows.map((row) => (
2580 <TableRow key={row.id}>
2581 <TableCell>
2582 <Badge
2583 className={
2584 row.direction === "inbound"
2585 ? "bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-400"
2586 : "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
2587 }
2588 >
2589 {row.direction}
2590 </Badge>
2591 </TableCell>
2592 <TableCell className="font-mono text-gray-900 dark:text-white whitespace-nowrap">
2593 {row.phone}
2594 </TableCell>
2595 <TableCell className="text-gray-700 dark:text-gray-200">
2596 <div
2597 className="max-w-md truncate"
2598 title={row.error_message || row.body || ""}
2599 >
2600 {row.error_message || row.body || "—"}
2601 </div>
2602 </TableCell>
2603 <TableCell className="text-gray-500 dark:text-gray-400">
2604 <span
2605 className="truncate block max-w-[12rem]"
2606 title={row.template_key || ""}
2607 >
2608 {row.template_key || "—"}
2609 </span>
2610 </TableCell>
2611 <TableCell>
2612 <Badge
2613 className={
2614 row.status === "sent" ||
2615 row.status === "delivered" ||
2616 row.status === "read"
2617 ? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
2618 : row.status === "failed"
2619 ? "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400"
2620 : "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
2621 }
2622 >
2623 {row.status}
2624 </Badge>
2625 </TableCell>
2626 <TableCell className="text-gray-500 dark:text-gray-400 whitespace-nowrap">
2627 {row.created_at
2628 ? new Date(row.created_at).toLocaleString()
2629 : "—"}
2630 </TableCell>
2631 </TableRow>
2632 ))}
2633 </TableBody>
2634 </Table>
2635 )}
2636 {rows.length > 0 && totalPages > 1 && (
2637 <div className="border-t border-gray-200 px-4 py-3 dark:border-gray-700">
2638 <Pagination
2639 currentPage={page}
2640 totalPages={totalPages}
2641 totalItems={totalItems}
2642 itemsPerPage={perPage}
2643 onPageChange={setPage}
2644 itemName={__("messages", "yatra")}
2645 />
2646 </div>
2647 )}
2648 </CardContent>
2649 </Card>
2650 </div>
2651 );
2652 };
2653
2654 /* -------------------------------------------------------------------------- */
2655 /* Opt-ins */
2656 /* -------------------------------------------------------------------------- */
2657
2658 /**
2659 * GDPR audit view — every phone number that has opted in (or been
2660 * explicitly opted out via inbound STOP keyword). Read-only here;
2661 * mutations happen via the booking form (in) and inbound webhook (out).
2662 *
2663 * Exports to CSV for data-portability requests.
2664 */
2665 const OptInsList: React.FC = () => {
2666 const [search, setSearch] = useState("");
2667 const [filter, setFilter] = useState<"all" | "in" | "out">("all");
2668
2669 const { data, isLoading } = useQuery({
2670 queryKey: ["whatsapp-opt-ins"],
2671 queryFn: () => whatsappApi.listOptIns(),
2672 staleTime: 30 * 1000,
2673 });
2674
2675 // eslint-disable-next-line react-hooks/exhaustive-deps
2676 const rows = data?.data ?? [];
2677
2678 const filtered = useMemo(() => {
2679 return rows.filter((r) => {
2680 if (filter === "in" && !r.opted_in) return false;
2681 if (filter === "out" && r.opted_in) return false;
2682 if (search.trim() !== "") {
2683 const needle = search.trim().toLowerCase();
2684 if (
2685 !r.phone.toLowerCase().includes(needle) &&
2686 !r.source.toLowerCase().includes(needle)
2687 )
2688 return false;
2689 }
2690 return true;
2691 });
2692 }, [rows, filter, search]);
2693
2694 const inCount = useMemo(() => rows.filter((r) => r.opted_in).length, [rows]);
2695 const outCount = rows.length - inCount;
2696
2697 const exportCsv = () => {
2698 // Build CSV client-side to avoid a server round-trip. RFC 4180:
2699 // quote every field, double-quote escapes embedded quotes, CRLF line endings.
2700 const escape = (v: string) => '"' + v.replace(/"/g, '""') + '"';
2701 const lines = [
2702 ["phone", "opted_in", "source", "updated_at"].map(escape).join(","),
2703 ...filtered.map((r) =>
2704 [r.phone, r.opted_in ? "yes" : "no", r.source, r.updated_at]
2705 .map((v) => escape(String(v ?? "")))
2706 .join(","),
2707 ),
2708 ];
2709 const csv = lines.join("\r\n");
2710 const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
2711 const url = URL.createObjectURL(blob);
2712 const a = document.createElement("a");
2713 a.href = url;
2714 a.download = `whatsapp-opt-ins-${todayYmd()}.csv`;
2715 document.body.appendChild(a);
2716 a.click();
2717 document.body.removeChild(a);
2718 URL.revokeObjectURL(url);
2719 };
2720
2721 if (isLoading) {
2722 return (
2723 <Card>
2724 <CardContent className="p-12 flex justify-center">
2725 <Loader2 className="h-6 w-6 animate-spin text-gray-400" />
2726 </CardContent>
2727 </Card>
2728 );
2729 }
2730
2731 return (
2732 <div className="space-y-4">
2733 <div className="flex flex-wrap items-start justify-between gap-3">
2734 <div>
2735 <h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
2736 <Users className="w-5 h-5 text-blue-500" />
2737 {__("Opt-in audit", "yatra")}
2738 </h3>
2739 <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
2740 {__(
2741 "Every phone number that's opted into WhatsApp messaging, plus those that opted out via inbound STOP keyword. Export this list for GDPR / privacy requests.",
2742 "yatra",
2743 )}
2744 </p>
2745 </div>
2746 <Button
2747 type="button"
2748 variant="outline"
2749 onClick={exportCsv}
2750 disabled={filtered.length === 0}
2751 >
2752 <ExternalLink className="mr-1.5 h-4 w-4" />
2753 {__("Export CSV", "yatra")}
2754 </Button>
2755 </div>
2756
2757 {/* Canonical Yatra filter row */}
2758 <Card>
2759 <CardContent className="p-3">
2760 <div className="flex flex-col lg:flex-row lg:items-center gap-2">
2761 <div className="flex-1 min-w-0">
2762 <Input
2763 type="text"
2764 value={search}
2765 onChange={(e) => setSearch(e.target.value)}
2766 placeholder={__("Search by phone or source…", "yatra")}
2767 aria-label={__("Search opt-ins", "yatra")}
2768 className="w-full"
2769 />
2770 </div>
2771 <Select
2772 value={filter}
2773 onChange={(e) =>
2774 setFilter(e.target.value as "all" | "in" | "out")
2775 }
2776 aria-label={__("Filter opt-in status", "yatra")}
2777 className="w-full lg:w-56 lg:flex-none"
2778 >
2779 <option value="all">
2780 {__("All", "yatra")} ({rows.length})
2781 </option>
2782 <option value="in">
2783 {__("Opted in", "yatra")} ({inCount})
2784 </option>
2785 <option value="out">
2786 {__("Opted out", "yatra")} ({outCount})
2787 </option>
2788 </Select>
2789 {(search || filter !== "all") && (
2790 <Button
2791 type="button"
2792 variant="outline"
2793 onClick={() => {
2794 setSearch("");
2795 setFilter("all");
2796 }}
2797 className="w-full lg:w-auto"
2798 >
2799 {__("Reset", "yatra")}
2800 </Button>
2801 )}
2802 </div>
2803 </CardContent>
2804 </Card>
2805
2806 <Card>
2807 <CardContent className="p-0">
2808 {filtered.length === 0 ? (
2809 <div className="p-8 text-center text-gray-500 dark:text-gray-400">
2810 {rows.length === 0
2811 ? __(
2812 "No opt-in records yet. Records appear here when customers tick the WhatsApp opt-in on a booking form, or reply STOP to opt out.",
2813 "yatra",
2814 )
2815 : __("No records match your search.", "yatra")}
2816 </div>
2817 ) : (
2818 <Table>
2819 <TableHeader className="bg-gray-50 dark:bg-gray-800/50">
2820 <TableRow>
2821 <TableHead className="w-44">{__("Phone", "yatra")}</TableHead>
2822 <TableHead className="w-32">
2823 {__("Status", "yatra")}
2824 </TableHead>
2825 <TableHead>{__("Source", "yatra")}</TableHead>
2826 <TableHead className="w-44 whitespace-nowrap">
2827 {__("Updated", "yatra")}
2828 </TableHead>
2829 </TableRow>
2830 </TableHeader>
2831 <TableBody>
2832 {filtered.map((row) => (
2833 <TableRow key={row.phone}>
2834 <TableCell className="font-mono text-gray-900 dark:text-white whitespace-nowrap">
2835 {row.phone}
2836 </TableCell>
2837 <TableCell>
2838 {row.opted_in ? (
2839 <Badge className="bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">
2840 <CheckCircle2 className="mr-1 h-3 w-3" />
2841 {__("Opted in", "yatra")}
2842 </Badge>
2843 ) : (
2844 <Badge className="bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-300">
2845 <XCircle className="mr-1 h-3 w-3" />
2846 {__("Opted out", "yatra")}
2847 </Badge>
2848 )}
2849 </TableCell>
2850 <TableCell className="text-gray-500 dark:text-gray-400">
2851 {row.source || "—"}
2852 </TableCell>
2853 <TableCell className="text-gray-500 dark:text-gray-400 whitespace-nowrap">
2854 {row.updated_at}
2855 </TableCell>
2856 </TableRow>
2857 ))}
2858 </TableBody>
2859 </Table>
2860 )}
2861 </CardContent>
2862 </Card>
2863 </div>
2864 );
2865 };
2866
2867 function extractError(e: any): string {
2868 if (!e) return "Request failed.";
2869 const data = e?.response?.data ?? e?.data ?? null;
2870 if (
2871 data &&
2872 typeof data === "object" &&
2873 typeof (data as any).message === "string"
2874 ) {
2875 return (data as any).message;
2876 }
2877 return e?.message || "Request failed.";
2878 }
2879
2880 export default Whatsapp;
2881