PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.9
Yatra – Travel Booking & Tour Operator Software v3.0.9
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 2.0.1 All 84 releases
yatra / resources / js / pages / AccountPage.tsx

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

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