From fef50e46410069fac3633ec5438def189c92a5ea Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Fri, 12 Jun 2026 19:05:59 +0300 Subject: [PATCH 1/4] Updated booking widget --- .../portal/src/app/booking/search/page.tsx | 628 +++++++++--------- .../edr-passenger-web/portal/src/app/page.tsx | 439 +----------- 2 files changed, 325 insertions(+), 742 deletions(-) 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 5b2ad3e9b..98ce04738 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 @@ -169,14 +169,18 @@ function StationModal({ function PassengerModal({ adultCount, childCount, + nationality, onChangeAdult, onChangeChild, + onChangeNationality, onClose, }: { adultCount: number; childCount: number; + nationality: string; onChangeAdult: (n: number) => void; onChangeChild: (n: number) => void; + onChangeNationality: (v: string) => void; onClose: () => void; }) { const rows = [ @@ -184,30 +188,31 @@ function PassengerModal({ { label: 'Children', sub: '< 5 years β€’ First child free', val: childCount, min: 0, max: 9, onChange: onChangeChild }, ]; + const natOptions = [ + { value: 'ETHIOPIAN', label: 'πŸ‡ͺπŸ‡Ή Ethiopian' }, + { value: 'DJIBOUTIAN', label: 'πŸ‡©πŸ‡― Djiboutian' }, + { value: 'OTHER', label: '🌍 Other' }, + ]; + return ( <> - {/* Backdrop */}
- {/* Bottom sheet */}
- {/* Handle */}
- {/* Header */}
-

Passengers

+

Passengers & Nationality

-
- {/* Rows */}
{rows.map(({ label, sub, val, min, max, onChange }, i) => (
@@ -218,35 +223,38 @@ function PassengerModal({

{sub}

- {val} -
))} +
+

Nationality

+
+ {natOptions.map((opt) => ( + + ))} +
+
- {/* Done */}
-
@@ -265,6 +273,7 @@ function StationDropdown({ onSelect, error, recentIds, + onOpen, }: { stations: Station[]; value: string; @@ -273,6 +282,7 @@ function StationDropdown({ onSelect: (s: Station) => void; error?: string; recentIds: string[]; + onOpen?: () => void; }) { const [query, setQuery] = useState(''); const [open, setOpen] = useState(false); @@ -315,7 +325,7 @@ function StationDropdown({ ref={inputRef} value={displayValue} onChange={(e) => { setQuery(e.target.value); setOpen(true); }} - onFocus={() => { setQuery(''); setOpen(true); }} + onFocus={() => { setQuery(''); setOpen(true); onOpen?.(); }} placeholder={placeholder} className="w-full pl-10 pr-8 py-3.5 bg-transparent rounded-xl focus:outline-none text-sm text-gray-900 dark:text-white placeholder-gray-400" /> @@ -331,7 +341,7 @@ function StationDropdown({
{open && ( -
+
{!query && recentIds.length > 0 && (

Recent

@@ -395,6 +405,16 @@ export default function SearchPage() { try { return JSON.parse(localStorage.getItem('edr_recent_stations') || '[]'); } catch { return []; } }); const passengerRef = useRef(null); + const widgetRef = useRef(null); + + const scrollWidgetIntoView = () => { + const el = widgetRef.current; + if (!el) return; + const headerHeight = 64; + const marginTop = 24; + const top = el.getBoundingClientRect().top + window.scrollY - headerHeight - marginTop; + window.scrollTo({ top, behavior: 'smooth' }); + }; const { data: stations = [], isLoading, error } = useQuery({ queryKey: ['stations'], @@ -517,14 +537,16 @@ export default function SearchPage() { }; return ( -
+
{/* Passenger modal (mobile) */} {passengerModalOpen && ( setValue('adultCount', n)} onChangeChild={(n) => setValue('childCount', n)} + onChangeNationality={(v) => setValue('nationality', v as any)} onClose={() => setPassengerModalOpen(false)} /> )} @@ -557,299 +579,219 @@ export default function SearchPage() { /> )} - {/* Hero Banner */} -
-
-
-
-
+ {/* ── 90vh hero with banner image ── */} +
+ {/* Background image */} +
+ {/* Gradient overlay */} +
+
+ + {/* Hero headline β€” top area */} +
+

+ Where are you
headed today? +

+

Book your train journey across East Africa

-
+ + {/* ── Widget β€” absolutely positioned at bottom with margin ── */} +
-

- Where are you headed? -

-

Search and book train tickets fast & easy

-
-
-
+
+
- {/* Search Card β€” pulled up over the hero */} -
-
- -
- - {/* Error banner */} - {error && ( -
- ⚠️ - Unable to load stations. Please check your connection. -
- )} - -
- - {/* ── Row 1 (mobile stacked): Stations + Date ── */} - - {/* Mobile station fields */} -
-
- - - {errors.originStationId &&

{errors.originStationId.message}

} -
-
-
- - -
- - {errors.destinationStationId &&

{errors.destinationStationId.message}

} -
- {/* Date (mobile) */} -
- -
- setValue('departureDate', `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`)} - minDate={new Date()} - placeholder="Select date" - /> -
- {errors.departureDate &&

{errors.departureDate.message}

} -
-
- - {/* Desktop Row 1: From [swap] To + Date (3 equal cols) */} -
- {/* From + To with swap */} -
-
- - { setValue('originStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.originStationId?.message} /> - {errors.originStationId &&

{errors.originStationId.message}

} -
- -
- - { setValue('destinationStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.destinationStationId?.message} /> - {errors.destinationStationId &&

{errors.destinationStationId.message}

} -
-
- {/* Date */} -
- -
- setValue('departureDate', `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`)} - minDate={new Date()} - placeholder="Select date" - /> -
- {errors.departureDate &&

{errors.departureDate.message}

} -
-
- - {/* Route preview pill */} - {originStation && destStation && ( -
- - {originStation.name} - - {destStation.name} + {error && ( +
+ ⚠️ + Unable to load stations. Please check your connection.
)} - {/* ── Row 2: Passengers | Nationality | Search Button ── */} -
+
- {/* Passengers */} -
- - - {/* Mobile: opens bottom-sheet modal */} - +
+
+
+ + +
+ +
+
+ +
+ setValue('departureDate', `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`)} + minDate={new Date()} placeholder="Select date" + /> +
+
+ {/* Pax + Nationality combined trigger */} + - - {/* sm+: inline dropdown */} -
- - {isPassengerOpen && ( -
- {[ - { label: 'Adults', sub: 'β‰₯ 5 years', key: 'adultCount' as const, val: adultCount || 1, min: 1, max: 9 }, - { label: 'Children', sub: '< 5 years β€’ First free', key: 'childCount' as const, val: childCount || 0, min: 0, max: 9 }, - ].map(({ label, sub, key, val, min, max }, i) => ( -
- {i > 0 &&
} -
-
-

{label}

-

{sub}

-
-
- - {val} - -
-
-
- ))} - -
- )} -
-
- - {/* Nationality */} -
- - -
- - {/* Search Button */} - -
- - {/* ── Promo Code (collapsed by default) ── */} -
- {!promoVisible ? ( - - ) : ( -
-
-
- - { setPromoCode(e.target.value.toUpperCase()); if (promoValidation) setPromoValidation(null); }} - placeholder="Enter promo code" - onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), handleValidatePromo())} - className="w-full pl-9 pr-3 py-2.5 border-2 border-gray-200 dark:border-gray-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 transition-all" - autoFocus - /> -
- - -
- {promoValidation && ( -
- {promoValidation.valid && } - {promoValidation.message} -
- )} +
+ + {/* Desktop: single row β€” From [swap] To | Date | Pax+Nat | Search */} +
+ {/* From */} +
+ + { setValue('originStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.originStationId?.message} onOpen={scrollWidgetIntoView} /> + {errors.originStationId &&

{errors.originStationId.message}

}
- )} + {/* Swap */} + + {/* To */} +
+ + { setValue('destinationStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.destinationStationId?.message} onOpen={scrollWidgetIntoView} /> + {errors.destinationStationId &&

{errors.destinationStationId.message}

} +
+ {/* Divider */} +
+ {/* Date */} +
+ +
+ setValue('departureDate', `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`)} + minDate={new Date()} placeholder="Select date" + /> +
+ {errors.departureDate &&

{errors.departureDate.message}

} +
+ {/* Divider */} +
+ {/* Pax + Nationality combined β€” opens shared modal */} +
+ + +
+ {/* Search */} + +
+ + {/* Promo */} +
+ {!promoVisible ? ( + + ) : ( +
+
+
+ + { setPromoCode(e.target.value.toUpperCase()); if (promoValidation) setPromoValidation(null); }} + placeholder="Enter promo code" + onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), handleValidatePromo())} + className="w-full pl-9 pr-3 py-2.5 border-2 border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/30 text-sm bg-white placeholder-gray-400" + autoFocus /> +
+ + +
+ {promoValidation && ( +
+ {promoValidation.valid && } + {promoValidation.message} +
+ )} +
+ )} +
+
-
-
- + +
+
+
- {/* ── Popular Routes ── */} -
+ {/* Popular Routes β€” below hero */} +
+
+

Popular Routes

{POPULAR_ROUTES.map((route, idx) => ( -
+ {/* Promotions Section */} +
+
+
+
+ 🎁 +

Offers & Promotions

+
+
+ + {/* Wide promo card */} +
+
+
+
+
+
+ + Limited Time + +

+ 20% Off Weekend
Travel +

+

+ Book any weekend journey and save 20%. Valid for all seat classes. +

+
+
+ Valid until 31 Dec 2024 + + Book now + +
+
+
+ + {/* Narrow promo cards */} +
+
+
+
+
+
+ New +

Family Package

+

4 tickets for the price of 3

+
+
+ + Learn more + +
+
+
+ +
+
+
+
+
+ Student +

Student Discount

+

15% off with valid student ID

+
+
+ + Learn more + +
+
+
+
+ +
+
+
+
+
); diff --git a/apps/edr-passenger-web/portal/src/app/page.tsx b/apps/edr-passenger-web/portal/src/app/page.tsx index b86db2ffe..c48063991 100644 --- a/apps/edr-passenger-web/portal/src/app/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/page.tsx @@ -1,438 +1 @@ -'use client'; - -import { useEffect, useState } from 'react'; -import { getTranslation, Language, useLanguage } from '@/lib/i18n'; -import Link from 'next/link'; -import { SearchWidget } from '@/components/SearchWidget'; -import { Zap, Heart, Shield, Clock, ArrowRight, Train, MapPin, Calendar } from 'lucide-react'; -import { useQuery } from '@tanstack/react-query'; -import { apiClient } from '@/lib/api-client'; -import { Station } from '@/types'; - -const styles = ` - .hero-section { - position: relative; - overflow: hidden; - background: linear-gradient(to bottom right, rgb(20, 113, 76), transparent); - padding: 80px 20px; - text-align: center; - color: #111827; - } - - .dark .hero-section { - background: linear-gradient(to bottom right, rgb(20, 113, 76), transparent); - color: #f3f4f6; - } - - .hero-content { - max-width: 100%; - margin: 0 auto; - padding: 0 20px; - } - - .hero-heading { - font-size: clamp(1rem, 3vw, 2.75rem); - font-weight: 700; - margin-bottom: 24px; - color: #ffffff; - line-height: 1.2; - } - - .dark .hero-heading { - color: #ffffff; - } - - .hero-subheading { - font-size: clamp(1rem, 3vw, 1.5rem); - color: #4b5563; - margin-bottom: 32px; - } - - .dark .hero-subheading { - color: #9ca3af; - } - - .stats-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 16px; - padding: 32px 16px; - border-top: 1px solid #e5e7eb; - border-bottom: 1px solid #e5e7eb; - margin-top: 48px; - } - - .dark .stats-grid { - border-top-color: #374151; - border-bottom-color: #374151; - } - - .stat-item { - text-align: center; - } - - .stat-number { - font-size: 2rem; - font-weight: 700; - color: rgb(20, 113, 76); - } - - .stat-label { - font-size: 0.875rem; - color: #6b7280; - } - - .dark .stat-label { - color: #9ca3af; - } - - .features-section { - padding: 80px 20px; - background-color: #ffffff; - } - - .dark .features-section { - background-color: #111827; - } - - .section-title { - text-align: center; - font-size: 2rem; - font-weight: 700; - margin-bottom: 40px; - color: #111827; - } - - .dark .section-title { - color: #f3f4f6; - } - - .features-grid { - max-width: 72rem; - margin: 0 auto; - display: grid; - grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); - gap: 24px; - } - - .feature-card { - background-color: white; - border: 2px solid #f3f4f6; - border-radius: 18px; - padding: 24px; - cursor: pointer; - transition: all 0.2s; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); - text-align: left; - } - - .dark .feature-card { - background-color: #1f2937; - border-color: #374151; - } - - .feature-card:hover { - border-color: rgb(20, 113, 76); - box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); - transform: translateY(-8px); - } - - .feature-icon-bg { - width: 48px; - height: 48px; - background-color: rgb(20, 113, 76); - border-radius: 8px; - display: flex; - align-items: center; - justify-content: center; - margin-bottom: 16px; - } - - .feature-title { - font-weight: 700; - color: #111827; - margin-bottom: 8px; - font-size: 1.125rem; - } - - .dark .feature-title { - color: #f3f4f6; - } - - .feature-desc { - font-size: 0.875rem; - color: #6b7280; - } - - .dark .feature-desc { - color: #9ca3af; - } - - .cta-section { - padding: 80px 20px; - background: #f3f4f6; - text-align: center; - } - - .dark .cta-section { - background: #111827; - } - - .cta-content { - max-width: 42rem; - margin: 0 auto; - } - - .cta-title { - font-size: 2rem; - font-weight: 700; - margin-bottom: 24px; - color: #111827; - } - - .dark .cta-title { - color: white; - } - - .cta-text { - font-size: 1.125rem; - color: #4b5563; - margin-bottom: 32px; - } - - .dark .cta-text { - color: #e0e7ff; - } - - .cta-button { - display: inline-flex; - align-items: center; - gap: 8px; - padding: 16px 32px; - background-color: white; - color: rgb(20 113 76 / var(--tw-bg-opacity, 1)); - font-weight: 700; - border-radius: 12px; - border: none; - cursor: pointer; - transition: all 0.2s; - box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); - text-decoration: none; - } - - .cta-button:hover { - background-color: #f0f9ff; - box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.15); - transform: scale(1.05); - } - - @media (max-width: 640px) { - .stats-grid { - grid-template-columns: 1fr; - } - } - - @keyframes bounce { - 0%, 100% { - transform: translateY(0); - } - 50% { - transform: translateY(-10px); - } - } - - .bounce { - animation: bounce 2s infinite; - } - - .bounce:nth-child(2) { - animation-delay: 0.2s; - } - - .bounce:nth-child(3) { - animation-delay: 0.4s; - } - - .search-widget-transparent { - background-color: rgba(255, 255, 255, 0.95) !important; - backdrop-filter: blur(10px); - border-color: rgba(255, 255, 255, 0.2) !important; - } - - .dark .search-widget-transparent { - background-color: rgba(31, 41, 55, 0.95) !important; - border-color: rgba(55, 65, 81, 0.2) !important; - } -`; - -export default function Home() { - const [lang, setLang] = useState('en'); - const { getLang } = useLanguage(); - const t = (key: string) => getTranslation(lang, key); - - useEffect(() => { - setLang(getLang()); - const handleLanguageChange = (e: any) => setLang(e.detail); - window.addEventListener('languageChange', handleLanguageChange); - return () => window.removeEventListener('languageChange', handleLanguageChange); - }, [getLang]); - - const { data: stations } = useQuery({ - queryKey: ['stations'], - queryFn: async () => await apiClient.get('/stations') as Station[], - }); - - const getStationByName = (name: string) => { - if (!stations) return null; - const exactMatch = stations.find(s => s.name.toLowerCase() === name.toLowerCase()); - if (exactMatch) return exactMatch; - return stations.find(s => s.name.toLowerCase().includes(name.toLowerCase())); - }; - - const handlePopularRoute = (fromName: string, toName: string) => { - const origin = getStationByName(fromName); - const destination = getStationByName(toName); - - if (origin && destination) { - window.scrollTo({ top: 0, behavior: 'smooth' }); - } - }; - - const popularRoutes = [ - { from: 'Sebeta', to: 'Nagad', duration: '12h' }, - { from: 'Sebeta', to: 'Diredawa', duration: '8h' }, - { from: 'Diredawa', to: 'Nagad', duration: '4h' }, - ]; - - const features = [ - { - icon: Heart, - title: t('home.comfortable'), - desc: t('home.comfortDesc'), - }, - { - icon: Zap, - title: t('home.affordable'), - desc: t('home.affordableDesc'), - }, - { - icon: Shield, - title: t('home.safe'), - desc: t('home.safeDesc'), - }, - { - icon: Clock, - title: t('home.fast'), - desc: t('home.fastDesc'), - }, - ]; - - const highlights = [ - { - icon: Train, - title: 'Modern fleet', - desc: 'Comfortable trains with modern amenities', - }, - { - icon: MapPin, - title: '21 stations', - desc: 'Connecting Ethiopia and Djibouti', - }, - { - icon: Calendar, - title: 'Easy booking', - desc: 'Book tickets in just a few clicks', - }, - ]; - - return ( - <> - -
- {/* Hero Section */} -
-
-

{t('home.hero')}

-

{t('home.heroSub')}

- - - - {/* Highlights */} -
- {highlights.map((highlight, idx) => { - const Icon = highlight.icon; - return ( -
-
- -
-

{highlight.title}

-

{highlight.desc}

-
- ); - })} -
-
-
- - {/* Popular Routes Section */} -
-
-

Popular Routes

-
- {popularRoutes.map((route, idx) => ( - - ))} -
-
-
- - {/* Features Section */} -
-

{t('home.features')}

-
- {features.map((feature, idx) => { - const Icon = feature.icon; - return ( -
-
- -
-

{feature.title}

-

{feature.desc}

-
- ); - })} -
-
- - {/* CTA Section */} -
-
-

Ready to start your journey?

-

Book your train tickets in just a few minutes and enjoy a comfortable ride.

- - {t('home.cta')} - - -
-
-
- - ); -} +export { default } from '@/app/booking/search/page'; From faf0eeb3b6c3574fa7c18461045f83bd4254ef46 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Sun, 14 Jun 2026 11:07:29 +0300 Subject: [PATCH 2/4] Update get payment methods from static to api --- .../portal/src/app/booking/payment/page.tsx | 448 ++++++++++-------- 1 file changed, 257 insertions(+), 191 deletions(-) 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 34f1fada1..716db1168 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 @@ -1,148 +1,163 @@ -'use client'; +"use client"; -import { useRouter } from 'next/navigation'; -import { useBookingStore } from '@/lib/booking-store'; -import { usePaymentStore } from '@/lib/payment-store'; -import { useMutation } from '@tanstack/react-query'; -import { apiClient } from '@/lib/api-client'; -import { useState, useEffect } from 'react'; -import { CreditCard, Smartphone, Wallet, Loader2, CheckCircle } from 'lucide-react'; +import { useRouter } from "next/navigation"; +import { useBookingStore } from "@/lib/booking-store"; +import { usePaymentStore } from "@/lib/payment-store"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { apiClient } from "@/lib/api-client"; +import { useState, useEffect } from "react"; +import { + CreditCard, + Smartphone, + Wallet, + Loader2, + CheckCircle, + ExternalLink, +} from "lucide-react"; -// Mock payment methods with Ethiopian providers -const paymentMethods = [ - { - id: 'TELEBIRR', - name: 'Telebirr', - icon: Smartphone, - description: 'Pay with Telebirr mobile money', - color: 'bg-orange-50 border-orange-200 hover:border-orange-400' - }, - { - id: 'CBE_BIRR', - name: 'CBE Birr', - icon: Smartphone, - description: 'Pay with CBE Birr', - color: 'bg-blue-50 border-blue-200 hover:border-blue-400' - }, - { - id: 'EBIRR', - name: 'eBirr', - icon: Smartphone, - description: 'Pay with eBirr', - color: 'bg-green-50 border-green-200 hover:border-green-400' - }, - { - id: 'CARD', - name: 'Card Payment', - icon: CreditCard, - description: 'Pay with credit/debit card', - color: 'bg-purple-50 border-purple-200 hover:border-purple-400' - }, - { - id: 'WALLET', - name: 'Wallet', - icon: Wallet, - description: 'Pay from your wallet balance', - color: 'bg-indigo-50 border-indigo-200 hover:border-indigo-400' - }, -]; +const METHOD_ICONS: Record = { + TELEBIRR: Smartphone, + CBE_BIRR: Smartphone, + EBIRR: Smartphone, + CARD: CreditCard, + WALLET: Wallet, +}; + +const METHOD_COLORS: Record = { + TELEBIRR: "bg-orange-50 dark:bg-orange-900/20", + CBE_BIRR: "bg-blue-50 dark:bg-blue-900/20", + EBIRR: "bg-green-50 dark:bg-green-900/20", + CARD: "bg-purple-50 dark:bg-purple-900/20", + WALLET: "bg-indigo-50 dark:bg-indigo-900/20", +}; export default function PaymentPage() { const router = useRouter(); const { bookingId, pnr, selectedSchedule, passengers } = useBookingStore(); - const { selectedCurrency, setPaymentIntent, updateStatus } = usePaymentStore(); + const { selectedCurrency, setPaymentIntent, updateStatus } = + usePaymentStore(); const [selectedMethod, setSelectedMethod] = useState(null); const [isProcessing, setIsProcessing] = useState(false); - // Calculate total amount - const baseFare = passengers.reduce((sum) => sum + (selectedSchedule?.baseFareAdult || 0), 0); + const baseFare = passengers.reduce( + (sum) => sum + (selectedSchedule?.baseFareAdult || 0), + 0, + ); const totalAmount = baseFare; + // Fetch real payment methods + const { data: methodsData, isLoading: methodsLoading } = useQuery({ + queryKey: ["payment-methods"], + queryFn: () => apiClient.get("/payments/methods") as Promise, + }); + + const paymentMethods: any[] = Array.isArray(methodsData) + ? methodsData + : (methodsData as any)?.methods || (methodsData as any)?.data || []; + + // Find paymentMethodId for the selected method from API response + const getPaymentMethodId = (methodCode: string): string => { + const found = paymentMethods.find( + (m: any) => + m.code === methodCode || + m.type === methodCode || + m.name?.toUpperCase().replace(/\s/g, "_") === methodCode || + m.id === methodCode, + ); + return found?.id || found?.paymentMethodId || methodCode; + }; + + // TELEBIRR initiate mutation + const initiateMutation = useMutation({ + mutationFn: async (methodCode: string) => { + const paymentMethodId = getPaymentMethodId(methodCode); + return apiClient.post("/payments/initiate", { + bookingId, + method: methodCode, + paymentMethodId, + platform: "web", + }); + }, + onSuccess: (data: any) => { + setPaymentIntent(data?.paymentIntentId || data?.id || ""); + updateStatus("PROCESSING"); + setIsProcessing(false); + + // If there's a redirect URL (e.g. Telebirr checkout page), open it + if (data?.checkoutUrl || data?.redirectUrl || data?.paymentUrl) { + window.open( + data.checkoutUrl || data.redirectUrl || data.paymentUrl, + "_blank", + ); + } else { + router.push("/booking/confirmation"); + } + }, + onError: (error: any) => { + console.error("Payment initiation failed:", error); + updateStatus("FAILED"); + const msg = + error?.response?.data?.message || + error?.message || + "Payment failed. Please try again."; + alert(msg); + setIsProcessing(false); + }, + }); + + // Generic payment intent mutation (for non-Telebirr methods) const paymentMutation = useMutation({ mutationFn: async (data: any) => { - // Try to call the real API, fallback to mock if it fails try { - return await apiClient.post('/payments/intent', data); - } catch (error) { - console.log('Payment API not available, using mock payment'); - // Mock payment response + return await apiClient.post("/payments/intent", data); + } catch { return { paymentIntentId: `mock-payment-${Date.now()}`, - status: 'PENDING', - amountMinor: data.amountMinor, - currency: data.currency, - method: data.method, + status: "PENDING", }; } }, onSuccess: async (data: any) => { setPaymentIntent(data.paymentIntentId); - updateStatus('PROCESSING'); - - // Simulate payment processing - await new Promise(resolve => setTimeout(resolve, 2000)); - - // Generate tickets after successful payment - try { - await generateTickets(); - updateStatus('SUCCEEDED'); - router.push('/booking/confirmation'); - } catch (error) { - console.error('Ticket generation failed:', error); - // Still proceed to confirmation even if ticket generation fails - updateStatus('SUCCEEDED'); - router.push('/booking/confirmation'); - } + updateStatus("PROCESSING"); + await new Promise((resolve) => setTimeout(resolve, 2000)); + updateStatus("SUCCEEDED"); + router.push("/booking/confirmation"); }, onError: (error: any) => { - console.error('Payment failed:', error); - updateStatus('FAILED'); - const errorMessage = error?.response?.data?.message || error?.message || 'Payment failed. Please try again.'; - alert(errorMessage); + updateStatus("FAILED"); + alert( + error?.response?.data?.message || "Payment failed. Please try again.", + ); setIsProcessing(false); }, }); - const generateTickets = async () => { - // Try to generate tickets via API, fallback to mock - try { - await apiClient.post('/tickets/generate', { - bookingId, - pnr, - }); - } catch (error) { - console.log('Ticket API not available, tickets will be generated on confirmation page'); - // Mock ticket generation - tickets will be displayed on confirmation page - } - }; - const handlePayment = async () => { if (!selectedMethod || !bookingId) { - alert('Please select a payment method'); + alert("Please select a payment method"); return; } setIsProcessing(true); - paymentMutation.mutate({ - bookingId, - method: selectedMethod, - currency: selectedCurrency, - amountMinor: totalAmount, - }); + if (selectedMethod === "TELEBIRR") { + debugger; + initiateMutation.mutate(selectedMethod); + } else { + paymentMutation.mutate({ + bookingId, + method: selectedMethod, + currency: selectedCurrency, + amountMinor: totalAmount, + }); + } }; - // Redirect if no booking data (but not during navigation) useEffect(() => { - // Add a small delay to allow state to be set from previous page const timer = setTimeout(() => { - if (!bookingId || !pnr) { - console.log('Payment page: Missing booking data, redirecting to search'); - console.log('bookingId:', bookingId, 'pnr:', pnr); - router.push('/booking/search'); - } + if (!bookingId || !pnr) router.push("/booking/search"); }, 500); - return () => clearTimeout(timer); }, [bookingId, pnr, router]); @@ -157,63 +172,79 @@ export default function PaymentPage() { ); } + const isBusy = + isProcessing || initiateMutation.isPending || paymentMutation.isPending; + return (
-

Complete payment

+

+ Complete payment +

- Booking reference: {pnr} + Booking reference:{" "} + {pnr}

- {/* Payment Processing Overlay */} - {isProcessing && ( -
-
- {paymentMutation.isSuccess ? ( - <> - -

Payment successful!

-

Generating your tickets...

- - - ) : ( - <> - -

Processing payment

-

Please wait while we process your payment...

- - )} + {/* Processing overlay */} + {isBusy && ( +
+
+ +

+ Processing payment +

+

+ Please wait… +

)} - {/* Order Summary */} + {/* Order summary */}
-

Order summary

+

+ Order summary +

Route - {selectedSchedule?.origin} β†’ {selectedSchedule?.destination} + + {selectedSchedule?.origin} β†’ {selectedSchedule?.destination} +
Train - {selectedSchedule?.trainNumber} + + {selectedSchedule?.trainNumber} +
{selectedSchedule?.selectedSeatClassName && (
- Class - {selectedSchedule.selectedSeatClassName.replace(/_/g, ' ')} + + Class + + + {selectedSchedule.selectedSeatClassName.replace(/_/g, " ")} +
)}
- Passengers - {passengers.length} passenger{passengers.length !== 1 ? 's' : ''} + + Passengers + + + {passengers.length} passenger + {passengers.length !== 1 ? "s" : ""} +
- Total amount - + + Total amount + + ETB {(totalAmount / 100).toFixed(2)}
@@ -221,87 +252,122 @@ export default function PaymentPage() {
- {/* Payment Methods */} + {/* Payment methods */}
-

Select payment method

-
- {paymentMethods.map((method) => { - const Icon = method.icon; - const isSelected = selectedMethod === method.id; - return ( - - ); - })} -
+
+

+ {name} +

+

+ {description} +

+
+ {code === "TELEBIRR" && ( + + + Redirect + + )} + {isSelected && ( +
+ +
+ )} +
+ + ); + })} +
+ )}
- {/* Action Buttons */} + {/* Actions */}
-
- - {/* Error Message */} - {paymentMutation.isError && ( -
-

- ⚠️ Payment failed. Please try again or contact support if the problem persists. + + {(initiateMutation.isError || paymentMutation.isError) && ( +

+

+ ⚠️ Payment failed. Please try again or contact support.

)} - {/* Security Notice */} -
-

- πŸ”’ Your payment is secure and encrypted. We do not store your payment information. +

+

+ πŸ”’ Your payment is secure and encrypted. We do not store your + payment information.

From 13e7fbbab07e5ea9a8d5f952034eae392b4bd955 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Sun, 14 Jun 2026 20:28:10 +0300 Subject: [PATCH 3/4] Update home page and fix build error --- .../portal/src/app/booking/search/page.tsx | 7 +++---- apps/edr-passenger-web/portal/src/app/page.tsx | 11 ++++++++++- 2 files changed, 13 insertions(+), 5 deletions(-) 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 98ce04738..819b59d6d 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 @@ -10,7 +10,7 @@ import { apiClient } from '@/lib/api-client'; import { useBookingStore } from '@/lib/booking-store'; import { Station } from '@/types'; import { - Train, MapPin, ArrowRight, ArrowLeftRight, Plus, Minus, Search, + MapPin, ArrowRight, ArrowLeftRight, Plus, Minus, Search, Users, ChevronDown, Gift, Check, X, ChevronLeft, Clock, Zap, } from 'lucide-react'; import { useEffect, useRef, useState, useCallback } from 'react'; @@ -393,7 +393,6 @@ export default function SearchPage() { const setSearchCriteria = useBookingStore((s) => s.setSearchCriteria); const { user, isAuthenticated } = useAuthStore(); - const [isPassengerOpen, setIsPassengerOpen] = useState(false); const [passengerModalOpen, setPassengerModalOpen] = useState(false); const [promoVisible, setPromoVisible] = useState(false); const [promoCode, setPromoCode] = useState(''); @@ -421,7 +420,7 @@ export default function SearchPage() { queryFn: async () => await apiClient.get('/stations') as Station[], }); - const { register, handleSubmit, watch, setValue, formState: { errors } } = useForm({ + const { handleSubmit, watch, setValue, formState: { errors } } = useForm({ resolver: zodResolver(searchSchema as any), defaultValues: { adultCount: 1, @@ -457,7 +456,7 @@ export default function SearchPage() { useEffect(() => { const handler = (e: MouseEvent) => { if (passengerRef.current && !passengerRef.current.contains(e.target as Node)) { - setIsPassengerOpen(false); + setPassengerModalOpen(false); } }; document.addEventListener('mousedown', handler); diff --git a/apps/edr-passenger-web/portal/src/app/page.tsx b/apps/edr-passenger-web/portal/src/app/page.tsx index c48063991..5e717705f 100644 --- a/apps/edr-passenger-web/portal/src/app/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/page.tsx @@ -1 +1,10 @@ -export { default } from '@/app/booking/search/page'; +import { Suspense } from 'react'; +import SearchPage from '@/app/booking/search/page'; + +export default function Home() { + return ( + + + + ); +} From 4a0773e1a5650a95e5c47c3c532ee87cda58c22b Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Sun, 14 Jun 2026 20:40:54 +0300 Subject: [PATCH 4/4] Revert payment page --- .../portal/src/app/booking/payment/page.tsx | 387 +++++++++--------- 1 file changed, 191 insertions(+), 196 deletions(-) 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 716db1168..d271e0042 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 @@ -3,7 +3,7 @@ import { useRouter } from "next/navigation"; import { useBookingStore } from "@/lib/booking-store"; import { usePaymentStore } from "@/lib/payment-store"; -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useMutation } from "@tanstack/react-query"; import { apiClient } from "@/lib/api-client"; import { useState, useEffect } from "react"; import { @@ -12,24 +12,46 @@ import { Wallet, Loader2, CheckCircle, - ExternalLink, } from "lucide-react"; -const METHOD_ICONS: Record = { - TELEBIRR: Smartphone, - CBE_BIRR: Smartphone, - EBIRR: Smartphone, - CARD: CreditCard, - WALLET: Wallet, -}; - -const METHOD_COLORS: Record = { - TELEBIRR: "bg-orange-50 dark:bg-orange-900/20", - CBE_BIRR: "bg-blue-50 dark:bg-blue-900/20", - EBIRR: "bg-green-50 dark:bg-green-900/20", - CARD: "bg-purple-50 dark:bg-purple-900/20", - WALLET: "bg-indigo-50 dark:bg-indigo-900/20", -}; +// Mock payment methods with Ethiopian providers +const paymentMethods = [ + { + id: "TELEBIRR", + name: "Telebirr", + icon: Smartphone, + description: "Pay with Telebirr mobile money", + color: "bg-orange-50 border-orange-200 hover:border-orange-400", + }, + { + id: "CBE_BIRR", + name: "CBE Birr", + icon: Smartphone, + description: "Pay with CBE Birr", + color: "bg-blue-50 border-blue-200 hover:border-blue-400", + }, + { + id: "EBIRR", + name: "eBirr", + icon: Smartphone, + description: "Pay with eBirr", + color: "bg-green-50 border-green-200 hover:border-green-400", + }, + { + id: "CARD", + name: "Card Payment", + icon: CreditCard, + description: "Pay with credit/debit card", + color: "bg-purple-50 border-purple-200 hover:border-purple-400", + }, + { + id: "WALLET", + name: "Wallet", + icon: Wallet, + description: "Pay from your wallet balance", + color: "bg-indigo-50 border-indigo-200 hover:border-indigo-400", + }, +]; export default function PaymentPage() { const router = useRouter(); @@ -39,100 +61,76 @@ export default function PaymentPage() { const [selectedMethod, setSelectedMethod] = useState(null); const [isProcessing, setIsProcessing] = useState(false); + // Calculate total amount const baseFare = passengers.reduce( (sum) => sum + (selectedSchedule?.baseFareAdult || 0), 0, ); const totalAmount = baseFare; - // Fetch real payment methods - const { data: methodsData, isLoading: methodsLoading } = useQuery({ - queryKey: ["payment-methods"], - queryFn: () => apiClient.get("/payments/methods") as Promise, - }); - - const paymentMethods: any[] = Array.isArray(methodsData) - ? methodsData - : (methodsData as any)?.methods || (methodsData as any)?.data || []; - - // Find paymentMethodId for the selected method from API response - const getPaymentMethodId = (methodCode: string): string => { - const found = paymentMethods.find( - (m: any) => - m.code === methodCode || - m.type === methodCode || - m.name?.toUpperCase().replace(/\s/g, "_") === methodCode || - m.id === methodCode, - ); - return found?.id || found?.paymentMethodId || methodCode; - }; - - // TELEBIRR initiate mutation - const initiateMutation = useMutation({ - mutationFn: async (methodCode: string) => { - const paymentMethodId = getPaymentMethodId(methodCode); - return apiClient.post("/payments/initiate", { - bookingId, - method: methodCode, - paymentMethodId, - platform: "web", - }); - }, - onSuccess: (data: any) => { - setPaymentIntent(data?.paymentIntentId || data?.id || ""); - updateStatus("PROCESSING"); - setIsProcessing(false); - - // If there's a redirect URL (e.g. Telebirr checkout page), open it - if (data?.checkoutUrl || data?.redirectUrl || data?.paymentUrl) { - window.open( - data.checkoutUrl || data.redirectUrl || data.paymentUrl, - "_blank", - ); - } else { - router.push("/booking/confirmation"); - } - }, - onError: (error: any) => { - console.error("Payment initiation failed:", error); - updateStatus("FAILED"); - const msg = - error?.response?.data?.message || - error?.message || - "Payment failed. Please try again."; - alert(msg); - setIsProcessing(false); - }, - }); - - // Generic payment intent mutation (for non-Telebirr methods) const paymentMutation = useMutation({ mutationFn: async (data: any) => { + // Try to call the real API, fallback to mock if it fails try { return await apiClient.post("/payments/intent", data); - } catch { + } catch (error) { + console.log("Payment API not available, using mock payment"); + // Mock payment response return { paymentIntentId: `mock-payment-${Date.now()}`, status: "PENDING", + amountMinor: data.amountMinor, + currency: data.currency, + method: data.method, }; } }, onSuccess: async (data: any) => { setPaymentIntent(data.paymentIntentId); updateStatus("PROCESSING"); + + // Simulate payment processing await new Promise((resolve) => setTimeout(resolve, 2000)); - updateStatus("SUCCEEDED"); - router.push("/booking/confirmation"); + + // Generate tickets after successful payment + try { + await generateTickets(); + updateStatus("SUCCEEDED"); + router.push("/booking/confirmation"); + } catch (error) { + console.error("Ticket generation failed:", error); + // Still proceed to confirmation even if ticket generation fails + updateStatus("SUCCEEDED"); + router.push("/booking/confirmation"); + } }, onError: (error: any) => { + console.error("Payment failed:", error); updateStatus("FAILED"); - alert( - error?.response?.data?.message || "Payment failed. Please try again.", - ); + const errorMessage = + error?.response?.data?.message || + error?.message || + "Payment failed. Please try again."; + alert(errorMessage); setIsProcessing(false); }, }); + const generateTickets = async () => { + // Try to generate tickets via API, fallback to mock + try { + await apiClient.post("/tickets/generate", { + bookingId, + pnr, + }); + } catch (error) { + console.log( + "Ticket API not available, tickets will be generated on confirmation page", + ); + // Mock ticket generation - tickets will be displayed on confirmation page + } + }; + const handlePayment = async () => { if (!selectedMethod || !bookingId) { alert("Please select a payment method"); @@ -141,23 +139,27 @@ export default function PaymentPage() { setIsProcessing(true); - if (selectedMethod === "TELEBIRR") { - debugger; - initiateMutation.mutate(selectedMethod); - } else { - paymentMutation.mutate({ - bookingId, - method: selectedMethod, - currency: selectedCurrency, - amountMinor: totalAmount, - }); - } + paymentMutation.mutate({ + bookingId, + method: selectedMethod, + currency: selectedCurrency, + amountMinor: totalAmount, + }); }; + // Redirect if no booking data (but not during navigation) useEffect(() => { + // Add a small delay to allow state to be set from previous page const timer = setTimeout(() => { - if (!bookingId || !pnr) router.push("/booking/search"); + if (!bookingId || !pnr) { + console.log( + "Payment page: Missing booking data, redirecting to search", + ); + console.log("bookingId:", bookingId, "pnr:", pnr); + router.push("/booking/search"); + } }, 500); + return () => clearTimeout(timer); }, [bookingId, pnr, router]); @@ -172,9 +174,6 @@ export default function PaymentPage() { ); } - const isBusy = - isProcessing || initiateMutation.isPending || paymentMutation.isPending; - return (
@@ -187,22 +186,37 @@ export default function PaymentPage() { {pnr}

- {/* Processing overlay */} - {isBusy && ( -
-
- -

- Processing payment -

-

- Please wait… -

+ {/* Payment Processing Overlay */} + {isProcessing && ( +
+
+ {paymentMutation.isSuccess ? ( + <> + +

+ Payment successful! +

+

+ Generating your tickets... +

+ + + ) : ( + <> + +

+ Processing payment +

+

+ Please wait while we process your payment... +

+ + )}
)} - {/* Order summary */} + {/* Order Summary */}

Order summary @@ -244,7 +258,7 @@ export default function PaymentPage() { Total amount - + ETB {(totalAmount / 100).toFixed(2)}

@@ -252,120 +266,101 @@ export default function PaymentPage() {
- {/* Payment methods */} + {/* Payment Methods */}

Select payment method

- - {methodsLoading ? ( -
- - Loading payment methods… -
- ) : paymentMethods.length === 0 ? ( -

- No payment methods available. -

- ) : ( -
- {paymentMethods.map((method: any) => { - const code: string = - method.code || method.type || method.id || ""; - const name: string = - method.name || method.displayName || code; - const description: string = - method.description || `Pay with ${name}`; - const isSelected = selectedMethod === code; - const Icon = METHOD_ICONS[code] || Smartphone; - const bgColor = - METHOD_COLORS[code] || "bg-gray-50 dark:bg-gray-800/50"; - - return ( - - ); - })} -
- )} +
+

+ {method.name} +

+

+ {method.description} +

+
+ {isSelected && ( +
+ +
+ )} +
+ + ); + })} +
- {/* Actions */} + {/* Action Buttons */}
+
- {(initiateMutation.isError || paymentMutation.isError) && ( -
-

- ⚠️ Payment failed. Please try again or contact support. + {/* Error Message */} + {paymentMutation.isError && ( +

+

+ ⚠️ Payment failed. Please try again or contact support if the + problem persists.

)} -
-

+ {/* Security Notice */} +

+

πŸ”’ Your payment is secure and encrypted. We do not store your payment information.