PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.6
Yatra – Travel Booking & Tour Operator Software v3.0.6
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.6, at resources/js/pages/Whatsapp.tsx

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