PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.16
Yatra – Travel Booking & Tour Operator Software v3.0.16
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 / GoogleCalendar.tsx

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

682 lines 24.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import React, { useState, useEffect } from "react";
2 import {
3 Card,
4 CardHeader,
5 CardTitle,
6 CardDescription,
7 CardContent,
8 } from "../components/ui/card";
9 import { Button } from "../components/ui/button";
10 import { Badge } from "../components/ui/badge";
11 import { Switch } from "../components/ui/switch";
12 import { useToast } from "../components/ui/toast";
13 import {
14 Calendar,
15 CheckCircle,
16 XCircle,
17 RefreshCw,
18 Settings,
19 ExternalLink,
20 ArrowLeft,
21 X,
22 } from "lucide-react";
23 import { apiService } from "../lib/api-client";
24 import { __, sprintf } from "../lib/i18n";
25
26 interface GoogleCalendarSettings {
27 connected: boolean;
28 calendar_id: string;
29 calendar_name: string;
30 auto_sync: boolean;
31 sync_bookings: boolean;
32 sync_departures: boolean;
33 send_invitations: boolean;
34 reminder_days: number[];
35 /** Pro 3.0.13+. Absent on an older Pro, which is how we detect support. */
36 add_guests?: boolean;
37 /** Pro 3.0.13+. Absent on an older Pro. */
38 combine_bookings?: boolean;
39 last_sync: string | null;
40 }
41
42 const GoogleCalendar: React.FC = () => {
43 const [settings, setSettings] = useState<GoogleCalendarSettings | null>(null);
44 const [loading, setLoading] = useState(true);
45 const [connecting, setConnecting] = useState(false);
46 const [syncing, setSyncing] = useState(false);
47 const [newReminderDay, setNewReminderDay] = useState("");
48 const [reminderError, setReminderError] = useState("");
49 const [resyncing, setResyncing] = useState(false);
50 const [resyncProgress, setResyncProgress] = useState<{
51 processed: number;
52 total: number;
53 created: number;
54 updated: number;
55 deleted: number;
56 failed: number;
57 } | null>(null);
58 const { showToast } = useToast();
59 // This dashboard lives under Settings → Integration; the Back button returns
60 // there (and deep-links the Integration section via ?section=integration).
61 const adminUrl = (window as any).yatraAdmin?.adminUrl || "admin.php";
62 const settingsIntegrationUrl = `${adminUrl}?page=yatra&subpage=settings&section=integration`;
63
64
65
66 useEffect(() => {
67 fetchSettings();
68 }, []);
69
70 const fetchSettings = async () => {
71 try {
72 const response = await apiService.getGoogleCalendarSettings();
73 setSettings(response.data);
74 } catch (error) {
75 console.error("Failed to fetch settings:", error);
76 } finally {
77 setLoading(false);
78 }
79 };
80
81 const handleConnect = async () => {
82 setConnecting(true);
83 try {
84 const data = await apiService.connectGoogleCalendar();
85
86 if (data.success && data.data.auth_url) {
87 // Redirect to Google OAuth
88 window.location.href = data.data.auth_url;
89 } else {
90 showToast(data.message || __("Failed to connect"), "error");
91 }
92 } catch (error) {
93 showToast(__("Failed to connect to Google Calendar"), "error");
94 } finally {
95 setConnecting(false);
96 }
97 };
98
99 const handleDisconnect = async () => {
100 if (!confirm(__("Are you sure you want to disconnect Google Calendar?"))) {
101 return;
102 }
103
104 try {
105 await apiService.disconnectGoogleCalendar();
106 showToast(__("Disconnected successfully"), "success");
107 fetchSettings();
108 } catch (error) {
109 showToast(__("Failed to disconnect"), "error");
110 }
111 };
112
113 const handleSyncAll = async () => {
114 setSyncing(true);
115 try {
116 const data = await apiService.syncAllGoogleCalendar();
117
118 if (data.success) {
119 showToast(__("Sync completed successfully"), "success");
120 fetchSettings();
121 } else {
122 showToast(data.message || __("Sync failed"), "error");
123 }
124 } catch (error) {
125 showToast(__("Failed to sync bookings"), "error");
126 } finally {
127 setSyncing(false);
128 }
129 };
130
131 /**
132 * Full resync: walks every booking, repairing whatever it finds — creating
133 * missing events, re-pushing existing ones, recreating any deleted in Google
134 * and removing events for cancelled bookings. Runs in batches so a large
135 * calendar can't time out, looping until the server reports done.
136 */
137 const handleResync = async () => {
138 const confirmed = window.confirm(
139 __(
140 "Resync every booking with Google Calendar? Existing events are updated in place, missing ones are recreated, and events for cancelled bookings are removed. This can take a while on a large calendar.",
141 ),
142 );
143 if (!confirmed) {
144 return;
145 }
146
147 setResyncing(true);
148 const totals = {
149 processed: 0,
150 total: 0,
151 created: 0,
152 updated: 0,
153 deleted: 0,
154 failed: 0,
155 };
156 setResyncProgress({ ...totals });
157
158 try {
159 let cursor: number | null = 0;
160 // Hard stop: a server that never reports `done` must not spin forever.
161 for (let guard = 0; guard < 2000 && cursor !== null; guard++) {
162 const response: any = await apiService.resyncGoogleCalendar(cursor);
163 const data = response?.data ?? response;
164
165 if (!response?.success && !data?.success) {
166 throw new Error(response?.message || __("Resync failed"));
167 }
168
169 totals.processed += Number(data.processed) || 0;
170 totals.created += Number(data.created) || 0;
171 totals.updated += Number(data.updated) || 0;
172 totals.deleted += Number(data.deleted) || 0;
173 totals.failed += Number(data.failed) || 0;
174 if (data.total) {
175 totals.total = Number(data.total) || 0;
176 }
177 setResyncProgress({ ...totals });
178
179 // `Number(null)` is 0, so a missing cursor must be checked before
180 // converting — otherwise the loop would restart from the beginning.
181 const next =
182 data.done || data.next_cursor == null
183 ? null
184 : Number(data.next_cursor);
185
186 // The cursor is a booking id and must strictly advance. Refusing to go
187 // backwards means a bad response ends the run instead of re-walking
188 // the same bookings (and re-calling Google) until the guard trips.
189 cursor = next !== null && next > cursor ? next : null;
190 }
191
192 showToast(
193 sprintf(
194 /* translators: %1$d created, %2$d updated, %3$d removed, %4$d failed. */
195 __(
196 "Resync complete — %1$d created, %2$d updated, %3$d removed, %4$d failed.",
197 ),
198 totals.created,
199 totals.updated,
200 totals.deleted,
201 totals.failed,
202 ),
203 totals.failed > 0 ? "warning" : "success",
204 );
205 fetchSettings();
206 } catch (error: any) {
207 // The endpoint lives in Yatra Pro. A site that updated the free plugin
208 // first has no route yet, and "404" tells the admin nothing useful.
209 const status = error?.response?.status ?? error?.status;
210 const code = error?.response?.data?.code ?? error?.code;
211 const missingRoute = status === 404 || code === "rest_no_route";
212
213 showToast(
214 missingRoute
215 ? __(
216 "Resync needs a newer version of Yatra Pro. Please update Yatra Pro and try again.",
217 )
218 : error?.message || __("Resync failed"),
219 "error",
220 );
221 } finally {
222 setResyncing(false);
223 }
224 };
225
226 const handleSettingChange = async (
227 key: keyof GoogleCalendarSettings,
228 value: any,
229 ) => {
230 try {
231 await apiService.updateGoogleCalendarSettings({ [key]: value });
232 setSettings((prev) => (prev ? { ...prev, [key]: value } : null));
233 showToast(__("Settings updated"), "success");
234 } catch (error) {
235 showToast(__("Failed to update settings"), "error");
236 }
237 };
238
239 /** Google rejects an event carrying more than 5 reminder overrides. */
240 const MAX_REMINDERS = 5;
241
242 const addReminderDay = () => {
243 const days = Number(newReminderDay);
244 if (!Number.isInteger(days) || days <= 0) {
245 setReminderError(__("Enter a whole number of days, 1 or more."));
246 return;
247 }
248 const current = settings?.reminder_days ?? [];
249 if (current.includes(days)) {
250 setReminderError(__("That reminder already exists."));
251 return;
252 }
253 if (current.length >= MAX_REMINDERS) {
254 setReminderError(
255 __("Google allows at most 5 reminders on an event."),
256 );
257 return;
258 }
259 setReminderError("");
260 const next = [...current, days].sort((a, b) => b - a);
261 setNewReminderDay("");
262 handleSettingChange("reminder_days", next);
263 };
264
265 const removeReminderDay = (days: number) => {
266 setReminderError("");
267 handleSettingChange(
268 "reminder_days",
269 (settings?.reminder_days ?? []).filter((d) => d !== days),
270 );
271 };
272
273 if (loading) {
274 return (
275 <div className="flex items-center justify-center h-64">
276 <RefreshCw className="w-8 h-8 animate-spin text-blue-600" />
277 </div>
278 );
279 }
280
281 return (
282 <div className="space-y-6 p-6">
283 {/* Back to Settings → Integration */}
284 <a
285 href={settingsIntegrationUrl}
286 className="inline-flex items-center gap-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
287 >
288 <ArrowLeft className="w-4 h-4" />
289 {__("Back to Settings → Integration")}
290 </a>
291
292 {/* Header */}
293 <div className="flex items-center justify-between">
294 <div>
295 <h1 className="text-3xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
296 <Calendar className="w-8 h-8 text-blue-600" />
297 {__("Google Calendar Integration")}
298 </h1>
299 <p className="text-gray-600 dark:text-gray-400 mt-2">
300 {__(
301 "Automatically sync your bookings and departures to Google Calendar",
302 )}
303 </p>
304 </div>
305 </div>
306
307 {/* Connection Status Card */}
308 <Card>
309 <CardHeader>
310 <CardTitle className="flex items-center justify-between">
311 <span>{__("Connection Status")}</span>
312 {settings?.connected ? (
313 <Badge className="bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400">
314 <CheckCircle className="w-4 h-4 mr-1" />
315 {__("Connected")}
316 </Badge>
317 ) : (
318 <Badge className="bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300">
319 <XCircle className="w-4 h-4 mr-1" />
320 {__("Not Connected")}
321 </Badge>
322 )}
323 </CardTitle>
324 <CardDescription>
325 {settings?.connected
326 ? __("Your Google Calendar is connected and ready to sync")
327 : __("Connect your Google account to start syncing bookings")}
328 </CardDescription>
329 </CardHeader>
330 <CardContent className="space-y-4">
331 {settings?.connected ? (
332 <>
333 <div className="bg-blue-50 dark:bg-blue-900/10 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
334 <div className="flex items-start gap-3">
335 <Calendar className="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5" />
336 <div className="flex-1">
337 <p className="font-medium text-gray-900 dark:text-white">
338 {settings.calendar_name || __("Primary Calendar")}
339 </p>
340 <p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
341 {__("Calendar ID")}: {settings.calendar_id}
342 </p>
343 {settings.last_sync && (
344 <p className="text-xs text-gray-500 dark:text-gray-500 mt-2">
345 {__("Last synced")}:{" "}
346 {new Date(settings.last_sync).toLocaleString()}
347 </p>
348 )}
349 </div>
350 </div>
351 </div>
352
353 <div className="flex gap-3">
354 <Button
355 onClick={handleSyncAll}
356 disabled={syncing || resyncing}
357 className="flex-1"
358 >
359 {syncing ? (
360 <>
361 <RefreshCw className="w-4 h-4 mr-2 animate-spin" />
362 {__("Syncing...")}
363 </>
364 ) : (
365 <>
366 <RefreshCw className="w-4 h-4 mr-2" />
367 {__("Sync All Bookings")}
368 </>
369 )}
370 </Button>
371 <Button
372 variant="outline"
373 onClick={handleResync}
374 disabled={resyncing || syncing}
375 className="flex-1"
376 title={__(
377 "Re-push every booking: updates existing events, recreates missing ones and removes events for cancelled bookings.",
378 )}
379 >
380 {resyncing ? (
381 <>
382 <RefreshCw className="w-4 h-4 mr-2 animate-spin" />
383 {resyncProgress && resyncProgress.total > 0
384 ? sprintf(
385 /* translators: %1$d: bookings processed so far, %2$d: total bookings. */
386 __("Resyncing %1$d/%2$d…"),
387 resyncProgress.processed,
388 resyncProgress.total,
389 )
390 : __("Resyncing…")}
391 </>
392 ) : (
393 <>
394 <RefreshCw className="w-4 h-4 mr-2" />
395 {__("Resync Everything")}
396 </>
397 )}
398 </Button>
399 <Button
400 variant="outline"
401 onClick={handleDisconnect}
402 className="text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/10"
403 >
404 {__("Disconnect")}
405 </Button>
406 </div>
407 {resyncProgress && !resyncing && (
408 <p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
409 {sprintf(
410 /* translators: %1$d created, %2$d updated, %3$d removed, %4$d failed. */
411 __(
412 "Last resync: %1$d created, %2$d updated, %3$d removed, %4$d failed.",
413 ),
414 resyncProgress.created,
415 resyncProgress.updated,
416 resyncProgress.deleted,
417 resyncProgress.failed,
418 )}
419 </p>
420 )}
421 </>
422 ) : (
423 <Button
424 onClick={handleConnect}
425 disabled={connecting}
426 className="w-full"
427 size="lg"
428 >
429 {connecting ? (
430 <>
431 <RefreshCw className="w-5 h-5 mr-2 animate-spin" />
432 {__("Connecting...")}
433 </>
434 ) : (
435 <>
436 <Calendar className="w-5 h-5 mr-2" />
437 {__("Connect Google Calendar")}
438 </>
439 )}
440 </Button>
441 )}
442 </CardContent>
443 </Card>
444
445 {/* Sync Settings */}
446 {settings?.connected && (
447 <Card>
448 <CardHeader>
449 <CardTitle className="flex items-center gap-2">
450 <Settings className="w-5 h-5" />
451 {__("Sync Settings")}
452 </CardTitle>
453 <CardDescription>
454 {__("Configure what gets synced to your Google Calendar")}
455 </CardDescription>
456 </CardHeader>
457 <CardContent className="space-y-6">
458 <div className="space-y-4">
459 <div className="flex items-center justify-between">
460 <div className="flex-1">
461 <label className="font-medium text-gray-900 dark:text-white">
462 {__("Auto Sync")}
463 </label>
464 <p className="text-sm text-gray-600 dark:text-gray-400">
465 {__("Automatically sync new bookings and changes")}
466 </p>
467 </div>
468 <Switch
469 checked={settings.auto_sync}
470 onCheckedChange={(checked: boolean) =>
471 handleSettingChange("auto_sync", checked)
472 }
473 />
474 </div>
475
476 <div className="flex items-center justify-between">
477 <div className="flex-1">
478 <label className="font-medium text-gray-900 dark:text-white">
479 {__("Sync Bookings")}
480 </label>
481 <p className="text-sm text-gray-600 dark:text-gray-400">
482 {__("Create calendar events for new bookings")}
483 </p>
484 </div>
485 <Switch
486 checked={settings.sync_bookings}
487 onCheckedChange={(checked: boolean) =>
488 handleSettingChange("sync_bookings", checked)
489 }
490 />
491 </div>
492
493 <div className="flex items-center justify-between">
494 <div className="flex-1">
495 <label className="font-medium text-gray-900 dark:text-white">
496 {__("Sync Departures")}
497 </label>
498 <p className="text-sm text-gray-600 dark:text-gray-400">
499 {__("Create calendar events for departures")}
500 </p>
501 </div>
502 <Switch
503 checked={settings.sync_departures}
504 onCheckedChange={(checked: boolean) =>
505 handleSettingChange("sync_departures", checked)
506 }
507 />
508 </div>
509
510 <div className="flex items-center justify-between">
511 <div className="flex-1">
512 <label className="font-medium text-gray-900 dark:text-white">
513 {__("Send Invitations")}
514 </label>
515 <p className="text-sm text-gray-600 dark:text-gray-400">
516 {__("Send calendar invitations to customers")}
517 </p>
518 </div>
519 <Switch
520 checked={settings.send_invitations}
521 onCheckedChange={(checked: boolean) =>
522 handleSettingChange("send_invitations", checked)
523 }
524 />
525 </div>
526
527 {/* Whether the customer goes on the event at all. Distinct from
528 "Send Invitations", which only governs the invite email:
529 an attendee still receives Google's own reminders and
530 cancellation notices.
531
532 Rendered only when Yatra Pro actually supports it. On an older
533 Pro the key is missing from the settings payload, and showing a
534 switch that silently saves nothing is worse than not showing
535 it at all. */}
536 {settings.add_guests !== undefined && (
537 <div className="flex items-center justify-between">
538 <div className="flex-1 pr-4">
539 <label className="font-medium text-gray-900 dark:text-white">
540 {__("Add customers as guests")}
541 </label>
542 <p className="text-sm text-gray-600 dark:text-gray-400">
543 {__(
544 "Puts the customer's email on the calendar event. Turn this off to keep their address off the event entirely, so Google sends them no reminders or cancellation emails.",
545 )}
546 </p>
547 </div>
548 <Switch
549 checked={settings.add_guests}
550 onCheckedChange={(checked: boolean) =>
551 handleSettingChange("add_guests", checked)
552 }
553 />
554 </div>
555 )}
556
557 {/* One event per departure instead of one per booking.
558 Same Pro-support check as above. */}
559 {settings.combine_bookings !== undefined && (
560 <div className="flex items-center justify-between">
561 <div className="flex-1 pr-4">
562 <label className="font-medium text-gray-900 dark:text-white">
563 {__("One event per departure")}
564 </label>
565 <p className="text-sm text-gray-600 dark:text-gray-400">
566 {__(
567 "Puts every booking on the same departure into a single calendar event, with each booking's details listed in the description. Customers are never added as guests on a shared event, so they cannot see each other's addresses.",
568 )}
569 </p>
570 </div>
571 <Switch
572 checked={settings.combine_bookings}
573 onCheckedChange={(checked: boolean) =>
574 handleSettingChange("combine_bookings", checked)
575 }
576 />
577 </div>
578 )}
579
580 {/* Reminders on the event. The backend already stored these; there
581 was simply no way to change them. */}
582 <div className="pt-4 border-t border-gray-200 dark:border-gray-700">
583 <label className="font-medium text-gray-900 dark:text-white">
584 {__("Event reminders")}
585 </label>
586 <p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
587 {__(
588 "Email reminders added to each calendar event, counted in days before departure. Google allows up to 5.",
589 )}
590 </p>
591 <div className="flex gap-2 mb-2">
592 <input
593 type="number"
594 min="1"
595 step="1"
596 value={newReminderDay}
597 onChange={(e) => setNewReminderDay(e.target.value)}
598 onKeyDown={(e) => {
599 if (e.key === "Enter") {
600 e.preventDefault();
601 addReminderDay();
602 }
603 }}
604 placeholder={__("Days before")}
605 className="w-40 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-white"
606 />
607 <Button
608 type="button"
609 variant="outline"
610 onClick={addReminderDay}
611 disabled={
612 !newReminderDay ||
613 (settings.reminder_days?.length ?? 0) >= MAX_REMINDERS
614 }
615 >
616 {__("Add")}
617 </Button>
618 </div>
619 {reminderError && (
620 <p className="text-xs text-red-600 dark:text-red-400 mb-2">
621 {reminderError}
622 </p>
623 )}
624 <div className="flex flex-wrap gap-2">
625 {(settings.reminder_days ?? []).length === 0 ? (
626 <p className="text-sm text-gray-500 dark:text-gray-400">
627 {__("No reminders — Google will use your calendar's defaults.")}
628 </p>
629 ) : (
630 (settings.reminder_days ?? []).map((days) => (
631 <Badge
632 key={days}
633 variant="outline"
634 className="flex items-center gap-1"
635 >
636 {days === 1
637 ? __("1 day before")
638 : `${days} ${__("days before")}`}
639 <button
640 type="button"
641 onClick={() => removeReminderDay(days)}
642 className="ml-1 hover:text-red-500"
643 aria-label={__("Remove reminder")}
644 >
645 <X className="w-3 h-3" />
646 </button>
647 </Badge>
648 ))
649 )}
650 </div>
651 </div>
652 </div>
653 </CardContent>
654 </Card>
655 )}
656
657 {/* Documentation */}
658 <Card>
659 <CardHeader>
660 <CardTitle>{__("Documentation")}</CardTitle>
661 <CardDescription>
662 {__("Learn more about Google Calendar integration")}
663 </CardDescription>
664 </CardHeader>
665 <CardContent>
666 <a
667 href="https://docs.wpyatra.com/modules/google-calendar"
668 target="_blank"
669 rel="noopener noreferrer"
670 className="inline-flex items-center gap-2 text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
671 >
672 {__("View Documentation")}
673 <ExternalLink className="w-4 h-4" />
674 </a>
675 </CardContent>
676 </Card>
677 </div>
678 );
679 };
680
681 export default GoogleCalendar;
682