PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.10
Yatra – Travel Booking & Tour Operator Software v3.0.10
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 2.0.10 All 83 releases
yatra / resources / js / pages / AccountPage.tsx

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

699 lines 23.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import React, { useMemo, useState } from "react";
2 import { useQuery } from "@tanstack/react-query";
3 import { apiClient } from "../lib/api-client";
4 import { API_ENDPOINTS } from "../lib/api-endpoints";
5 import { __ } from "../lib/i18n";
6 import { toDateValue } from "../lib/dateFormat";
7 import {
8 LayoutDashboard,
9 Calendar,
10 CreditCard,
11 User,
12 FileText,
13 LogOut,
14 ShieldCheck,
15 Heart,
16 Package,
17 DollarSign,
18 } from "lucide-react";
19 import type {
20 Section,
21 Booking,
22 Payment,
23 TravelDocument,
24 CustomerProfile,
25 } from "./account/types";
26 import {
27 formatDate,
28 getYatraAccountPageGlobals,
29 phoneToTelHref,
30 currency as formatAccountCurrency,
31 } from "./account/utils";
32 import Dashboard from "./account/Dashboard";
33 import Bookings from "./account/Bookings";
34 import Payments from "./account/Payments";
35 import Documents from "./account/Documents";
36 import Profile from "./account/Profile";
37 import SavedTrips from "./account/SavedTrips";
38 import { useToast } from "../components/ui/toast";
39
40 const navigation: Array<{
41 id: Section;
42 label: string;
43 icon: React.ElementType;
44 }> = [
45 { id: "dashboard", label: __("Dashboard", "yatra"), icon: LayoutDashboard },
46 { id: "bookings", label: __("Bookings", "yatra"), icon: Calendar },
47 { id: "payments", label: __("Payments", "yatra"), icon: CreditCard },
48 { id: "documents", label: __("Documents", "yatra"), icon: FileText },
49 { id: "saved-trips", label: __("Saved Trips", "yatra"), icon: Heart },
50 { id: "profile", label: __("Profile", "yatra"), icon: User },
51 ];
52
53 const AccountPage: React.FC = () => {
54 const accountShell = useMemo(() => getYatraAccountPageGlobals(), []);
55 const { showToast } = useToast();
56
57 const accountNavigation = React.useMemo(() => {
58 const wl =
59 typeof window !== "undefined" &&
60 !!(
61 window as unknown as {
62 yatraAccountPage?: { wishlistEnabled?: boolean };
63 }
64 ).yatraAccountPage?.wishlistEnabled;
65 return navigation.filter((n) => n.id !== "saved-trips" || wl);
66 }, []);
67
68 // Track URL changes
69 const [urlKey, setUrlKey] = useState(0);
70
71 React.useEffect(() => {
72 const handleLocationChange = () => {
73 setUrlKey((prev) => prev + 1);
74 };
75
76 // Listen for popstate (back/forward button)
77 window.addEventListener("popstate", handleLocationChange);
78
79 // Also check periodically (fallback for direct navigation)
80 const interval = setInterval(() => {
81 const currentSearch = window.location.search;
82 if (currentSearch !== (window as any).__lastAccountSearch) {
83 (window as any).__lastAccountSearch = currentSearch;
84 handleLocationChange();
85 }
86 }, 100);
87
88 return () => {
89 window.removeEventListener("popstate", handleLocationChange);
90 clearInterval(interval);
91 };
92 }, []);
93
94 // Get section from URL parameter, localStorage, or default to 'dashboard'
95 const getSectionFromUrl = (): Section => {
96 if (typeof window !== "undefined") {
97 const wl = !!(
98 window as unknown as {
99 yatraAccountPage?: { wishlistEnabled?: boolean };
100 }
101 ).yatraAccountPage?.wishlistEnabled;
102 const params = new URLSearchParams(window.location.search);
103 const tab = params.get("tab");
104 if (tab === "saved-trips" && !wl) {
105 return "dashboard";
106 }
107 if (
108 tab &&
109 [
110 "dashboard",
111 "bookings",
112 "payments",
113 "documents",
114 "profile",
115 "saved-trips",
116 ].includes(tab)
117 ) {
118 return tab as Section;
119 }
120 // Fallback to localStorage
121 const saved = localStorage.getItem("yatra-account-active-section");
122 const wlSaved = !!(
123 window as unknown as {
124 yatraAccountPage?: { wishlistEnabled?: boolean };
125 }
126 ).yatraAccountPage?.wishlistEnabled;
127 if (saved === "saved-trips" && !wlSaved) {
128 return "dashboard";
129 }
130 if (
131 saved &&
132 [
133 "dashboard",
134 "bookings",
135 "payments",
136 "documents",
137 "profile",
138 "saved-trips",
139 ].includes(saved)
140 ) {
141 return saved as Section;
142 }
143 }
144 return "dashboard";
145 };
146
147 const [section, setSection] = useState<Section>(getSectionFromUrl);
148
149 // Cross-section booking selection. Lifted here from Bookings.tsx so
150 // Dashboard's upcoming-trip cards can deep-link straight into the
151 // booking detail screen — click on the dashboard card sets this id
152 // AND switches to the "bookings" section; the Bookings component
153 // honours `initialBookingId` on mount/update to open the detail view.
154 const [pendingBookingId, setPendingBookingId] = useState<number | null>(null);
155
156 // Update section when URL changes
157 React.useEffect(() => {
158 const newSection = getSectionFromUrl();
159 setSection(newSection);
160 }, [urlKey]);
161
162 // Surface the result of an email-change confirmation. The confirmation link
163 // (from WordPress-style verification email) redirects here with
164 // ?email_change=success|error; show feedback, open the Profile tab, then
165 // strip the param so a refresh doesn't repeat the toast.
166 React.useEffect(() => {
167 if (typeof window === "undefined") {
168 return;
169 }
170 const params = new URLSearchParams(window.location.search);
171 const outcome = params.get("email_change");
172 if (outcome !== "success" && outcome !== "error") {
173 return;
174 }
175 if (outcome === "success") {
176 showToast(__("Your email address has been updated.", "yatra"), "success");
177 setSection("profile");
178 } else {
179 showToast(
180 __(
181 "This email confirmation link is invalid or has expired.",
182 "yatra",
183 ),
184 "error",
185 );
186 }
187 params.delete("email_change");
188 const query = params.toString();
189 const cleanUrl = `${window.location.pathname}${query ? `?${query}` : ""}`;
190 window.history.replaceState({}, "", cleanUrl);
191 (window as any).__lastAccountSearch = window.location.search;
192 // Run once on mount.
193 // eslint-disable-next-line react-hooks/exhaustive-deps
194 }, []);
195
196 // Update URL and localStorage when section changes
197 const handleSectionChange = (newSection: Section) => {
198 setSection(newSection);
199
200 if (typeof window !== "undefined") {
201 // Update URL
202 const urlParams = new URLSearchParams(window.location.search);
203 urlParams.set("tab", newSection);
204 const newUrl = `${window.location.pathname}?${urlParams.toString()}`;
205 window.history.pushState({}, "", newUrl);
206
207 // Save to localStorage
208 localStorage.setItem("yatra-account-active-section", newSection);
209
210 // Trigger URL change event
211 window.dispatchEvent(new PopStateEvent("popstate"));
212 }
213 };
214
215 // Open a specific booking from anywhere (currently used by the
216 // Dashboard's "Upcoming Trips" cards). Switches to the bookings tab
217 // and seeds the id so Bookings opens straight into the detail view.
218 const handleBookingOpen = (bookingId: number) => {
219 setPendingBookingId(bookingId);
220 handleSectionChange("bookings");
221 };
222
223 // State moved to individual components
224
225 const unwrapArrayResponse = (response: any): any[] => {
226 if (!response) return [];
227
228 // Common WP pattern: { success: true, data: [...] }
229 if (
230 typeof response === "object" &&
231 response.success === true &&
232 Array.isArray(response.data)
233 ) {
234 return response.data;
235 }
236
237 // Nested wrapper pattern: { data: { success: true, data: [...] } }
238 if (
239 typeof response === "object" &&
240 response.data &&
241 typeof response.data === "object"
242 ) {
243 const inner = response.data;
244 if (inner.success === true && Array.isArray(inner.data)) {
245 return inner.data;
246 }
247 if (Array.isArray(inner)) {
248 return inner;
249 }
250 }
251
252 // Direct array
253 if (Array.isArray(response)) {
254 return response;
255 }
256
257 // Some endpoints may return {data: [...]} without success
258 if (typeof response === "object" && Array.isArray(response.data)) {
259 return response.data;
260 }
261
262 return [];
263 };
264
265 const { data: profile, isLoading: isLoadingProfile } =
266 useQuery<CustomerProfile | null>({
267 queryKey: ["account-profile"],
268 queryFn: async () => {
269 try {
270 const response = await apiClient.get(API_ENDPOINTS.CUSTOMER_ME);
271 const raw =
272 response &&
273 typeof response === "object" &&
274 "data" in response &&
275 response.data &&
276 typeof response.data === "object"
277 ? response.data
278 : response;
279 if (
280 !raw ||
281 typeof raw !== "object" ||
282 (!("id" in raw) && !("user_id" in raw) && !("email" in raw))
283 ) {
284 return null;
285 }
286 const row = raw as CustomerProfile & { created_at?: string };
287 const fromParts = [row.first_name, row.last_name]
288 .filter(Boolean)
289 .join(" ")
290 .trim();
291 const name =
292 (typeof row.name === "string" && row.name.trim()) ||
293 fromParts ||
294 (typeof row.email === "string"
295 ? row.email.split("@")[0] || ""
296 : "") ||
297 "";
298 return {
299 ...row,
300 name,
301 registered_at: row.registered_at || row.created_at || "",
302 } as CustomerProfile;
303 } catch (error) {
304 console.error("Error fetching profile:", error);
305 return null;
306 }
307 },
308 refetchOnMount: "always",
309 });
310
311 const displayProfile = profile;
312
313 // Fetch bookings
314 const { data: bookings = [] } = useQuery<Booking[]>({
315 queryKey: ["account-bookings"],
316 queryFn: async () => {
317 try {
318 const response = await apiClient.get(
319 API_ENDPOINTS.CUSTOMER_MY_BOOKINGS,
320 );
321 return unwrapArrayResponse(response) as Booking[];
322 } catch (error) {
323 console.error("Error fetching bookings:", error);
324 return [];
325 }
326 },
327 refetchOnMount: "always",
328 });
329
330 // Fetch payments
331 const { data: payments = [] } = useQuery<Payment[]>({
332 queryKey: ["account-payments"],
333 queryFn: async () => {
334 try {
335 const response = await apiClient.get(
336 API_ENDPOINTS.CUSTOMER_MY_PAYMENTS,
337 );
338 return unwrapArrayResponse(response) as Payment[];
339 } catch (error) {
340 console.error("Error fetching payments:", error);
341 return [];
342 }
343 },
344 refetchOnMount: "always",
345 });
346
347 // Fetch documents
348 const { data: documents = [] } = useQuery<TravelDocument[]>({
349 queryKey: ["account-documents"],
350 queryFn: async () => {
351 try {
352 const response = await apiClient.get(
353 API_ENDPOINTS.CUSTOMER_MY_DOCUMENTS,
354 );
355 return unwrapArrayResponse(response) as TravelDocument[];
356 } catch (error) {
357 console.error("Error fetching documents:", error);
358 return [];
359 }
360 },
361 refetchOnMount: "always",
362 });
363
364 // Notifications (empty for now)
365 const notifications: any[] = [];
366
367 // Fetch saved trips
368 const wishlistEnabled =
369 typeof window !== "undefined" &&
370 !!(
371 window as unknown as { yatraAccountPage?: { wishlistEnabled?: boolean } }
372 ).yatraAccountPage?.wishlistEnabled;
373
374 const { data: savedTripsData, isLoading: isLoadingSavedTrips } =
375 useQuery<any>({
376 queryKey: ["account-saved-trips"],
377 enabled: wishlistEnabled,
378 queryFn: async () => {
379 try {
380 const response = await apiClient.get(API_ENDPOINTS.SAVED_TRIPS);
381 // WordPress REST API returns: {success: true, data: [...]}
382 // apiClient might wrap it in response.data
383 let trips = [];
384
385 if (response && typeof response === "object") {
386 // Check if response has a data property (apiClient wrapper)
387 if (response.data && typeof response.data === "object") {
388 // Check for success property first (WordPress REST API format)
389 if (
390 response.data.success === true &&
391 Array.isArray(response.data.data)
392 ) {
393 trips = response.data.data;
394 }
395 // Direct data property
396 else if (Array.isArray(response.data.data)) {
397 trips = response.data.data;
398 }
399 // Direct array in response.data
400 else if (Array.isArray(response.data)) {
401 trips = response.data;
402 }
403 }
404 // Check for success property directly
405 else if (
406 response.success === true &&
407 Array.isArray(response.data)
408 ) {
409 trips = response.data;
410 }
411 // Direct data property
412 else if (Array.isArray(response.data)) {
413 trips = response.data;
414 }
415 // Direct array response
416 else if (Array.isArray(response)) {
417 trips = response;
418 }
419 }
420
421 // Debug: log first trip to see structure
422 if (trips.length > 0) {
423 }
424
425 return trips;
426 } catch (error) {
427 console.error("Error fetching saved trips:", error);
428 return [];
429 }
430 },
431 });
432
433 const savedTrips = Array.isArray(savedTripsData) ? savedTripsData : [];
434
435 // Booking details fetching moved to Bookings component
436
437 // formatDate / site currency from ./account/utils
438
439 const stats = useMemo(() => {
440 const outstanding = payments
441 .filter((p: Payment) => p.status === "pending")
442 .reduce((sum: number, payment: Payment) => sum + payment.amount, 0);
443 const upcoming = bookings.filter(
444 (b: Booking) => toDateValue(b.travel_date) > new Date(),
445 ).length;
446
447 // Calculate from real data
448 const totalSpent = displayProfile?.total_spent ?? 0;
449 const totalBookings = bookings.length;
450
451 return [
452 {
453 label: __("Total Bookings", "yatra"),
454 value: displayProfile?.total_bookings ?? totalBookings,
455 icon: Package,
456 badge: displayProfile?.loyalty_tier || "",
457 },
458 {
459 label: __("Upcoming Trips", "yatra"),
460 value: upcoming,
461 icon: Calendar,
462 },
463 {
464 label: __("Outstanding Balance", "yatra"),
465 value: formatAccountCurrency(outstanding),
466 icon: DollarSign,
467 },
468 {
469 label: __("Total Spent", "yatra"),
470 value: formatAccountCurrency(totalSpent),
471 icon: ShieldCheck,
472 },
473 ];
474 }, [bookings, payments, displayProfile]);
475
476 // getBadge moved to ./account/utils
477
478 // Old render functions removed - now using components from ./account/
479
480 const renderSection = () => {
481 switch (section) {
482 case "dashboard":
483 return (
484 <Dashboard
485 bookings={bookings}
486 payments={payments}
487 displayProfile={displayProfile || null}
488 stats={stats}
489 notifications={notifications}
490 conciergePhone={accountShell.companyPhone}
491 conciergeEmail={accountShell.companyEmail}
492 onSectionChange={(section: string) =>
493 handleSectionChange(section as Section)
494 }
495 onBookingOpen={handleBookingOpen}
496 />
497 );
498 case "bookings":
499 return (
500 <Bookings
501 bookings={bookings}
502 conciergePhone={accountShell.companyPhone}
503 conciergeEmail={accountShell.companyEmail}
504 onSectionChange={(section: string) =>
505 handleSectionChange(section as Section)
506 }
507 initialBookingId={pendingBookingId}
508 onBookingIdConsumed={() => setPendingBookingId(null)}
509 />
510 );
511 case "payments":
512 return (
513 <Payments
514 payments={payments}
515 onSectionChange={(section: string) =>
516 handleSectionChange(section as Section)
517 }
518 />
519 );
520 case "documents":
521 return <Documents documents={documents} />;
522 case "saved-trips":
523 if (!wishlistEnabled) {
524 return null;
525 }
526 return (
527 <SavedTrips savedTrips={savedTrips} isLoading={isLoadingSavedTrips} />
528 );
529 case "profile":
530 return (
531 <Profile
532 profile={displayProfile || null}
533 savedTrips={savedTrips}
534 wishlistEnabled={wishlistEnabled}
535 />
536 );
537 default:
538 return null;
539 }
540 };
541
542 return (
543 <div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-10 pb-16 px-4 sm:px-6 lg:px-8">
544 <div className="w-full max-w-[var(--yatra-container-max-width,80rem)] mx-auto space-y-6 pb-8">
545 <div className="bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-800 rounded-xl shadow-sm p-6">
546 <div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
547 <div>
548 <p className="text-sm text-gray-500 dark:text-gray-400">
549 {displayProfile?.registered_at
550 ? formatDate(displayProfile.registered_at)
551 : ""}
552 </p>
553 <h1 className="text-xl font-bold text-gray-900 dark:text-white">
554 {__("Hello,", "yatra")}{" "}
555 {displayProfile?.name || __("Guest", "yatra")}
556 </h1>
557 <p className="text-sm text-gray-500 dark:text-gray-400">
558 {__(
559 "Manage bookings, payments, and documents – everything for your adventures in one place.",
560 "yatra",
561 )}
562 </p>
563 </div>
564 <div className="flex flex-wrap gap-3">
565 <div
566 role="button"
567 tabIndex={0}
568 onClick={() => {
569 const url = accountShell.logoutUrl;
570 if (url) {
571 window.location.href = url;
572 }
573 }}
574 onKeyDown={(e) => {
575 if (e.key === "Enter" || e.key === " ") {
576 e.preventDefault();
577 const url = accountShell.logoutUrl;
578 if (url) {
579 window.location.href = url;
580 }
581 }
582 }}
583 className="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-gray-200 dark:border-gray-600 cursor-pointer text-sm"
584 >
585 <LogOut className="w-4 h-4" /> {__("Logout", "yatra")}
586 </div>
587 </div>
588 </div>
589 </div>
590
591 <div className="flex flex-col gap-6 lg:flex-row lg:items-start">
592 <aside className="w-full lg:w-64 flex-shrink-0 space-y-4 lg:sticky lg:top-10 self-start bg-transparent">
593 <nav className="bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-800 rounded-xl divide-y divide-gray-100 dark:divide-gray-800">
594 <div className="p-4 space-y-1">
595 {accountNavigation.map((item) => (
596 <div
597 key={item.id}
598 role="button"
599 tabIndex={0}
600 onClick={() => handleSectionChange(item.id)}
601 className={`yatra-nav-item yatra-nav-item-${item.id} w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition cursor-pointer ${
602 section === item.id
603 ? "bg-yatra-soft text-yatra-primary-dark dark:bg-yatra-surface-dark dark:text-yatra-primary-light"
604 : "text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-900/40"
605 }`}
606 >
607 <item.icon className="w-4 h-4" />
608 {item.label}
609 </div>
610 ))}
611 </div>
612 </nav>
613
614 <div
615 className="rounded-xl p-6 text-white space-y-2 shadow-xl"
616 style={{
617 backgroundImage:
618 "linear-gradient(to bottom right, var(--yatra-primary-dark, #2563eb), var(--yatra-primary-darker, #1e40af))",
619 }}
620 >
621 <ShieldCheck className="w-6 h-6 text-white" />
622 <p className="text-sm font-medium text-white">
623 {__("Need help right away?", "yatra")}
624 </p>
625 <p className="font-semibold text-lg text-white">
626 {accountShell.companyName || __("Concierge Desk", "yatra")}
627 </p>
628 {accountShell.companyPhone ? (
629 <a
630 href={phoneToTelHref(accountShell.companyPhone)}
631 className="text-sm font-medium text-white hover:underline"
632 >
633 {accountShell.companyPhone}
634 </a>
635 ) : accountShell.companyEmail ? (
636 <a
637 href={`mailto:${encodeURIComponent(accountShell.companyEmail)}`}
638 className="text-sm font-medium text-white hover:underline break-all"
639 >
640 {accountShell.companyEmail}
641 </a>
642 ) : null}
643 </div>
644 </aside>
645
646 <section className="flex-1 min-w-0 space-y-6" style={{ minWidth: 0 }}>
647 {isLoadingProfile && !profile ? (
648 <div className="space-y-6">
649 {/* Header Skeleton */}
650 <div className="bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700 rounded-xl shadow-sm p-6">
651 <div className="animate-pulse">
652 <div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-48 mb-2"></div>
653 <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-96"></div>
654 </div>
655 </div>
656
657 {/* Stats Skeleton */}
658 <div className="flex flex-nowrap gap-6 overflow-x-auto">
659 {[1, 2, 3, 4, 5].map((i) => (
660 <div
661 key={i}
662 className="flex-shrink-0 bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-100 dark:border-gray-700 min-w-0 flex-1"
663 >
664 <div className="animate-pulse">
665 <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-20 mb-3"></div>
666 <div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-16"></div>
667 </div>
668 </div>
669 ))}
670 </div>
671
672 {/* Content Cards Skeleton */}
673 <div className="space-y-4">
674 {[1, 2, 3].map((i) => (
675 <div
676 key={i}
677 className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-6"
678 >
679 <div className="animate-pulse">
680 <div className="h-5 bg-gray-200 dark:bg-gray-700 rounded w-64 mb-4"></div>
681 <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-full mb-2"></div>
682 <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4"></div>
683 </div>
684 </div>
685 ))}
686 </div>
687 </div>
688 ) : (
689 renderSection()
690 )}
691 </section>
692 </div>
693 </div>
694 </div>
695 );
696 };
697
698 export default AccountPage;
699