import React, { useState, useEffect } from "react"; import { Card, CardHeader, CardTitle, CardDescription, CardContent, } from "../components/ui/card"; import { Button } from "../components/ui/button"; import { Badge } from "../components/ui/badge"; import { Switch } from "../components/ui/switch"; import { useToast } from "../components/ui/toast"; import { Calendar, CheckCircle, XCircle, RefreshCw, Settings, ExternalLink, ArrowLeft, X, } from "lucide-react"; import { apiService } from "../lib/api-client"; import { __, sprintf } from "../lib/i18n"; interface GoogleCalendarSettings { connected: boolean; calendar_id: string; calendar_name: string; auto_sync: boolean; sync_bookings: boolean; sync_departures: boolean; send_invitations: boolean; reminder_days: number[]; /** Pro 3.0.13+. Absent on an older Pro, which is how we detect support. */ add_guests?: boolean; /** Pro 3.0.13+. Absent on an older Pro. */ combine_bookings?: boolean; last_sync: string | null; } const GoogleCalendar: React.FC = () => { const [settings, setSettings] = useState(null); const [loading, setLoading] = useState(true); const [connecting, setConnecting] = useState(false); const [syncing, setSyncing] = useState(false); const [newReminderDay, setNewReminderDay] = useState(""); const [reminderError, setReminderError] = useState(""); const [resyncing, setResyncing] = useState(false); const [resyncProgress, setResyncProgress] = useState<{ processed: number; total: number; created: number; updated: number; deleted: number; failed: number; } | null>(null); const { showToast } = useToast(); // This dashboard lives under Settings → Integration; the Back button returns // there (and deep-links the Integration section via ?section=integration). const adminUrl = (window as any).yatraAdmin?.adminUrl || "admin.php"; const settingsIntegrationUrl = `${adminUrl}?page=yatra&subpage=settings§ion=integration`; useEffect(() => { fetchSettings(); }, []); const fetchSettings = async () => { try { const response = await apiService.getGoogleCalendarSettings(); setSettings(response.data); } catch (error) { console.error("Failed to fetch settings:", error); } finally { setLoading(false); } }; const handleConnect = async () => { setConnecting(true); try { const data = await apiService.connectGoogleCalendar(); if (data.success && data.data.auth_url) { // Redirect to Google OAuth window.location.href = data.data.auth_url; } else { showToast(data.message || __("Failed to connect"), "error"); } } catch (error) { showToast(__("Failed to connect to Google Calendar"), "error"); } finally { setConnecting(false); } }; const handleDisconnect = async () => { if (!confirm(__("Are you sure you want to disconnect Google Calendar?"))) { return; } try { await apiService.disconnectGoogleCalendar(); showToast(__("Disconnected successfully"), "success"); fetchSettings(); } catch (error) { showToast(__("Failed to disconnect"), "error"); } }; const handleSyncAll = async () => { setSyncing(true); try { const data = await apiService.syncAllGoogleCalendar(); if (data.success) { showToast(__("Sync completed successfully"), "success"); fetchSettings(); } else { showToast(data.message || __("Sync failed"), "error"); } } catch (error) { showToast(__("Failed to sync bookings"), "error"); } finally { setSyncing(false); } }; /** * Full resync: walks every booking, repairing whatever it finds — creating * missing events, re-pushing existing ones, recreating any deleted in Google * and removing events for cancelled bookings. Runs in batches so a large * calendar can't time out, looping until the server reports done. */ const handleResync = async () => { const confirmed = window.confirm( __( "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.", ), ); if (!confirmed) { return; } setResyncing(true); const totals = { processed: 0, total: 0, created: 0, updated: 0, deleted: 0, failed: 0, }; setResyncProgress({ ...totals }); try { let cursor: number | null = 0; // Hard stop: a server that never reports `done` must not spin forever. for (let guard = 0; guard < 2000 && cursor !== null; guard++) { const response: any = await apiService.resyncGoogleCalendar(cursor); const data = response?.data ?? response; if (!response?.success && !data?.success) { throw new Error(response?.message || __("Resync failed")); } totals.processed += Number(data.processed) || 0; totals.created += Number(data.created) || 0; totals.updated += Number(data.updated) || 0; totals.deleted += Number(data.deleted) || 0; totals.failed += Number(data.failed) || 0; if (data.total) { totals.total = Number(data.total) || 0; } setResyncProgress({ ...totals }); // `Number(null)` is 0, so a missing cursor must be checked before // converting — otherwise the loop would restart from the beginning. const next = data.done || data.next_cursor == null ? null : Number(data.next_cursor); // The cursor is a booking id and must strictly advance. Refusing to go // backwards means a bad response ends the run instead of re-walking // the same bookings (and re-calling Google) until the guard trips. cursor = next !== null && next > cursor ? next : null; } showToast( sprintf( /* translators: %1$d created, %2$d updated, %3$d removed, %4$d failed. */ __( "Resync complete — %1$d created, %2$d updated, %3$d removed, %4$d failed.", ), totals.created, totals.updated, totals.deleted, totals.failed, ), totals.failed > 0 ? "warning" : "success", ); fetchSettings(); } catch (error: any) { // The endpoint lives in Yatra Pro. A site that updated the free plugin // first has no route yet, and "404" tells the admin nothing useful. const status = error?.response?.status ?? error?.status; const code = error?.response?.data?.code ?? error?.code; const missingRoute = status === 404 || code === "rest_no_route"; showToast( missingRoute ? __( "Resync needs a newer version of Yatra Pro. Please update Yatra Pro and try again.", ) : error?.message || __("Resync failed"), "error", ); } finally { setResyncing(false); } }; const handleSettingChange = async ( key: keyof GoogleCalendarSettings, value: any, ) => { try { await apiService.updateGoogleCalendarSettings({ [key]: value }); setSettings((prev) => (prev ? { ...prev, [key]: value } : null)); showToast(__("Settings updated"), "success"); } catch (error) { showToast(__("Failed to update settings"), "error"); } }; /** Google rejects an event carrying more than 5 reminder overrides. */ const MAX_REMINDERS = 5; const addReminderDay = () => { const days = Number(newReminderDay); if (!Number.isInteger(days) || days <= 0) { setReminderError(__("Enter a whole number of days, 1 or more.")); return; } const current = settings?.reminder_days ?? []; if (current.includes(days)) { setReminderError(__("That reminder already exists.")); return; } if (current.length >= MAX_REMINDERS) { setReminderError( __("Google allows at most 5 reminders on an event."), ); return; } setReminderError(""); const next = [...current, days].sort((a, b) => b - a); setNewReminderDay(""); handleSettingChange("reminder_days", next); }; const removeReminderDay = (days: number) => { setReminderError(""); handleSettingChange( "reminder_days", (settings?.reminder_days ?? []).filter((d) => d !== days), ); }; if (loading) { return (
); } return (
{/* Back to Settings → Integration */} {__("Back to Settings → Integration")} {/* Header */}

{__("Google Calendar Integration")}

{__( "Automatically sync your bookings and departures to Google Calendar", )}

{/* Connection Status Card */} {__("Connection Status")} {settings?.connected ? ( {__("Connected")} ) : ( {__("Not Connected")} )} {settings?.connected ? __("Your Google Calendar is connected and ready to sync") : __("Connect your Google account to start syncing bookings")} {settings?.connected ? ( <>

{settings.calendar_name || __("Primary Calendar")}

{__("Calendar ID")}: {settings.calendar_id}

{settings.last_sync && (

{__("Last synced")}:{" "} {new Date(settings.last_sync).toLocaleString()}

)}
{resyncProgress && !resyncing && (

{sprintf( /* translators: %1$d created, %2$d updated, %3$d removed, %4$d failed. */ __( "Last resync: %1$d created, %2$d updated, %3$d removed, %4$d failed.", ), resyncProgress.created, resyncProgress.updated, resyncProgress.deleted, resyncProgress.failed, )}

)} ) : ( )}
{/* Sync Settings */} {settings?.connected && ( {__("Sync Settings")} {__("Configure what gets synced to your Google Calendar")}

{__("Automatically sync new bookings and changes")}

handleSettingChange("auto_sync", checked) } />

{__("Create calendar events for new bookings")}

handleSettingChange("sync_bookings", checked) } />

{__("Create calendar events for departures")}

handleSettingChange("sync_departures", checked) } />

