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 +316 -2 3.0.13 → 3.0.17 View file →
@@ -17,10 +17,12 @@
17 17 RefreshCw,
18 18 Settings,
19 19 ExternalLink,
20 20 ArrowLeft,
21 + X,
21 22 } from "lucide-react";
22 23 import { apiService } from "../lib/api-client";
24 +import { __, sprintf } from "../lib/i18n";
23 25
24 26 interface GoogleCalendarSettings {
25 27 connected: boolean;
26 28 calendar_id: string;
@@ -29,8 +31,12 @@
29 31 sync_bookings: boolean;
30 32 sync_departures: boolean;
31 33 send_invitations: boolean;
32 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;
33 39 last_sync: string | null;
34 40 }
35 41
36 42 const GoogleCalendar: React.FC = () => {
@@ -37,8 +43,19 @@
37 43 const [settings, setSettings] = useState<GoogleCalendarSettings | null>(null);
38 44 const [loading, setLoading] = useState(true);
39 45 const [connecting, setConnecting] = useState(false);
40 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);
41 58 const { showToast } = useToast();
42 59 // This dashboard lives under Settings → Integration; the Back button returns
43 60 // there (and deep-links the Integration section via ?section=integration).
44 61 const adminUrl = (window as any).yatraAdmin?.adminUrl || "admin.php";
@@ -43,10 +60,10 @@
43 60 // there (and deep-links the Integration section via ?section=integration).
44 61 const adminUrl = (window as any).yatraAdmin?.adminUrl || "admin.php";
45 62 const settingsIntegrationUrl = `${adminUrl}?page=yatra&subpage=settings&section=integration`;
46 63
47 - const __ = (text: string) => text;
48 64
65 +
49 66 useEffect(() => {
50 67 fetchSettings();
51 68 }, []);
52 69
@@ -110,8 +127,103 @@
110 127 setSyncing(false);
111 128 }
112 129 };
113 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 +
114 226 const handleSettingChange = async (
115 227 key: keyof GoogleCalendarSettings,
116 228 value: any,
117 229 ) => {
@@ -123,8 +235,42 @@
123 235 showToast(__("Failed to update settings"), "error");
124 236 }
125 237 };
126 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 +
127 273 if (loading) {
128 274 return (
129 275 <div className="flex items-center justify-center h-64">
130 276 <RefreshCw className="w-8 h-8 animate-spin text-blue-600" />
@@ -206,9 +352,9 @@
206 352
207 353 <div className="flex gap-3">
208 354 <Button
209 355 onClick={handleSyncAll}
210 - disabled={syncing}
356 + disabled={syncing || resyncing}
211 357 className="flex-1"
212 358 >
213 359 {syncing ? (
214 360 <>
@@ -223,8 +369,36 @@
223 369 )}
224 370 </Button>
225 371 <Button
226 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"
227 401 onClick={handleDisconnect}
228 402 className="text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/10"
229 403 >
230 404 {__("Disconnect")}
@@ -229,8 +403,22 @@
229 403 >
230 404 {__("Disconnect")}
231 405 </Button>
232 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 + )}
233 421 </>
234 422 ) : (
235 423 <Button
236 424 onClick={handleConnect}
@@ -333,8 +521,134 @@
333 521 onCheckedChange={(checked: boolean) =>
334 522 handleSettingChange("send_invitations", checked)
335 523 }
336 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>
337 651 </div>
338 652 </div>
339 653 </CardContent>
340 654 </Card>