From 188c818251e94c93f735be6cd0d1f3294f8d20c3 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Thu, 9 Jul 2026 16:18:52 +0300 Subject: [PATCH 1/2] Fix chat bot alignment --- .../src/app/booking/confirmation/page.tsx | 2 +- .../portal/src/app/booking/layout.tsx | 3 +- .../src/app/booking/passengers/page.tsx | 4 +- .../portal/src/app/booking/payment/page.tsx | 8 +- .../portal/src/app/booking/results/page.tsx | 12 +-- .../portal/src/app/booking/review/page.tsx | 4 +- .../portal/src/app/booking/search/page.tsx | 6 +- .../portal/src/app/booking/seats/page.tsx | 16 ++-- .../portal/src/app/globals.css | 35 ++++++--- .../portal/src/app/layout.tsx | 2 +- .../edr-passenger-web/portal/src/app/page.tsx | 48 ++++++++++-- .../src/components/LoadingIndicator.tsx | 73 +++++++++++++++++-- .../src/components/ModernDatePicker.tsx | 15 ++++ .../portal/src/components/PageTransition.tsx | 15 ++++ .../portal/src/components/Skeleton.tsx | 10 +++ .../src/features/support/SupportWidget.tsx | 6 +- .../features/support/SupportWidgetLazy.tsx | 13 ++++ 17 files changed, 221 insertions(+), 51 deletions(-) create mode 100644 apps/edr-passenger-web/portal/src/components/PageTransition.tsx create mode 100644 apps/edr-passenger-web/portal/src/components/Skeleton.tsx create mode 100644 apps/edr-passenger-web/portal/src/features/support/SupportWidgetLazy.tsx diff --git a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx index 515eb4449..4189e9240 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx @@ -188,7 +188,7 @@ export default function ConfirmationPage() { if (!bookingId || !pnr) return null; return ( -
+
{/* Success Header */} diff --git a/apps/edr-passenger-web/portal/src/app/booking/layout.tsx b/apps/edr-passenger-web/portal/src/app/booking/layout.tsx index dd01d4d49..ec67ec56a 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/layout.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/layout.tsx @@ -2,6 +2,7 @@ import { usePathname } from 'next/navigation'; import { ProgressIndicator } from '@/components/ProgressIndicator'; +import { PageTransition } from '@/components/PageTransition'; export default function BookingLayout({ children, @@ -31,7 +32,7 @@ export default function BookingLayout({
)} - {children} + {children}
); } diff --git a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx index 5e5c13b3e..da81c5cd8 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx @@ -1024,7 +1024,7 @@ export default function PassengersPage() { if (!formInitialized || faydaCompleting) { return ( -
+
@@ -1043,7 +1043,7 @@ export default function PassengersPage() {
-

Passenger details

+

Passenger details

{fields.map((field, index) => { diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx index ceb54acd9..03b342d04 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx @@ -188,10 +188,10 @@ export default function PaymentPage() { if (!bookingId && !pnr) { return ( -
+
-

Loading payment details...

+

Loading payment details...

); @@ -372,10 +372,10 @@ export default function PaymentPage() { ); return ( -
+
-

Complete payment

+

Complete payment

{/* Reservation confirmation banner */}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx index 33609ba0e..dbaa4180b 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx @@ -524,7 +524,7 @@ export default function ResultsPage() { e.stopPropagation(); handleSelect(classModal, isOutbound); }} - className="mt-3 w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-gradient-to-r from-[rgb(20,113,76)] to-[rgb(16,95,65)] hover:from-[rgb(16,89,60)] hover:to-[rgb(12,75,50)] text-white font-bold text-sm rounded-xl transition-all shadow-md shadow-primary/30 hover:shadow-lg active:scale-[0.98]" + className="btn-primary w-full mt-3 text-sm active:scale-[0.98]" > {isRoundTrip && isOutbound @@ -739,7 +739,7 @@ export default function ResultsPage() { if (isLoading) { return ( -
+
{/* Progress Header */} @@ -942,7 +942,7 @@ export default function ResultsPage() { (results?.alternativeInbound || []).length === 0; if (isRoundTripNoResults) { return ( -
+
@@ -964,7 +964,7 @@ export default function ResultsPage() { const hasAlternatives = alternativeOutbound.length > 0; return ( -
+
{renderClassModal()}
@@ -1003,7 +1003,7 @@ export default function ResultsPage() { } return ( -
+
@@ -1022,7 +1022,7 @@ export default function ResultsPage() { } return ( -
+
{renderClassModal()} diff --git a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx index cf14cc87c..081626642 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx @@ -613,10 +613,10 @@ export default function ReviewPage() { ); return ( -
+
-

+

{packageName ? `Review your ${packageName} booking` : 'Review your booking'}

diff --git a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx index 574b08487..c51ee4141 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx @@ -1079,7 +1079,7 @@ export default function SearchPage() {
{isLoading ? ( -
-
-

- Loading seat map... -

+
+
+ + +
+
+ {Array.from({ length: 24 }).map((_, i) => ( + + ))} +
) : error ? (
diff --git a/apps/edr-passenger-web/portal/src/app/globals.css b/apps/edr-passenger-web/portal/src/app/globals.css index 9714d54bc..9296f3030 100644 --- a/apps/edr-passenger-web/portal/src/app/globals.css +++ b/apps/edr-passenger-web/portal/src/app/globals.css @@ -26,19 +26,17 @@ } .btn-ghost { - @apply text-[rgb(20_113_76)] font-medium py-2 px-4 rounded-lg transition-colors; + @apply text-[rgb(20_113_76)] font-medium py-2 px-4 rounded-lg transition-colors hover:bg-[rgba(20,113,76,0.1)] dark:hover:bg-[rgba(20,113,76,0.2)]; } - - .btn-ghost:hover { - background-color: rgba(20, 113, 76, 0.1); + + .booking-page { + @apply min-h-screen bg-gray-50 dark:bg-gray-900 py-6 md:py-8; } - - @media (prefers-color-scheme: dark) { - .btn-ghost:hover { - background-color: rgba(20, 113, 76, 0.2); - } + + .booking-container { + @apply container mx-auto px-4 max-w-6xl; } - + .input-field { @apply w-full px-4 py-3 border-2 border-gray-200 dark:border-gray-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-[rgb(20_113_76)] focus:border-transparent disabled:bg-gray-50 dark:disabled:bg-gray-800 disabled:cursor-not-allowed transition-all duration-200 text-base bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100; } @@ -162,7 +160,18 @@ opacity: 1; } } - + + @keyframes fade-in-up { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + .animate-bounce-in { animation: bounce-in 0.5s cubic-bezier(0.34, 1.56, 0.64, 1); } @@ -188,6 +197,10 @@ animation: slide-up 0.25s cubic-bezier(0.32, 0.72, 0, 1); } + .animate-fade-in-up { + animation: fade-in-up 0.35s ease-out; + } + .scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; diff --git a/apps/edr-passenger-web/portal/src/app/layout.tsx b/apps/edr-passenger-web/portal/src/app/layout.tsx index 3c18f1c13..e393b5f20 100644 --- a/apps/edr-passenger-web/portal/src/app/layout.tsx +++ b/apps/edr-passenger-web/portal/src/app/layout.tsx @@ -5,7 +5,7 @@ import { Providers } from './providers'; import AppHeader from '@/components/AppHeader'; import { Footer } from '@/components/Footer'; import { LoadingIndicator } from '@/components/LoadingIndicator'; -import SupportWidget from '@/features/support/SupportWidget'; +import SupportWidget from '@/features/support/SupportWidgetLazy'; export const metadata: Metadata = { title: 'EDR Passenger Portal - Book your train journey', diff --git a/apps/edr-passenger-web/portal/src/app/page.tsx b/apps/edr-passenger-web/portal/src/app/page.tsx index 93bf02fc2..b9475fc81 100644 --- a/apps/edr-passenger-web/portal/src/app/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/page.tsx @@ -1,9 +1,37 @@ import { Suspense } from 'react'; +import dynamic from 'next/dynamic'; import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query'; import SearchPage from '@/app/booking/search/page'; -import PackagesSection from '@/components/PackagesSection'; -export default async function Home() { +// Below-the-fold on the landing page — code-split out of the initial bundle +// needed to render/hydrate the (above-the-fold) search form. +const PackagesSection = dynamic(() => import('@/components/PackagesSection')); + +// Skeleton for the search form while stations are prefetched server-side — +// keeps first paint on a slow connection from being a blank page, and +// approximates the real form's height to avoid layout shift once it streams in. +function SearchFormSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+
+
+ ); +} + +// Isolates the server-side stations prefetch behind its own Suspense boundary +// (a real async Server Component, unlike a plain `await` in the page body) so +// Next.js can stream: the shell flushes immediately with SearchFormSkeleton, +// then the real search form + hydrated stations data stream in once ready — +// instead of blocking the whole page's TTFB on that one fetch. +async function StationsPrefetch({ children }: { children: React.ReactNode }) { const queryClient = new QueryClient(); const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000'; @@ -18,10 +46,18 @@ export default async function Home() { return ( - - - - + {children} ); } + +export default function Home() { + return ( + }> + + + + + + ); +} diff --git a/apps/edr-passenger-web/portal/src/components/LoadingIndicator.tsx b/apps/edr-passenger-web/portal/src/components/LoadingIndicator.tsx index 9fa5c466e..09ad7d787 100644 --- a/apps/edr-passenger-web/portal/src/components/LoadingIndicator.tsx +++ b/apps/edr-passenger-web/portal/src/components/LoadingIndicator.tsx @@ -1,23 +1,72 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { Suspense, useEffect, useRef, useState } from 'react'; +import { usePathname, useSearchParams } from 'next/navigation'; -export function LoadingIndicator() { +// Minimum time the bar stays visible once shown, so very fast local +// transitions don't flash imperceptibly (standard practice for top-loading +// progress bars). +const MIN_VISIBLE_MS = 300; + +function LoadingIndicatorInner() { + const pathname = usePathname(); + const searchParams = useSearchParams(); const [isVisible, setIsVisible] = useState(false); + const shownAtRef = useRef(null); + // Detects the START of a navigation: a same-origin link click gives + // instant feedback on tap, before the RSC fetch even begins. Browser + // back/forward (popstate) is covered the same way. useEffect(() => { - const handleStart = () => setIsVisible(true); - const handleEnd = () => setIsVisible(false); + const handleClick = (e: MouseEvent) => { + if (e.defaultPrevented || e.button !== 0) return; + if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; - window.addEventListener('beforeunload', handleStart); - window.addEventListener('load', handleEnd); + const anchor = (e.target as HTMLElement | null)?.closest('a'); + if (!anchor) return; + if (anchor.target && anchor.target !== '_self') return; + if (anchor.hasAttribute('download')) return; + const href = anchor.getAttribute('href'); + if (!href || href.startsWith('#') || href.startsWith('mailto:') || href.startsWith('tel:')) return; + + let url: URL; + try { + url = new URL(href, window.location.href); + } catch { + return; + } + if (url.origin !== window.location.origin) return; + if (url.pathname + url.search === window.location.pathname + window.location.search) return; + + shownAtRef.current = Date.now(); + setIsVisible(true); + }; + + const handlePopState = () => { + shownAtRef.current = Date.now(); + setIsVisible(true); + }; + + document.addEventListener('click', handleClick, true); + window.addEventListener('popstate', handlePopState); return () => { - window.removeEventListener('beforeunload', handleStart); - window.removeEventListener('load', handleEnd); + document.removeEventListener('click', handleClick, true); + window.removeEventListener('popstate', handlePopState); }; }, []); + // Detects the END of a navigation: pathname/search settling to a new value + // means the route transition has completed. + useEffect(() => { + if (!isVisible) return; + const elapsed = shownAtRef.current ? Date.now() - shownAtRef.current : MIN_VISIBLE_MS; + const remaining = Math.max(0, MIN_VISIBLE_MS - elapsed); + const timer = setTimeout(() => setIsVisible(false), remaining); + return () => clearTimeout(timer); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pathname, searchParams]); + if (!isVisible) return null; return ( @@ -28,3 +77,11 @@ export function LoadingIndicator() {
); } + +export function LoadingIndicator() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/portal/src/components/ModernDatePicker.tsx b/apps/edr-passenger-web/portal/src/components/ModernDatePicker.tsx index 5ea145bba..1f0bbddfe 100644 --- a/apps/edr-passenger-web/portal/src/components/ModernDatePicker.tsx +++ b/apps/edr-passenger-web/portal/src/components/ModernDatePicker.tsx @@ -259,6 +259,21 @@ export default function ModernDatePicker({ {calendarType === 'gregorian' ? renderGregorianCalendar() : renderEthiopianCalendar()} {calendarFooter}
+ + {/* Mobile-only: a large, unmistakable Cancel action in addition to the header + X — the mobile view is a full-screen takeover with no backdrop to tap, so + this is the fallback way out without having to pick a date. */} + {isMobileView && ( +
+ +
+ )}
); diff --git a/apps/edr-passenger-web/portal/src/components/PageTransition.tsx b/apps/edr-passenger-web/portal/src/components/PageTransition.tsx new file mode 100644 index 000000000..dbc8839e3 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/components/PageTransition.tsx @@ -0,0 +1,15 @@ +'use client'; + +import { usePathname } from 'next/navigation'; + +// Keying by pathname forces React to remount this wrapper on every route +// change, which restarts the CSS animation — a subtle fade/slide-up on each +// step of the booking flow, no animation library required. +export function PageTransition({ children }: { children: React.ReactNode }) { + const pathname = usePathname(); + return ( +
+ {children} +
+ ); +} diff --git a/apps/edr-passenger-web/portal/src/components/Skeleton.tsx b/apps/edr-passenger-web/portal/src/components/Skeleton.tsx new file mode 100644 index 000000000..1c69f7483 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/components/Skeleton.tsx @@ -0,0 +1,10 @@ +interface SkeletonProps { + className?: string; +} + +// Shared pulse-block primitive for loading placeholders — replaces the +// hand-rolled `animate-pulse` divs duplicated across PackagesSection, +// results/page.tsx, and the home page's search skeleton. +export function Skeleton({ className = '' }: SkeletonProps) { + return
; +} diff --git a/apps/edr-passenger-web/portal/src/features/support/SupportWidget.tsx b/apps/edr-passenger-web/portal/src/features/support/SupportWidget.tsx index 96ecc7276..8a4100de9 100644 --- a/apps/edr-passenger-web/portal/src/features/support/SupportWidget.tsx +++ b/apps/edr-passenger-web/portal/src/features/support/SupportWidget.tsx @@ -23,7 +23,11 @@ export function SupportWidget() { useSupportSocket(true); return ( -
+ // Kept below every modal/drawer tier (z-90 through z-[120]) and the mobile sticky + // bottom action bars (z-40) used on booking flow pages, so the floating launcher never + // paints over dialog content or a page's "Continue" bar — it only sits above ordinary + // in-page content. +
{open && setOpen(false)} />} {!open && (
- {/* Action Buttons */} -
- -
+ {/* Action Buttons — voucher/ticket only makes sense once the booking is + actually confirmed; a pending-payment booking has no real ticket yet. */} + {isConfirmed && ( +
+ +
+ )} {/* New Booking Button */}
-
+
-
+
{schedule.departureAt ? formatTime(schedule.departureAt) : "--:--"} @@ -659,9 +659,9 @@ export default function ResultsPage() {
-
-
- +
+
+ {durationStr}
@@ -669,15 +669,15 @@ export default function ResultsPage() {
{schedule.stops && schedule.stops.length > 0 && ( -
- +
+ {schedule.stops.length - 2} stops
)}
-
+
{schedule.arrivalAt ? formatTime(schedule.arrivalAt) : "--:--"} diff --git a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx index c51ee4141..02c1fcd35 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx @@ -608,6 +608,12 @@ export default function SearchPage() { reValidateMode: "onSubmit", defaultValues: { tripType: "ONE_WAY", + // originStationId/destinationStationId must default to "" rather than being omitted — + // Zod's base string type check runs before .min(1, "..."), so an `undefined` value hits + // its generic "Required" message instead of our custom one. An empty string is still a + // string, so .min() (and its custom message) is what actually fires. + originStationId: "", + destinationStationId: "", adultCount: 1, childCount: 0, // No default nationality — the user must explicitly pick one. Left blank (not a valid @@ -827,19 +833,21 @@ export default function SearchPage() { /> )} - {/* ── 90vh hero with banner image ── */} - {/* Round trip stacks an extra Return Date field into the widget on mobile, which grows + {/* ── 90vh hero with banner image (desktop) / top-aligned widget only (mobile) ── */} + {/* Round trip stacks an extra Return Date field into the widget on desktop, which grows upward from its bottom-anchored position — give the hero extra height there so the - widget's top edge doesn't creep up into the sticky header. */} + widget's top edge doesn't creep up into the sticky header. On mobile the widget is + in normal flow (not bottom-anchored), so this only applies at md: and up. */}
- {/* Background image with zoom - fully isolated */} -
+ {/* Background image with zoom — desktop only; mobile drops the hero image entirely + so the booking widget can sit at the top and use the available space. */} +
- {/* Gradient overlay */} -
-
+ {/* Gradient overlay — desktop only (exists to keep the hero text readable over the image) */} +
+
- {/* Hero headline — top area */} -
+ {/* Hero headline — desktop only; removed on mobile along with the banner image */} +

Where are you
headed today? @@ -867,14 +875,28 @@ export default function SearchPage() {

- {/* ── Widget — absolutely positioned at bottom with margin ── */} + {/* ── Widget — top-aligned, normal flow on mobile; absolutely positioned at + bottom on desktop over the hero image. z-[35] sits above the floating + support chat launcher (z-30) but below the sidebar/tab bar (z-40), so it + never covers either. Modals opened from inside the widget (date picker + etc.) are portaled to — see ModernDatePicker — so they aren't + capped by this wrapper's own stacking context. ── */}
+ {/* Mobile-only heading — desktop keeps the version overlaid on the hero image above */} +
+

+ Where are you headed today? +

+

+ Book your train journey across East Africa +

+
-
+
{error && (
⚠️ @@ -950,6 +972,11 @@ export default function SearchPage() {
+ {hasInteracted && errors.originStationId && ( +

+ {errors.originStationId.message} +

+ )}
@@ -998,6 +1025,11 @@ export default function SearchPage() {
+ {hasInteracted && errors.destinationStationId && ( +

+ {errors.destinationStationId.message} +

+ )}
+ {errors.departureDate && ( +

+ {errors.departureDate.message} +

+ )}
{tripType === "ROUND_TRIP" && (
@@ -1047,6 +1085,7 @@ export default function SearchPage() { : new Date() } placeholder="Select return date" + error={!!errors.returnDate} />
{errors.returnDate && ( @@ -1182,6 +1221,7 @@ export default function SearchPage() { }} minDate={new Date()} placeholder="Departure" + error={!!errors.departureDate} />
{errors.departureDate && ( @@ -1324,6 +1364,7 @@ export default function SearchPage() { }} minDate={new Date()} placeholder="Select date" + error={!!errors.departureDate} />
{errors.departureDate && ( @@ -1357,6 +1398,7 @@ export default function SearchPage() { : new Date() } placeholder="Select date" + error={!!errors.returnDate} />
{errors.returnDate && ( diff --git a/apps/edr-passenger-web/portal/src/app/contact/page.tsx b/apps/edr-passenger-web/portal/src/app/contact/page.tsx index f2c0f4296..669a99d0b 100644 --- a/apps/edr-passenger-web/portal/src/app/contact/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/contact/page.tsx @@ -3,16 +3,18 @@ import { useEffect, useState } from 'react'; import { getTranslation, Language, useLanguage } from '@/lib/i18n'; import { Phone, Mail, MapPin, Send, Loader } from 'lucide-react'; +import { Footer } from '@/components/Footer'; const styles = ` .contact-hero { padding: 60px 20px; - background: linear-gradient(to bottom right, rgb(20, 113, 76), transparent); + background: #ffffff; text-align: center; color: #111827; } - + .dark .contact-hero { + background: #111827; color: #f3f4f6; } @@ -370,6 +372,7 @@ export default function Contact() {
+