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

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