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

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