| @@ -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§ion=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> |