{__("Send calendar invitations to customers")}

handleSettingChange("send_invitations", checked) } />
{/* Whether the customer goes on the event at all. Distinct from "Send Invitations", which only governs the invite email: an attendee still receives Google's own reminders and cancellation notices. Rendered only when Yatra Pro actually supports it. On an older Pro the key is missing from the settings payload, and showing a switch that silently saves nothing is worse than not showing it at all. */} {settings.add_guests !== undefined && (

{__( "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.", )}

handleSettingChange("add_guests", checked) } />
)} {/* One event per departure instead of one per booking. Same Pro-support check as above. */} {settings.combine_bookings !== undefined && (

{__( "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.", )}

handleSettingChange("combine_bookings", checked) } />
)} {/* Reminders on the event. The backend already stored these; there was simply no way to change them. */}

{__( "Email reminders added to each calendar event, counted in days before departure. Google allows up to 5.", )}

setNewReminderDay(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addReminderDay(); } }} placeholder={__("Days before")} 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" />
{reminderError && (

{reminderError}

)}
{(settings.reminder_days ?? []).length === 0 ? (

{__("No reminders — Google will use your calendar's defaults.")}

) : ( (settings.reminder_days ?? []).map((days) => ( {days === 1 ? __("1 day before") : `${days} ${__("days before")}`} )) )}
)} {/* Documentation */} {__("Documentation")} {__("Learn more about Google Calendar integration")} {__("View Documentation")}
); }; export default GoogleCalendar;