PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.2.6
Yatra – Travel Booking & Tour Operator Software v3.0.2.6
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.2.6, at resources/js/pages/AccountPage.tsx

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