PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.17
Yatra – Travel Booking & Tour Operator Software v3.0.17
3.0.17 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 All 85 releases
← All changes | resources/js/pages/GoogleCalendar.tsx +331 -3 3.0.3 → 3.0.17 View file →
@@ -16,10 +16,13 @@
16 16 XCircle,
17 17 RefreshCw,
18 18 Settings,
19 19 ExternalLink,
20 + ArrowLeft,
21 + X,
20 22 } from "lucide-react";
21 23 import { apiService } from "../lib/api-client";
24 +import { __, sprintf } from "../lib/i18n";
22 25
23 26 interface GoogleCalendarSettings {
24 27 connected: boolean;
25 28 calendar_id: string;
@@ -28,8 +31,12 @@
28 31 sync_bookings: boolean;
29 32 sync_departures: boolean;
30 33 send_invitations: boolean;
31 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;
32 39 last_sync: string | null;
33 40 }
34 41
35 42 const GoogleCalendar: React.FC = () => {
@@ -36,12 +43,27 @@
36 43 const [settings, setSettings] = useState<GoogleCalendarSettings | null>(null);
37 44 const [loading, setLoading] = useState(true);
38 45 const [connecting, setConnecting] = useState(false);
39 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);
40 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`;
41 63
42 - const __ = (text: string) => text;
43 64
65 +
44 66 useEffect(() => {
45 67 fetchSettings();
46 68 }, []);
47 69
@@ -105,8 +127,103 @@
105 127 setSyncing(false);
106 128 }
107 129 };
108 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 +
109 226 const handleSettingChange = async (
110 227 key: keyof GoogleCalendarSettings,
111 228 value: any,
112 229 ) => {
@@ -118,8 +235,42 @@
118 235 showToast(__("Failed to update settings"), "error");
119 236 }
120 237 };
121 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 +
122 273 if (loading) {
123 274 return (
124 275 <div className="flex items-center justify-center h-64">
125 276 <RefreshCw className="w-8 h-8 animate-spin text-blue-600" />
@@ -128,8 +279,17 @@
128 279 }
129 280
130 281 return (
131 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 +
132 292 {/* Header */}
133 293 <div className="flex items-center justify-between">
134 294 <div>
135 295 <h1 className="text-3xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
@@ -192,9 +352,9 @@
192 352
193 353 <div className="flex gap-3">
194 354 <Button
195 355 onClick={handleSyncAll}
196 - disabled={syncing}
356 + disabled={syncing || resyncing}
197 357 className="flex-1"
198 358 >
199 359 {syncing ? (
200 360 <>
@@ -209,8 +369,36 @@
209 369 )}
210 370 </Button>
211 371 <Button
212 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"
213 401 onClick={handleDisconnect}
214 402 className="text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/10"
215 403 >
216 404 {__("Disconnect")}
@@ -215,8 +403,22 @@
215 403 >
216 404 {__("Disconnect")}
217 405 </Button>
218 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 + )}
219 421 </>
220 422 ) : (
221 423 <Button
222 424 onClick={handleConnect}
@@ -320,8 +522,134 @@
320 522 handleSettingChange("send_invitations", checked)
321 523 }
322 524 />
323 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>
324 652 </div>
325 653 </CardContent>
326 654 </Card>
327 655 )}
@@ -335,9 +663,9 @@
335 663 </CardDescription>
336 664 </CardHeader>
337 665 <CardContent>
338 666 <a
339 - href="https://docs.yatra.com/modules/google-calendar"
667 + href="https://docs.wpyatra.com/modules/google-calendar"
340 668 target="_blank"
341 669 rel="noopener noreferrer"
342 670 className="inline-flex items-center gap-2 text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
343 671 >