Passenger portal UI and UX updates: landing mini site added

This commit is contained in:
Stephanos A
2026-06-04 13:45:23 +03:00
parent 6ad051fba4
commit 87a27677d9
27 changed files with 3813 additions and 236 deletions

View File

@@ -0,0 +1,12 @@
# Banner Image
Place your banner image as `banner.jpg` in this directory.
## Recommended Specifications:
- **Filename**: `banner.jpg` (or `banner.png`)
- **Dimensions**: 1920x1080px or higher
- **Aspect Ratio**: 16:9 or similar
- **Content**: Railway/train themed image, Ethio-Djibouti Railway scenery
- **Format**: JPG or PNG
The image will be used as a background on the login page with a green overlay.

Binary file not shown.

After

Width:  |  Height:  |  Size: 734 KiB

View File

@@ -0,0 +1,411 @@
'use client';
import { useEffect, useState } from 'react';
import { getTranslation, Language, useLanguage } from '@/lib/i18n';
import Link from 'next/link';
import { Target, Globe, Leaf, Users } from 'lucide-react';
const styles = `
.about-hero {
padding: 60px 20px;
background: linear-gradient(to bottom right, rgb(20, 113, 76), transparent);
text-align: center;
color: #111827;
}
.dark .about-hero {
color: #f3f4f6;
}
.about-hero h1 {
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 16px;
color: #111827;
}
.dark .about-hero h1 {
color: #f3f4f6;
}
.about-hero p {
font-size: 1.125rem;
color: #6b7280;
}
.dark .about-hero p {
color: #9ca3af;
}
.values-grid {
max-width: 80rem;
margin: 0 auto;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 32px;
padding: 60px 20px;
background-color: white;
}
.dark .values-grid {
background-color: #111827;
}
.value-card {
background: white;
border: 1px solid #e5e7eb;
border-radius: 18px;
padding: 24px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.dark .value-card {
background: #1f2937;
border-color: #374151;
}
.value-card:hover {
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.value-icon {
width: 48px;
height: 48px;
background: rgb(20, 113, 76);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
}
.value-card h3 {
font-size: 1.25rem;
font-weight: 700;
margin-bottom: 12px;
color: #111827;
}
.dark .value-card h3 {
color: #f3f4f6;
}
.value-card p {
font-size: 0.875rem;
color: #6b7280;
}
.dark .value-card p {
color: #9ca3af;
}
.stats-section {
padding: 60px 20px;
background-color: #f9fafb;
}
.dark .stats-section {
background-color: #0f1117;
}
.stats-container {
max-width: 80rem;
margin: 0 auto;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 32px;
text-align: center;
}
.stat {
padding: 20px;
}
.stat-number {
font-size: 2rem;
font-weight: 700;
color: rgb(20, 113, 76);
margin-bottom: 8px;
}
.stat-label {
font-size: 0.875rem;
color: #6b7280;
}
.dark .stat-label {
color: #9ca3af;
}
.timeline-section {
padding: 60px 20px;
background: linear-gradient(135deg, #f9fafb 0%, #f3f4f6 100%);
}
.dark .timeline-section {
background: linear-gradient(135deg, #111827 0%, #0f1117 100%);
}
.timeline-title {
text-align: center;
font-size: 2rem;
font-weight: 700;
margin-bottom: 48px;
color: #111827;
}
.dark .timeline-title {
color: #f3f4f6;
}
.timeline {
max-width: 48rem;
margin: 0 auto;
position: relative;
}
.timeline::before {
content: '';
position: absolute;
left: 8px;
top: 0;
bottom: 0;
width: 2px;
background: linear-gradient(180deg, rgb(20, 113, 76), rgb(20, 113, 76) 50%, transparent);
}
.timeline-item {
display: flex;
margin-bottom: 40px;
position: relative;
padding-left: 56px;
animation: slideInLeft 0.6s ease-out forwards;
opacity: 0;
}
.timeline-item:nth-child(1) { animation-delay: 0.1s; }
.timeline-item:nth-child(2) { animation-delay: 0.2s; }
.timeline-item:nth-child(3) { animation-delay: 0.3s; }
.timeline-item:nth-child(4) { animation-delay: 0.4s; }
.timeline-item:nth-child(5) { animation-delay: 0.5s; }
@keyframes slideInLeft {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.timeline-dot {
position: absolute;
left: -4px;
top: 8px;
width: 24px;
height: 24px;
background: white;
border-radius: 50%;
border: 3px solid rgb(20, 113, 76);
box-shadow: 0 0 0 2px rgb(20, 113, 76), 0 4px 12px rgba(20, 113, 76, 0.3);
transition: all 0.3s ease;
}
.timeline-item:hover .timeline-dot {
box-shadow: 0 0 0 2px rgb(20, 113, 76), 0 8px 24px rgba(20, 113, 76, 0.5);
transform: scale(1.15);
}
.dark .timeline-dot {
background: #1f2937;
}
.timeline-content {
background: white;
border-radius: 12px;
padding: 20px 24px;
border: 2px solid transparent;
border-left: 4px solid rgb(20, 113, 76);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
transition: all 0.3s ease;
flex: 1;
}
.timeline-item:hover .timeline-content {
border-color: rgb(20, 113, 76);
box-shadow: 0 8px 24px rgba(20, 113, 76, 0.15);
transform: translateY(-4px);
}
.dark .timeline-content {
background: #1f2937;
border-left-color: rgb(20, 113, 76);
}
.timeline-year {
font-weight: 700;
color: rgb(20, 113, 76);
font-size: 1.125rem;
display: flex;
align-items: center;
gap: 8px;
}
.timeline-year::before {
content: '📅';
}
.timeline-event {
color: #6b7280;
margin-top: 8px;
font-size: 0.95rem;
font-weight: 500;
}
.dark .timeline-event {
color: #d1d5db;
}
.cta-blue {
background-color: rgb(20, 113, 76);
color: white;
padding: 60px 20px;
text-align: center;
}
.cta-blue h2 {
font-size: 2rem;
font-weight: 700;
margin-bottom: 16px;
}
.cta-blue p {
font-size: 1.125rem;
margin-bottom: 32px;
max-width: 42rem;
margin-left: auto;
margin-right: auto;
}
.button-white {
display: inline-block;
padding: 16px 32px;
background-color: white;
color: rgb(20, 113, 76);
font-weight: 700;
border-radius: 12px;
text-decoration: none;
transition: all 0.2s;
}
.button-white:hover {
transform: scale(1.05);
}
@media (max-width: 768px) {
.values-grid {
grid-template-columns: 1fr;
}
.about-hero h1 {
font-size: 1.875rem;
}
}
`;
export default function About() {
const [lang, setLang] = useState<Language>('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 values = [
{ icon: Target, title: t('about.mission'), desc: t('about.missionText') },
{ icon: Globe, title: t('about.network'), desc: t('about.networkText') },
{ icon: Users, title: t('about.comfort'), desc: t('about.comfortText') },
{ icon: Leaf, title: t('about.eco'), desc: t('about.ecoText') },
];
return (
<>
<style>{styles}</style>
<main>
<section className="about-hero">
<h1>{t('about.title')}</h1>
<p>{t('about.subtitle')}</p>
</section>
<section className="values-grid">
{values.map((value, idx) => {
const Icon = value.icon;
return (
<div key={idx} className="value-card">
<div className="value-icon">
<Icon size={24} color="white" />
</div>
<h3>{value.title}</h3>
<p>{value.desc}</p>
</div>
);
})}
</section>
<section className="stats-section">
<div className="stats-container">
<div className="stat">
<div className="stat-number">21</div>
<div className="stat-label">Railway Stations</div>
</div>
<div className="stat">
<div className="stat-number">360+</div>
<div className="stat-label">Comfortable Seats</div>
</div>
<div className="stat">
<div className="stat-number">3</div>
<div className="stat-label">Seat Classes</div>
</div>
<div className="stat">
<div className="stat-number">24/7</div>
<div className="stat-label">Customer Support</div>
</div>
</div>
</section>
<section className="timeline-section">
<h2 className="timeline-title">Our Journey</h2>
<div className="timeline">
{[
{ year: '2020', event: 'EDR Platform Launched' },
{ year: '2021', event: 'Reached 10,000+ Passengers' },
{ year: '2022', event: 'Introduced Multi-Currency Support' },
{ year: '2023', event: 'Launched Loyalty Program' },
{ year: '2024', event: 'Age-Based Pricing & Verifayda Integration' },
].map((item, idx) => (
<div key={idx} className="timeline-item">
<div className="timeline-dot" />
<div className="timeline-content">
<div className="timeline-year">{item.year}</div>
<div className="timeline-event">{item.event}</div>
</div>
</div>
))}
</div>
</section>
<section className="cta-blue">
<h2>Join Our Community</h2>
<p>Be part of the modern railway revolution in East Africa.</p>
<Link href="/booking/search" className="button-white">Book Your First Journey</Link>
</section>
</main>
</>
);
}

View File

@@ -82,7 +82,7 @@ export default function ConfirmationPage() {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
<div className="container mx-auto px-4">
<div className="max-w-5xl mx-auto">
<div className="max-w-6xl mx-auto">
{/* Success Header */}
<div className="text-center mb-8">
<div className="flex justify-center mb-4">

View File

@@ -343,7 +343,7 @@ export default function PassengersPage() {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
<div className="container mx-auto px-4">
<div className="max-w-4xl mx-auto">
<div className="max-w-6xl mx-auto">
<h1 className="text-3xl font-bold mb-6 text-gray-900 dark:text-gray-100">Passenger details</h1>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">

View File

@@ -160,7 +160,7 @@ export default function PaymentPage() {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
<div className="container mx-auto px-4">
<div className="max-w-3xl mx-auto">
<div className="max-w-6xl mx-auto">
<h1 className="text-3xl font-bold mb-2 text-gray-900 dark:text-gray-100">Complete payment</h1>
<p className="text-gray-600 dark:text-gray-400 mb-6">
Booking reference: <span className="font-bold text-primary">{pnr}</span>

View File

@@ -155,7 +155,7 @@ export default function ResultsPage() {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-4 md:py-6">
<div className="container mx-auto px-4">
<div className="max-w-5xl mx-auto">
<div className="max-w-6xl mx-auto">
<div className="mb-8">
<button
onClick={() => router.push(buildSearchUrl())}
@@ -202,7 +202,7 @@ export default function ResultsPage() {
<div className="flex flex-col lg:flex-row lg:items-center gap-6">
<div className="flex-1">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-primary-100 dark:bg-primary-900/30 rounded-lg flex items-center justify-center flex-shrink-0">
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center flex-shrink-0">
<Clock className="w-5 h-5 text-primary" />
</div>
<div>
@@ -295,9 +295,9 @@ export default function ResultsPage() {
disabled={!isAvailable}
className={`relative p-4 rounded-lg border-2 text-left transition-all ${
isSelected
? 'border-primary bg-primary-50 dark:bg-primary-900/20 shadow-md'
? 'border-primary bg-blue-50 dark:bg-blue-900/20 shadow-md'
: isAvailable
? 'border-gray-200 dark:border-gray-700 hover:border-primary-300 hover:shadow-sm'
? 'border-gray-200 dark:border-gray-700 hover:border-blue-300 hover:shadow-sm'
: 'border-gray-100 dark:border-gray-800 bg-gray-50 dark:bg-gray-800 opacity-60 cursor-not-allowed'
}`}
>

View File

@@ -328,7 +328,7 @@ export default function ReviewPage() {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
<div className="container mx-auto px-4">
<div className="max-w-4xl mx-auto">
<div className="max-w-6xl mx-auto">
<h1 className="text-3xl font-bold mb-6 text-gray-900 dark:text-gray-100">Review your booking</h1>
{seatHold && (

View File

@@ -9,8 +9,8 @@ import { useAuthStore } from '@/lib/auth-store';
import { apiClient } from '@/lib/api-client';
import { useBookingStore } from '@/lib/booking-store';
import { Station } from '@/types';
import { Train, MapPin, Calendar, ArrowRight, ArrowLeftRight, Plus, Minus, Search } from 'lucide-react';
import { useEffect } from 'react';
import { Train, MapPin, ArrowRight, Plus, Minus, Search, Users, ChevronDown } from 'lucide-react';
import { useEffect, useState } from 'react';
import ModernDatePicker from '@/components/ModernDatePicker';
const searchSchema = z.object({
@@ -32,6 +32,7 @@ export default function SearchPage() {
const searchParams = useSearchParams();
const setSearchCriteria = useBookingStore((s) => s.setSearchCriteria);
const { user, isAuthenticated } = useAuthStore();
const [isPassengerOpen, setIsPassengerOpen] = useState(false);
const { data: stations, isLoading, error } = useQuery<Station[]>({
queryKey: ['stations'],
@@ -51,11 +52,9 @@ export default function SearchPage() {
},
});
// Set user's nationality after component mounts and user data is available
useEffect(() => {
if (isAuthenticated && user?.nationality) {
const normalized = user.nationality.toUpperCase().trim();
console.log('User nationality from store:', user.nationality, 'Normalized:', normalized);
if (normalized.includes('DJIBOUTIAN') || normalized === 'DJIBOUTIAN') {
setValue('nationality', 'DJIBOUTIAN');
} else if (normalized.includes('ETHIOPIAN') || normalized === 'ETHIOPIAN') {
@@ -66,7 +65,6 @@ export default function SearchPage() {
}
}, [isAuthenticated, user?.nationality, setValue]);
// Restore previous search values from URL params
useEffect(() => {
const origin = searchParams.get('origin');
const destination = searchParams.get('destination');
@@ -84,19 +82,9 @@ export default function SearchPage() {
}, [searchParams, setValue]);
const originId = watch('originStationId');
const destinationId = watch('destinationStationId');
const adultCount = watch('adultCount');
const childCount = watch('childCount');
const swapStations = () => {
if (originId && destinationId) {
const tempOrigin = originId;
const tempDestination = destinationId;
setValue('originStationId', tempDestination);
setValue('destinationStationId', tempOrigin);
}
};
const onSubmit = (data: SearchForm) => {
setSearchCriteria(data);
const params = new URLSearchParams({
@@ -134,11 +122,13 @@ export default function SearchPage() {
{ from: 'Diredawa', to: 'Nagad', duration: '4h' },
];
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
{/* Search Section */}
<div className="container mx-auto px-4 py-8 md:py-12">
<div className="max-w-5xl mx-auto">
<div className="max-w-6xl mx-auto">
{/* Search Card */}
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 overflow-visible">
{/* Header inside card */}
@@ -161,17 +151,19 @@ export default function SearchPage() {
)}
<form onSubmit={handleSubmit(onSubmit)} className="p-6 md:p-8">
<div className="grid md:grid-cols-[1fr_auto_1fr] gap-4 items-end mb-6">
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">From</label>
{/* First Row: From, To, Date */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end mb-4">
{/* From */}
<div className="space-y-2 md:col-span-1">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 text-left">From</label>
<div className="relative">
<MapPin className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 dark:text-gray-500" />
<MapPin className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-primary" />
<select
{...register('originStationId')}
className="w-full pl-11 pr-4 py-3.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent disabled:bg-gray-50 dark:disabled:bg-gray-800 disabled:cursor-not-allowed text-base bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
disabled={isLoading}
>
<option value="">Select departure station</option>
<option value="">Select departure</option>
{stations?.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
@@ -182,25 +174,17 @@ export default function SearchPage() {
)}
</div>
<button
type="button"
onClick={swapStations}
className="hidden md:flex items-center justify-center w-10 h-10 rounded-full border-2 border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 hover:border-primary transition-all mb-2"
title="Swap stations"
>
<ArrowLeftRight className="w-5 h-5 text-gray-600 dark:text-gray-300" />
</button>
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">To</label>
{/* To */}
<div className="space-y-2 md:col-span-1">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 text-left">To</label>
<div className="relative">
<MapPin className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 dark:text-gray-500" />
<MapPin className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-primary" />
<select
{...register('destinationStationId')}
className="w-full pl-11 pr-4 py-3.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent disabled:bg-gray-50 dark:disabled:bg-gray-800 disabled:cursor-not-allowed text-base bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
disabled={isLoading}
>
<option value="">Select arrival station</option>
<option value="">Select arrival</option>
{stations?.map((s) => (
<option key={s.id} value={s.id} disabled={s.id === originId}>{s.name}</option>
))}
@@ -210,11 +194,10 @@ export default function SearchPage() {
<p className="text-red-600 dark:text-red-400 text-sm">{errors.destinationStationId.message}</p>
)}
</div>
</div>
<div className="grid md:grid-cols-2 gap-6 mb-6">
<div className="space-y-2 relative z-10">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Departure Date</label>
{/* Date */}
<div className="space-y-2 relative z-30 md:col-span-1">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 text-left">Date</label>
<ModernDatePicker
value={watch('departureDate') ? new Date(watch('departureDate') + 'T00:00:00') : undefined}
onChange={(date) => {
@@ -224,99 +207,142 @@ export default function SearchPage() {
setValue('departureDate', `${year}-${month}-${day}`);
}}
minDate={new Date()}
placeholder="Select departure date"
placeholder="Select date"
/>
{errors.departureDate && (
<p className="text-red-600 dark:text-red-400 text-sm">{errors.departureDate.message}</p>
)}
</div>
</div>
{/* Second Row: Passengers, Nationality, Promo Code */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end mb-4">
{/* Passengers Dropdown */}
<div className="space-y-2 relative z-20">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 text-left">Passengers</label>
<button
type="button"
onClick={() => setIsPassengerOpen(!isPassengerOpen)}
className="w-full px-4 py-3.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-base bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 flex items-center justify-between hover:border-primary transition-colors"
>
<span className="flex items-center gap-2">
<Users className="w-4 h-4 text-primary" />
{(adultCount || 1) + (childCount || 0)} Passenger{((adultCount || 1) + (childCount || 0)) !== 1 ? 's' : ''}
</span>
<ChevronDown className={`w-4 h-4 transition-transform text-primary ${isPassengerOpen ? 'rotate-180' : ''}`} />
</button>
{/* Passenger Dropdown Menu */}
{isPassengerOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setIsPassengerOpen(false)} />
<div className="absolute top-full left-0 right-0 mt-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg z-50 p-4 space-y-4">
{/* Adults */}
<div>
<div className="flex items-center justify-between mb-2">
<div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">Adults</div>
<div className="text-xs text-gray-500 dark:text-gray-400">5 years</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
const current = adultCount || 1;
if (current > 1) setValue('adultCount', current - 1);
}}
className="w-7 h-7 rounded-full border border-gray-300 dark:border-gray-600 flex items-center justify-center hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50"
disabled={(adultCount || 1) <= 1}
>
<Minus className="w-4 h-4 text-primary" />
</button>
<span className="w-6 text-center font-semibold text-gray-900 dark:text-gray-100">{adultCount || 1}</span>
<button
type="button"
onClick={() => {
const current = adultCount || 1;
if (current < 9) setValue('adultCount', current + 1);
}}
className="w-7 h-7 rounded-full border border-gray-300 dark:border-gray-600 flex items-center justify-center hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50"
disabled={(adultCount || 1) >= 9}
>
<Plus className="w-4 h-4 text-primary" />
</button>
</div>
</div>
</div>
{/* Children */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">Children</div>
<div className="text-xs text-gray-500 dark:text-gray-400">&lt;5 years First free</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
const current = childCount || 0;
if (current > 0) setValue('childCount', current - 1);
}}
className="w-7 h-7 rounded-full border border-gray-300 dark:border-gray-600 flex items-center justify-center hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50"
disabled={(childCount || 0) <= 0}
>
<Minus className="w-4 h-4 text-primary" />
</button>
<span className="w-6 text-center font-semibold text-gray-900 dark:text-gray-100">{childCount || 0}</span>
<button
type="button"
onClick={() => {
const current = childCount || 0;
if (current < 9) setValue('childCount', current + 1);
}}
className="w-7 h-7 rounded-full border border-gray-300 dark:border-gray-600 flex items-center justify-center hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50"
disabled={(childCount || 0) >= 9}
>
<Plus className="w-4 h-4 text-primary" />
</button>
</div>
</div>
</div>
</div>
</>
)}
</div>
{/* Nationality */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Passengers</label>
<div className="border border-gray-300 dark:border-gray-600 rounded-lg p-3 bg-white dark:bg-gray-700">
<div className="flex items-center justify-between mb-3">
<div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">Adults</div>
<div className="text-xs text-gray-500 dark:text-gray-400">5 years</div>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => {
const current = adultCount || 1;
if (current > 1) setValue('adultCount', current - 1);
}}
className="w-8 h-8 rounded-full border border-gray-300 dark:border-gray-600 flex items-center justify-center hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50"
disabled={(adultCount || 1) <= 1}
>
<Minus className="w-4 h-4 text-gray-700 dark:text-gray-300" />
</button>
<span className="w-8 text-center font-semibold text-gray-900 dark:text-gray-100">{adultCount || 1}</span>
<button
type="button"
onClick={() => {
const current = adultCount || 1;
if (current < 9) setValue('adultCount', current + 1);
}}
className="w-8 h-8 rounded-full border border-gray-300 dark:border-gray-600 flex items-center justify-center hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50"
disabled={(adultCount || 1) >= 9}
>
<Plus className="w-4 h-4 text-gray-700 dark:text-gray-300" />
</button>
</div>
</div>
<div className="flex items-center justify-between pt-3 border-t border-gray-200 dark:border-gray-600">
<div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">Children</div>
<div className="text-xs text-gray-500 dark:text-gray-400">&lt;5 years First child free</div>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => {
const current = childCount || 0;
if (current > 0) setValue('childCount', current - 1);
}}
className="w-8 h-8 rounded-full border border-gray-300 dark:border-gray-600 flex items-center justify-center hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50"
disabled={(childCount || 0) <= 0}
>
<Minus className="w-4 h-4 text-gray-700 dark:text-gray-300" />
</button>
<span className="w-8 text-center font-semibold text-gray-900 dark:text-gray-100">{childCount || 0}</span>
<button
type="button"
onClick={() => {
const current = childCount || 0;
if (current < 9) setValue('childCount', current + 1);
}}
className="w-8 h-8 rounded-full border border-gray-300 dark:border-gray-600 flex items-center justify-center hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50"
disabled={(childCount || 0) >= 9}
>
<Plus className="w-4 h-4 text-gray-700 dark:text-gray-300" />
</button>
</div>
</div>
</div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 text-left">Nationality</label>
<select {...register('nationality')} className="w-full px-4 py-3.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-base bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100">
<option value="ETHIOPIAN">Ethiopian</option>
<option value="DJIBOUTIAN">Djiboutian</option>
<option value="OTHER">Other</option>
</select>
</div>
{/* Promo Code */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 text-left">Promo Code (Optional)</label>
<input
type="text"
placeholder="Enter promo code"
className="w-full px-4 py-3.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-base bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400"
/>
</div>
</div>
<div className="space-y-2 mb-6">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Nationality</label>
<select {...register('nationality')} className="w-full px-4 py-3.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-base bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100">
<option value="">Select nationality</option>
<option value="ETHIOPIAN">Ethiopian</option>
<option value="DJIBOUTIAN">Djiboutian</option>
<option value="OTHER">Other</option>
</select>
{/* Third Row: Search Button */}
<div>
<button type="submit" className="w-full bg-[rgb(20_113_76)] hover:bg-[rgb(16_89_60)] text-white font-semibold py-3.5 px-6 rounded-lg transition-all duration-200 flex items-center justify-center gap-2 shadow-lg hover:shadow-xl">
<Search className="w-5 h-5 text-white" />
<span>Search Train</span>
</button>
</div>
<button type="submit" className="w-full bg-primary hover:bg-primary-700 text-white font-semibold py-4 px-6 rounded-lg transition-all duration-200 flex items-center justify-center gap-2 text-lg shadow-lg hover:shadow-xl">
<Search className="w-5 h-5" />
<span>Search Trains</span>
</button>
</form>
</div>
{/* Popular Routes */}
<div className="mt-12">
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-6">Popular Routes</h2>
<div className="grid md:grid-cols-3 gap-4">
@@ -330,7 +356,7 @@ export default function SearchPage() {
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<div className="font-semibold text-gray-900 dark:text-gray-100 mb-1">{route.from}</div>
<ArrowRight className="w-4 h-4 text-gray-400 dark:text-gray-500 my-2" />
<ArrowRight className="w-4 h-4 text-primary my-2" />
<div className="font-semibold text-gray-900 dark:text-gray-100">{route.to}</div>
</div>
<Train className="w-5 h-5 text-primary opacity-0 group-hover:opacity-100 transition-opacity" />
@@ -341,29 +367,6 @@ export default function SearchPage() {
</div>
</div>
<div className="mt-12 grid md:grid-cols-3 gap-6">
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-6">
<div className="w-12 h-12 bg-primary-100 dark:bg-primary-900/30 rounded-lg flex items-center justify-center mb-4">
<Train className="w-6 h-6 text-primary" />
</div>
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-2">Modern Fleet</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">Comfortable trains with modern amenities</p>
</div>
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-6">
<div className="w-12 h-12 bg-primary-100 dark:bg-primary-900/30 rounded-lg flex items-center justify-center mb-4">
<MapPin className="w-6 h-6 text-primary" />
</div>
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-2">21 Stations</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">Connecting Ethiopia and Djibouti</p>
</div>
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-6">
<div className="w-12 h-12 bg-primary-100 dark:bg-primary-900/30 rounded-lg flex items-center justify-center mb-4">
<Calendar className="w-6 h-6 text-primary" />
</div>
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-2">Easy Booking</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">Book tickets in just a few clicks</p>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,375 @@
'use client';
import { useEffect, useState } from 'react';
import { getTranslation, Language, useLanguage } from '@/lib/i18n';
import { Phone, Mail, MapPin, Send, Loader } from 'lucide-react';
const styles = `
.contact-hero {
padding: 60px 20px;
background: linear-gradient(to bottom right, rgb(20, 113, 76), transparent);
text-align: center;
color: #111827;
}
.dark .contact-hero {
color: #f3f4f6;
}
.contact-hero h1 {
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 16px;
color: #111827;
}
.dark .contact-hero h1 {
color: #f3f4f6;
}
.contact-hero p {
font-size: 1.125rem;
color: #6b7280;
}
.dark .contact-hero p {
color: #9ca3af;
}
.contact-grid {
max-width: 80rem;
margin: 0 auto;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 24px;
padding: 60px 20px;
background-color: white;
}
.dark .contact-grid {
background-color: #111827;
}
.contact-card {
background: white;
border: 2px solid #f3f4f6;
border-radius: 18px;
padding: 24px;
cursor: pointer;
text-align: center;
transition: all 0.2s;
}
.dark .contact-card {
background: #1f2937;
border-color: #374151;
}
.contact-card:hover {
border-color: rgb(20, 113, 76);
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
}
.contact-icon {
width: 48px;
height: 48px;
background: rgb(20, 113, 76);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 16px;
}
.contact-card h3 {
font-weight: 700;
margin-bottom: 8px;
color: #111827;
}
.dark .contact-card h3 {
color: #f3f4f6;
}
.contact-card p {
font-size: 0.875rem;
color: #6b7280;
}
.dark .contact-card p {
color: #9ca3af;
}
.contact-card a {
color: #6b7280;
text-decoration: none;
}
.contact-card a:hover {
color: rgb(20, 113, 76);
}
.form-section {
padding: 60px 20px;
background-color: #f9fafb;
}
.dark .form-section {
background-color: #0f1117;
}
.form-container {
max-width: 42rem;
margin: 0 auto;
background: white;
border-radius: 18px;
padding: 32px;
border: 1px solid #e5e7eb;
}
.dark .form-container {
background: #1f2937;
border-color: #374151;
}
.form-container h2 {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 24px;
color: #111827;
}
.dark .form-container h2 {
color: #f3f4f6;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: #374151;
margin-bottom: 8px;
}
.dark .form-group label {
color: #d1d5db;
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 12px 16px;
border: 2px solid #e5e7eb;
border-radius: 12px;
font-size: 1rem;
font-family: inherit;
transition: all 0.2s;
box-sizing: border-box;
background: white;
color: #111827;
}
.dark .form-group input,
.dark .form-group textarea {
background: #111827;
color: #f3f4f6;
border-color: #374151;
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: rgb(20, 113, 76);
box-shadow: 0 0 0 3px rgba(20, 113, 76, 0.1);
}
.form-submit {
width: 100%;
padding: 14px 20px;
background-color: rgb(20, 113, 76);
color: white;
border: none;
border-radius: 12px;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 8px;
}
.form-submit:hover {
background-color: rgb(16, 89, 60);
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
}
.form-submit:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.alert {
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 16px;
font-size: 0.875rem;
}
.alert-success {
background-color: #dbeafe;
color: #1e40af;
}
.dark .alert-success {
background-color: rgba(20, 113, 76, 0.1);
color: #a7f3d0;
}
.alert-error {
background-color: #fee2e2;
color: #991b1b;
}
.dark .alert-error {
background-color: rgba(239, 68, 68, 0.1);
color: #fca5a5;
}
`;
export default function Contact() {
const [lang, setLang] = useState<Language>('en');
const [formData, setFormData] = useState({ name: '', email: '', subject: '', message: '' });
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
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 handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
await new Promise(resolve => setTimeout(resolve, 1500));
setMessage({ type: 'success', text: t('contact.success') });
setFormData({ name: '', email: '', subject: '', message: '' });
} catch (error) {
setMessage({ type: 'error', text: t('contact.error') });
} finally {
setLoading(false);
}
};
const contactInfo = [
{ icon: Phone, title: t('contact.phone'), value: '+251 911 000 000', link: 'tel:+251911000000' },
{ icon: Mail, title: t('contact.email'), value: 'support@edr.et', link: 'mailto:support@edr.et' },
{ icon: MapPin, title: t('contact.address'), value: 'Addis Ababa, Ethiopia', link: '#' },
];
return (
<>
<style>{styles}</style>
<main>
<section className="contact-hero">
<h1>{t('contact.title')}</h1>
<p>{t('contact.subtitle')}</p>
</section>
<section className="contact-grid">
{contactInfo.map((info, idx) => {
const Icon = info.icon;
return (
<a key={idx} href={info.link} className="contact-card">
<div className="contact-icon">
<Icon size={24} color="white" />
</div>
<h3>{info.title}</h3>
<p>{info.value}</p>
</a>
);
})}
</section>
<section className="form-section">
<div className="form-container">
<h2>{t('contact.form')}</h2>
{message && (
<div className={`alert alert-${message.type === 'success' ? 'success' : 'error'}`}>
{message.text}
</div>
)}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>{t('contact.name')}</label>
<input
type="text"
required
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
/>
</div>
<div className="form-group">
<label>{t('contact.emailField')}</label>
<input
type="email"
required
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
/>
</div>
<div className="form-group">
<label>{t('contact.subject')}</label>
<input
type="text"
required
value={formData.subject}
onChange={(e) => setFormData({ ...formData, subject: e.target.value })}
/>
</div>
<div className="form-group">
<label>{t('contact.message')}</label>
<textarea
required
rows={5}
value={formData.message}
onChange={(e) => setFormData({ ...formData, message: e.target.value })}
/>
</div>
<button type="submit" disabled={loading} className="form-submit">
{loading ? (
<>
<Loader size={18} />
{t('contact.sending')}
</>
) : (
<>
<Send size={18} />
{t('contact.send')}
</>
)}
</button>
</form>
</div>
</section>
</main>
</>
);
}

View File

@@ -10,27 +10,47 @@
@layer components {
.btn-primary {
@apply bg-primary hover:bg-primary-700 text-white font-semibold py-3 px-6 rounded-xl transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed shadow-lg hover:shadow-xl transform hover:-translate-y-0.5;
@apply inline-flex items-center gap-2 px-6 py-3 bg-[rgb(20_113_76)] text-white font-semibold rounded-xl transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed shadow-lg hover:shadow-xl transform hover:-translate-y-0.5;
}
.btn-primary:hover {
@apply bg-[rgb(16_89_60)];
}
.btn-secondary {
@apply bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-800 dark:text-gray-200 font-semibold py-3 px-6 rounded-xl transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed border-2 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 shadow-md hover:shadow-lg;
@apply bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 font-semibold py-3 px-6 rounded-xl transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed border-2 border-gray-200 dark:border-gray-700 shadow-md hover:shadow-lg;
}
.btn-secondary:hover {
@apply bg-gray-50 dark:bg-gray-700 border-[rgb(20_113_76)] dark:border-[rgb(20_113_76)];
}
.btn-ghost {
@apply text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20 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;
}
.btn-ghost:hover {
@apply bg-[rgb(20_113_76)] bg-opacity-10 dark:bg-[rgb(20_113_76)] dark:bg-opacity-20;
}
.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-primary 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;
@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;
}
.card {
@apply bg-white dark:bg-gray-800 rounded-2xl shadow-sm p-6 border border-gray-100 dark:border-gray-700 hover:shadow-md transition-shadow duration-200;
@apply bg-white dark:bg-gray-800 rounded-2xl shadow-sm p-6 border border-gray-100 dark:border-gray-700;
}
.card:hover {
@apply shadow-md;
}
.card-interactive {
@apply bg-white dark:bg-gray-800 rounded-2xl shadow-sm p-6 border-2 border-gray-100 dark:border-gray-700 hover:border-primary hover:shadow-lg transition-all duration-200 cursor-pointer;
@apply bg-white dark:bg-gray-800 rounded-2xl shadow-sm p-6 border-2 border-gray-100 dark:border-gray-700 cursor-pointer transition-all duration-200;
}
.card-interactive:hover {
@apply border-[rgb(20_113_76)] shadow-lg;
}
.badge {
@@ -62,4 +82,79 @@
.text-balance {
text-wrap: balance;
}
@keyframes bounce-in {
0% {
opacity: 0;
transform: translateY(20px) scale(0.9);
}
50% {
opacity: 1;
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes float {
0%, 100% {
transform: translateY(0px);
}
50% {
transform: translateY(-20px);
}
}
@keyframes shimmer {
0% {
background-position: -1000px 0;
}
100% {
background-position: 1000px 0;
}
}
@keyframes slide-in-left {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes slide-in-right {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.animate-bounce-in {
animation: bounce-in 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.animate-float {
animation: float 6s ease-in-out infinite;
}
.animate-shimmer {
background-size: 1000px 100%;
animation: shimmer 2s infinite;
}
.animate-slide-in-left {
animation: slide-in-left 0.5s ease-out;
}
.animate-slide-in-right {
animation: slide-in-right 0.5s ease-out;
}
}

View File

@@ -0,0 +1,424 @@
'use client';
import { useEffect, useState } from 'react';
import { getTranslation, Language, useLanguage } from '@/lib/i18n';
import Link from 'next/link';
import { ChevronDown, Search, MessageCircle } from 'lucide-react';
interface FAQItem {
question: string;
answer: string;
}
interface FAQCategory {
title: string;
items: FAQItem[];
}
const styles = `
.help-hero {
padding: 60px 20px;
background: linear-gradient(to bottom right, rgb(20, 113, 76), transparent);
text-align: center;
color: #111827;
}
.dark .help-hero {
color: #f3f4f6;
}
.help-hero h1 {
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 16px;
color: #111827;
}
.dark .help-hero h1 {
color: #f3f4f6;
}
.help-hero p {
font-size: 1.125rem;
color: #6b7280;
}
.dark .help-hero p {
color: #9ca3af;
}
.search-section {
padding: 30px 20px;
background-color: white;
border-bottom: 1px solid #e5e7eb;
}
.dark .search-section {
background-color: #111827;
border-bottom-color: #374151;
}
.search-box {
max-width: 42rem;
margin: 0 auto;
position: relative;
}
.search-box input {
width: 100%;
padding: 12px 40px 12px 12px;
border: 2px solid #e5e7eb;
border-radius: 12px;
font-size: 1rem;
transition: all 0.2s;
box-sizing: border-box;
background: white;
color: #111827;
}
.dark .search-box input {
background: #1f2937;
color: #f3f4f6;
border-color: #374151;
}
.search-box input:focus {
outline: none;
border-color: rgb(20, 113, 76);
box-shadow: 0 0 0 3px rgba(20, 113, 76, 0.1);
}
.search-icon {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
color: #9ca3af;
pointer-events: none;
}
.faq-section {
padding: 60px 20px;
background-color: white;
}
.dark .faq-section {
background-color: #111827;
}
.faq-container {
max-width: 48rem;
margin: 0 auto;
}
.faq-category {
margin-bottom: 40px;
}
.faq-category h2 {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 24px;
color: #111827;
}
.dark .faq-category h2 {
color: #f3f4f6;
}
.faq-item {
border: 2px solid #e5e7eb;
border-radius: 8px;
margin-bottom: 16px;
overflow: hidden;
transition: all 0.2s;
}
.dark .faq-item {
border-color: #374151;
}
.faq-item:hover {
border-color: rgb(20, 113, 76);
}
.faq-question {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
background-color: white;
cursor: pointer;
transition: all 0.2s;
border: none;
width: 100%;
text-align: left;
font-weight: 600;
color: #111827;
}
.dark .faq-question {
background-color: #1f2937;
color: #f3f4f6;
}
.faq-question:hover {
background-color: #f9fafb;
}
.dark .faq-question:hover {
background-color: #374151;
}
.faq-chevron {
transition: transform 0.3s;
flex-shrink: 0;
margin-left: 12px;
}
.faq-chevron.open {
transform: rotate(180deg);
}
.faq-answer {
padding: 16px;
background-color: #f9fafb;
border-top: 1px solid #e5e7eb;
color: #6b7280;
font-size: 0.875rem;
line-height: 1.6;
}
.dark .faq-answer {
background-color: #0f1117;
border-top-color: #374151;
color: #9ca3af;
}
.help-section {
padding: 60px 20px;
background-color: #f9fafb;
}
.dark .help-section {
background-color: #0f1117;
}
.help-card {
max-width: 42rem;
margin: 0 auto;
background: white;
border-radius: 18px;
padding: 32px;
border: 1px solid #e5e7eb;
text-align: center;
}
.dark .help-card {
background: #1f2937;
border-color: #374151;
}
.help-icon {
width: 64px;
height: 64px;
background: rgb(20, 113, 76);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 16px;
}
.help-card h2 {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 12px;
color: #111827;
}
.dark .help-card h2 {
color: #f3f4f6;
}
.help-card p {
color: #6b7280;
margin-bottom: 24px;
}
.dark .help-card p {
color: #9ca3af;
}
.button-primary {
display: inline-block;
padding: 12px 32px;
background-color: rgb(20, 113, 76);
color: white;
font-weight: 700;
border-radius: 12px;
text-decoration: none;
transition: all 0.2s;
}
.button-primary:hover {
background-color: rgb(16, 89, 60);
transform: scale(1.05);
}
.no-results {
text-align: center;
padding: 40px 20px;
color: #6b7280;
}
.dark .no-results {
color: #9ca3af;
}
@media (max-width: 768px) {
.help-hero h1 {
font-size: 1.875rem;
}
}
`;
export default function Help() {
const [lang, setLang] = useState<Language>('en');
const [openIndexes, setOpenIndexes] = useState<number[]>([]);
const [searchTerm, setSearchTerm] = useState('');
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 faqCategories: FAQCategory[] = [
{
title: t('help.bookingFaq'),
items: [
{ question: t('help.how'), answer: t('help.howAnswer') },
{ question: t('help.modify'), answer: t('help.modifyAnswer') },
{ question: t('help.cancel'), answer: t('help.cancelAnswer') },
],
},
{
title: t('help.paymentFaq'),
items: [
{ question: t('help.payMethods'), answer: t('help.payMethodsAnswer') },
{ question: t('help.refund'), answer: t('help.refundAnswer') },
],
},
{
title: t('help.other'),
items: [
{ question: t('help.docs'), answer: t('help.docsAnswer') },
],
},
];
const toggleFAQ = (index: number) => {
if (openIndexes.includes(index)) {
setOpenIndexes(openIndexes.filter(i => i !== index));
} else {
setOpenIndexes([...openIndexes, index]);
}
};
let flatFAQs: (FAQItem & { id: number })[] = [];
faqCategories.forEach((cat) => {
cat.items.forEach((item) => {
flatFAQs.push({ ...item, id: flatFAQs.length });
});
});
const filteredFAQs = flatFAQs.filter(
(faq) =>
faq.question.toLowerCase().includes(searchTerm.toLowerCase()) ||
faq.answer.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<>
<style>{styles}</style>
<main>
<section className="help-hero">
<h1>{t('help.title')}</h1>
<p>{t('help.subtitle')}</p>
</section>
<section className="search-section">
<div className="search-box">
<input
type="text"
placeholder="Search FAQs..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<Search className="search-icon" size={20} />
</div>
</section>
<section className="faq-section">
<div className="faq-container">
{searchTerm ? (
<>
{filteredFAQs.length > 0 ? (
filteredFAQs.map((faq) => (
<div key={faq.id} className="faq-item">
<div className="faq-question">
<span>{faq.question}</span>
</div>
<div className="faq-answer">{faq.answer}</div>
</div>
))
) : (
<div className="no-results">
No FAQs found for &quot;{searchTerm}&quot;
</div>
)}
</>
) : (
faqCategories.map((category, catIdx) => (
<div key={catIdx} className="faq-category">
<h2>{category.title}</h2>
{category.items.map((item, itemIdx) => {
const globalIdx = catIdx * 100 + itemIdx;
const isOpen = openIndexes.includes(globalIdx);
return (
<div key={itemIdx} className="faq-item">
<button
className="faq-question"
onClick={() => toggleFAQ(globalIdx)}
>
<span>{item.question}</span>
<ChevronDown className={`faq-chevron ${isOpen ? 'open' : ''}`} size={20} color="rgb(20, 113, 76)" />
</button>
{isOpen && <div className="faq-answer">{item.answer}</div>}
</div>
);
})}
</div>
))
)}
</div>
</section>
<section className="help-section">
<div className="help-card">
<div className="help-icon">
<MessageCircle size={32} color="white" />
</div>
<h2>{t('help.help')}</h2>
<p>{t('help.contact')}</p>
<Link href="/contact" className="button-primary">Contact Support</Link>
</div>
</section>
</main>
</>
);
}

View File

@@ -2,6 +2,8 @@ import type { Metadata } from 'next';
import './globals.css';
import { Providers } from './providers';
import AppHeader from '@/components/AppHeader';
import { Footer } from '@/components/Footer';
import { LoadingIndicator } from '@/components/LoadingIndicator';
export const metadata: Metadata = {
title: 'EDR Passenger Portal - Book your train journey',
@@ -15,7 +17,7 @@ export default function RootLayout({
}) {
return (
<html lang="en" suppressHydrationWarning>
<body className="font-sans antialiased">
<body className="font-sans antialiased flex flex-col min-h-screen">
<script
dangerouslySetInnerHTML={{
__html: `
@@ -39,8 +41,12 @@ export default function RootLayout({
}}
/>
<Providers>
<LoadingIndicator />
<AppHeader />
{children}
<main className="flex-1">
{children}
</main>
<Footer />
</Providers>
</body>
</html>

View File

@@ -41,11 +41,13 @@ function LoginContent() {
};
return (
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-primary-100 dark:from-gray-900 dark:to-gray-800 flex items-center justify-center py-12 px-4">
<div className="min-h-screen bg-gradient-to-br from-[rgb(20_113_76)] from-10% via-transparent to-[rgb(20_113_76)] to-90% dark:from-gray-900 dark:to-gray-800 flex items-center justify-center py-12 px-4">
<div className="max-w-md w-full">
<div className="text-center mb-8">
<div className="flex justify-center mb-4">
<Train className="w-12 h-12 text-primary" />
<div className="w-12 h-12 bg-[rgb(20_113_76)] rounded-lg flex items-center justify-center">
<Train className="w-6 h-6 text-white" />
</div>
</div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">Sign in</h1>
<p className="text-gray-600 dark:text-gray-400 mt-2">Welcome back</p>
@@ -106,10 +108,12 @@ function LoginContent() {
export default function LoginPage() {
return (
<Suspense fallback={
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-primary-100 dark:from-gray-900 dark:to-gray-800 flex items-center justify-center">
<Suspense fallback={
<div className="min-h-screen bg-gradient-to-br from-[rgb(20_113_76)] from-10% via-transparent to-[rgb(20_113_76)] to-90% dark:from-gray-900 dark:to-gray-800 flex items-center justify-center">
<div className="text-center">
<Train className="w-12 h-12 text-primary animate-pulse mx-auto mb-4" />
<div className="w-12 h-12 bg-white rounded-lg flex items-center justify-center mx-auto mb-4">
<Train className="w-6 h-6 text-[rgb(20_113_76)]" />
</div>
<p className="text-gray-600 dark:text-gray-400">Loading...</p>
</div>
</div>

View File

@@ -1,5 +1,438 @@
import { redirect } from 'next/navigation';
'use client';
export default function HomePage() {
redirect('/booking/search');
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<Language>('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<Station[]>({
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 (
<>
<style>{styles}</style>
<main>
{/* Hero Section */}
<section className="hero-section">
<div className="hero-content">
<h1 className="hero-heading">{t('home.hero')}</h1>
<p className="hero-subheading">{t('home.heroSub')}</p>
<SearchWidget />
{/* Highlights */}
<div className="grid md:grid-cols-3 gap-6 mt-12 max-w-6xl mx-auto">
{highlights.map((highlight, idx) => {
const Icon = highlight.icon;
return (
<div key={idx} className="feature-card">
<div className="feature-icon-bg">
<Icon size={24} color="white" />
</div>
<h3 className="feature-title">{highlight.title}</h3>
<p className="feature-desc">{highlight.desc}</p>
</div>
);
})}
</div>
</div>
</section>
{/* Popular Routes Section */}
<section className="py-16 bg-gray-50 dark:bg-gray-900">
<div className="max-w-6xl mx-auto px-4">
<h2 className="section-title">Popular Routes</h2>
<div className="grid md:grid-cols-3 gap-4">
{popularRoutes.map((route, idx) => (
<button
key={idx}
type="button"
onClick={() => handlePopularRoute(route.from, route.to)}
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-5 hover:border-primary hover:shadow-md transition-all text-left group"
>
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<div className="font-semibold text-gray-900 dark:text-gray-100 mb-1">{route.from}</div>
<ArrowRight className="w-4 h-4 text-primary my-2" />
<div className="font-semibold text-gray-900 dark:text-gray-100">{route.to}</div>
</div>
<Train className="w-5 h-5 text-primary opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">{route.duration} journey</div>
</button>
))}
</div>
</div>
</section>
{/* Features Section */}
<section className="features-section">
<h2 className="section-title">{t('home.features')}</h2>
<div className="features-grid">
{features.map((feature, idx) => {
const Icon = feature.icon;
return (
<div key={idx} className="feature-card">
<div className="feature-icon-bg">
<Icon size={24} color="white" />
</div>
<h3 className="feature-title">{feature.title}</h3>
<p className="feature-desc">{feature.desc}</p>
</div>
);
})}
</div>
</section>
{/* CTA Section */}
<section className="cta-section">
<div className="cta-content">
<h2 className="cta-title">Ready to start your journey?</h2>
<p className="cta-text">Book your train tickets in just a few minutes and enjoy a comfortable ride.</p>
<Link href="/booking/search" className="cta-button">
{t('home.cta')}
<ArrowRight size={20} />
</Link>
</div>
</section>
</main>
</>
);
}

View File

@@ -65,9 +65,7 @@ export default function ProfilePage() {
if (!isInitialized) return;
if (isAuthenticated && user) {
// Fetch fresh profile data
fetchProfile().catch(() => {
// If fetch fails, redirect to login
router.push('/login?redirect=/profile');
});
@@ -200,7 +198,6 @@ export default function ProfilePage() {
message: 'Are you sure you want to sign out?',
onConfirm: async () => {
await logout();
// Navigation will be handled by logout function
},
});
setShowModal(true);
@@ -245,7 +242,7 @@ export default function ProfilePage() {
if (!isInitialized || !user) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[rgb(20_113_76)]"></div>
</div>
);
}
@@ -258,7 +255,7 @@ export default function ProfilePage() {
<div className="card mb-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-16 h-16 bg-primary rounded-full flex items-center justify-center">
<div className="w-16 h-16 bg-[rgb(20_113_76)] rounded-full flex items-center justify-center">
<User className="w-8 h-8 text-white" />
</div>
<div>
@@ -289,7 +286,7 @@ export default function ProfilePage() {
onClick={() => setActiveTab('bookings')}
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors ${
activeTab === 'bookings'
? 'bg-primary text-white'
? 'bg-[rgb(20_113_76)] text-white'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
>
@@ -300,7 +297,7 @@ export default function ProfilePage() {
onClick={() => setActiveTab('profile')}
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors ${
activeTab === 'profile'
? 'bg-primary text-white'
? 'bg-[rgb(20_113_76)] text-white'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
>
@@ -311,7 +308,7 @@ export default function ProfilePage() {
onClick={() => setActiveTab('settings')}
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors ${
activeTab === 'settings'
? 'bg-primary text-white'
? 'bg-[rgb(20_113_76)] text-white'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
>
@@ -328,7 +325,7 @@ export default function ProfilePage() {
{loadingBookings ? (
<div className="card text-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[rgb(20_113_76)] mx-auto"></div>
<p className="text-gray-600 dark:text-gray-400 mt-4">Loading bookings...</p>
</div>
) : bookings && Array.isArray(bookings) && bookings.length > 0 ? (
@@ -482,7 +479,7 @@ export default function ProfilePage() {
onChange={(e) => setSettings({ ...settings, notifications: e.target.checked })}
className="sr-only peer"
/>
<div className="w-12 h-6 bg-gray-300 dark:bg-gray-600 rounded-full peer peer-checked:bg-primary transition-colors"></div>
<div className="w-12 h-6 bg-gray-300 dark:bg-gray-600 rounded-full peer peer-checked:bg-[rgb(20_113_76)] transition-colors"></div>
<div className="absolute left-1 top-1 w-4 h-4 bg-white rounded-full transition-transform peer-checked:translate-x-6"></div>
</div>
</label>
@@ -495,7 +492,7 @@ export default function ProfilePage() {
onChange={(e) => setSettings({ ...settings, emailNotifications: e.target.checked })}
className="sr-only peer"
/>
<div className="w-12 h-6 bg-gray-300 dark:bg-gray-600 rounded-full peer peer-checked:bg-primary transition-colors"></div>
<div className="w-12 h-6 bg-gray-300 dark:bg-gray-600 rounded-full peer peer-checked:bg-[rgb(20_113_76)] transition-colors"></div>
<div className="absolute left-1 top-1 w-4 h-4 bg-white rounded-full transition-transform peer-checked:translate-x-6"></div>
</div>
</label>
@@ -508,7 +505,7 @@ export default function ProfilePage() {
onChange={(e) => setSettings({ ...settings, smsNotifications: e.target.checked })}
className="sr-only peer"
/>
<div className="w-12 h-6 bg-gray-300 dark:bg-gray-600 rounded-full peer peer-checked:bg-primary transition-colors"></div>
<div className="w-12 h-6 bg-gray-300 dark:bg-gray-600 rounded-full peer peer-checked:bg-[rgb(20_113_76)] transition-colors"></div>
<div className="absolute left-1 top-1 w-4 h-4 bg-white rounded-full transition-transform peer-checked:translate-x-6"></div>
</div>
</label>

View File

@@ -0,0 +1,309 @@
'use client';
import { useEffect, useState } from 'react';
import { getTranslation, Language, useLanguage } from '@/lib/i18n';
import Link from 'next/link';
import { BookOpen, CreditCard, Headphones, Star, MapPin, Radio, ArrowRight } from 'lucide-react';
const styles = `
.service-hero {
padding: 60px 20px;
background: linear-gradient(to bottom right, rgb(20, 113, 76), transparent);
text-align: center;
color: #111827;
}
.dark .service-hero {
color: #f3f4f6;
}
.service-hero h1 {
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 16px;
color: #111827;
}
.dark .service-hero h1 {
color: #f3f4f6;
}
.service-hero p {
font-size: 1.125rem;
color: #6b7280;
max-width: 48rem;
margin: 0 auto;
}
.dark .service-hero p {
color: #9ca3af;
}
.service-grid {
max-width: 80rem;
margin: 0 auto;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 24px;
padding: 60px 20px;
background-color: white;
}
.dark .service-grid {
background-color: #111827;
}
.service-card {
background: white;
border: 2px solid #f3f4f6;
border-radius: 18px;
padding: 24px;
cursor: pointer;
transition: all 0.2s;
}
.dark .service-card {
background: #1f2937;
border-color: #374151;
}
.service-card:hover {
border-color: rgb(20, 113, 76);
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1);
}
.service-icon {
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 12px;
margin-bottom: 16px;
}
.service-card h3 {
font-size: 1.25rem;
font-weight: 700;
margin-bottom: 12px;
color: #111827;
}
.dark .service-card h3 {
color: #f3f4f6;
}
.service-card p {
font-size: 0.875rem;
color: #6b7280;
margin-bottom: 16px;
}
.dark .service-card p {
color: #9ca3af;
}
.service-card a {
color: rgb(20, 113, 76);
font-weight: 600;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 8px;
}
.service-card a:hover {
text-decoration: underline;
}
.info-section {
padding: 60px 20px;
background-color: #f9fafb;
}
.dark .info-section {
background-color: #0f1117;
}
.info-grid {
max-width: 80rem;
margin: 0 auto;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 32px;
}
.info-box {
background: white;
border-radius: 18px;
padding: 32px;
border: 1px solid #e5e7eb;
}
.dark .info-box {
background: #1f2937;
border-color: #374151;
}
.info-box h3 {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 16px;
color: #111827;
}
.dark .info-box h3 {
color: #f3f4f6;
}
.info-box p {
color: #6b7280;
margin-bottom: 16px;
}
.dark .info-box p {
color: #9ca3af;
}
.info-box ul {
list-style: none;
padding: 0;
}
.info-box li {
font-size: 0.875rem;
color: #6b7280;
padding: 8px 0;
}
.dark .info-box li {
color: #9ca3af;
}
.cta-bg {
background-color: rgb(20, 113, 76);
color: white;
padding: 60px 20px;
text-align: center;
}
.cta-bg h2 {
font-size: 2rem;
font-weight: 700;
margin-bottom: 16px;
}
.cta-bg p {
font-size: 1.125rem;
margin-bottom: 32px;
max-width: 42rem;
margin-left: auto;
margin-right: auto;
}
.button-white {
display: inline-block;
padding: 16px 32px;
background-color: white;
color: rgb(20, 113, 76);
font-weight: 700;
border-radius: 12px;
text-decoration: none;
transition: all 0.2s;
}
.button-white:hover {
transform: scale(1.05);
}
@media (max-width: 768px) {
.info-grid {
grid-template-columns: 1fr;
}
.service-hero h1 {
font-size: 1.875rem;
}
}
`;
export default function Services() {
const [lang, setLang] = useState<Language>('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 services = [
{ icon: BookOpen, title: t('services.booking'), desc: t('services.bookingDesc'), color: '#3b82f6' },
{ icon: MapPin, title: t('services.seats'), desc: t('services.seatsDesc'), color: '#10b981' },
{ icon: CreditCard, title: t('services.payment'), desc: t('services.paymentDesc'), color: '#a855f7' },
{ icon: Headphones, title: t('services.support'), desc: t('services.supportDesc'), color: '#f97316' },
{ icon: Star, title: t('services.loyalty'), desc: t('services.loyaltyDesc'), color: '#ec4899' },
{ icon: Radio, title: t('services.tracking'), desc: t('services.trackingDesc'), color: '#ef4444' },
];
return (
<>
<style>{styles}</style>
<main>
<section className="service-hero">
<h1>{t('services.title')}</h1>
<p>{t('services.subtitle')}</p>
</section>
<div className="service-grid">
{services.map((service, idx) => {
const Icon = service.icon;
return (
<div key={idx} className="service-card">
<div className="service-icon" style={{ background: `${service.color}20` }}>
<Icon size={28} color={service.color} />
</div>
<h3>{service.title}</h3>
<p>{service.desc}</p>
<Link href="/booking/search">
Learn More <ArrowRight size={16} />
</Link>
</div>
);
})}
</div>
<section className="info-section">
<div className="info-grid">
<div className="info-box">
<h3>Multi-Currency Support</h3>
<p>Book in multiple currencies with real-time exchange rates.</p>
<ul>
<li> ETB (Ethiopian Birr)</li>
<li> DJF (Djiboutian Franc)</li>
<li> USD (US Dollar)</li>
</ul>
</div>
<div className="info-box">
<h3>Age-Based Pricing</h3>
<p>Smart pricing for families with special rates for children.</p>
<ul>
<li> Adults (5 years): Full fare</li>
<li> Children (&lt;5 years): First free</li>
<li> Automatic age calculation</li>
</ul>
</div>
</div>
</section>
<section className="cta-bg">
<h2>Experience the Difference</h2>
<p>Start booking your train journey today and discover our premium services.</p>
<Link href="/booking/search" className="button-white">Book Your Trip Now</Link>
</section>
</main>
</>
);
}

View File

@@ -1,69 +1,172 @@
'use client';
import { Train, User, BookOpen, LogIn } from 'lucide-react';
import ThemeToggle from './ThemeToggle';
import { Train, Menu, X, Moon, Sun, HelpCircle } from 'lucide-react';
import Link from 'next/link';
import { useAuthStore } from '@/lib/auth-store';
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { usePathname } from 'next/navigation';
import { LanguageSwitcher } from './LanguageSwitcher';
export default function AppHeader() {
const { user, isAuthenticated, initialize } = useAuthStore();
const pathname = usePathname();
const [isOpen, setIsOpen] = useState(false);
const [isDark, setIsDark] = useState(false);
useEffect(() => {
initialize();
}, [initialize]);
return (
<header className="sticky top-0 z-50 bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 shadow-sm">
<div className="container mx-auto px-4">
<div className="max-w-5xl mx-auto">
<div className="flex items-center justify-between h-16">
{/* Logo and Brand */}
<Link href="/booking/search" className="flex items-center gap-3 hover:opacity-80 transition-opacity">
<div className="w-10 h-10 bg-primary rounded-lg flex items-center justify-center">
<Train className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="text-lg font-bold text-gray-900 dark:text-gray-100">
Ethio-Djibouti Railway
</h1>
<p className="text-xs text-gray-600 dark:text-gray-400">
Book train tickets across East Africa
</p>
</div>
</Link>
const isDarkMode = document.documentElement.classList.contains('dark');
setIsDark(isDarkMode);
}, []);
{/* Right side actions */}
<div className="flex items-center gap-2">
{!isAuthenticated ? (
<Link
href="/login"
className="p-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
title="Sign in"
>
<LogIn className="w-5 h-5" />
</Link>
) : (
<Link
href="/profile"
className="flex items-center gap-2 px-3 py-1.5 bg-gray-100 dark:bg-gray-800 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
title="Profile"
>
<User className="w-4 h-4 text-gray-600 dark:text-gray-400" />
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 hidden sm:inline">
{user?.fullName}
</span>
</Link>
)}
<ThemeToggle />
<Link
href="/guide"
className="p-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
title="How to Book"
const toggleTheme = () => {
const html = document.documentElement;
const isDarkMode = html.classList.contains('dark');
if (isDarkMode) {
html.classList.remove('dark');
setIsDark(false);
localStorage.setItem('theme', 'light');
} else {
html.classList.add('dark');
setIsDark(true);
localStorage.setItem('theme', 'dark');
}
};
const isLandingPage = ['/', '/services', '/about', '/contact', '/help'].includes(pathname);
return (
<header className="sticky top-0 z-50 bg-[rgb(20_113_76)] dark:bg-gray-900 border-b border-[rgb(16_89_60)] dark:border-gray-800 shadow-sm">
<div className="container mx-auto px-4">
<div className="max-w-6xl mx-auto">
<div className="flex items-center justify-between h-16">
{/* Logo */}
<Link
href="/"
className="flex items-center gap-3 hover:opacity-80 transition-opacity"
>
<BookOpen className="w-5 h-5" />
<div className="w-10 h-10 bg-white rounded-lg flex items-center justify-center">
<Train className="w-6 h-6 text-[rgb(20_113_76)]" />
</div>
<div>
<h1 className="text-lg font-bold text-white">
Ethio-Djibouti Railway
</h1>
<p className="text-xs text-gray-100">
Book your train journey with us
</p>
</div>
</Link>
{/* Desktop Menu - only show for landing pages */}
{isLandingPage && (
<div className="hidden md:flex items-center gap-8">
<Link
href="/"
className="text-sm font-medium text-gray-100 hover:text-white transition-colors"
>
Home
</Link>
<Link
href="/services"
className="text-sm font-medium text-gray-100 hover:text-white transition-colors"
>
Services
</Link>
<Link
href="/about"
className="text-sm font-medium text-gray-100 hover:text-white transition-colors"
>
About
</Link>
<Link
href="/contact"
className="text-sm font-medium text-gray-100 hover:text-white transition-colors"
>
Contact
</Link>
</div>
)}
{/* Right Actions */}
<div className="flex items-center gap-2">
{/* Help Link */}
<Link
href="/help"
className="hidden sm:flex items-center justify-center p-2 text-gray-100 hover:bg-white hover:bg-opacity-10 rounded-lg transition-colors"
title="Help & FAQ"
>
<HelpCircle className="w-5 h-5" />
</Link>
{/* Language Switcher */}
<LanguageSwitcher />
{/* Theme Toggler */}
<button
onClick={toggleTheme}
className="p-2 text-gray-100 hover:bg-white hover:bg-opacity-10 rounded-lg transition-colors"
title={isDark ? 'Light mode' : 'Dark mode'}
>
{isDark ? (
<Sun className="w-5 h-5" />
) : (
<Moon className="w-5 h-5" />
)}
</button>
{/* Mobile Menu Button */}
<button
onClick={() => setIsOpen(!isOpen)}
className="md:hidden p-2 text-gray-100 hover:bg-white hover:bg-opacity-10 rounded-lg"
>
{isOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
</button>
</div>
</div>
</div>
{/* Mobile Menu */}
{isOpen && (
<div className="md:hidden border-t border-white border-opacity-20 dark:border-gray-700 py-4 space-y-2 animate-in slide-in-from-top-2 duration-200">
{isLandingPage && (
<>
<Link
href="/"
className="block px-4 py-2 text-gray-100 dark:text-gray-300 hover:bg-white hover:bg-opacity-10 dark:hover:bg-gray-800 rounded-lg transition-colors"
onClick={() => setIsOpen(false)}
>
Home
</Link>
<Link
href="/services"
className="block px-4 py-2 text-gray-100 dark:text-gray-300 hover:bg-white hover:bg-opacity-10 dark:hover:bg-gray-800 rounded-lg transition-colors"
onClick={() => setIsOpen(false)}
>
Services
</Link>
<Link
href="/about"
className="block px-4 py-2 text-gray-100 dark:text-gray-300 hover:bg-white hover:bg-opacity-10 dark:hover:bg-gray-800 rounded-lg transition-colors"
onClick={() => setIsOpen(false)}
>
About
</Link>
<Link
href="/contact"
className="block px-4 py-2 text-gray-100 dark:text-gray-300 hover:bg-white hover:bg-opacity-10 dark:hover:bg-gray-800 rounded-lg transition-colors"
onClick={() => setIsOpen(false)}
>
Contact
</Link>
</>
)}
<Link
href="/help"
className="block px-4 py-2 text-gray-100 dark:text-gray-300 hover:bg-white hover:bg-opacity-10 dark:hover:bg-gray-800 rounded-lg transition-colors"
onClick={() => setIsOpen(false)}
>
Help
</Link>
</div>
)}
</div>
</div>
</header>

View File

@@ -0,0 +1,165 @@
'use client';
import { Facebook, Twitter, Instagram, Linkedin, Mail, Phone, MapPin } from 'lucide-react';
import Link from 'next/link';
import { useLanguage, getTranslation, Language } from '@/lib/i18n';
import { useEffect, useState } from 'react';
export function Footer() {
const { getLang } = useLanguage();
const [lang, setLang] = useState<Language>('en');
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]);
return (
<footer className="bg-[rgb(20_113_76)] dark:bg-gray-900 text-white py-12">
<div className="container mx-auto px-4">
<div className="max-w-6xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-4 gap-8 mb-8">
{/* Company Info */}
<div>
<h4 className="font-semibold mb-2">
Ethio-Djibouti Railway
</h4>
<p className="text-sm text-gray-100 my-3">Connecting East Africa with train travel.
</p>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2 text-gray-100">
<Phone className="w-4 h-4" />
<a href="tel:9546" className="hover:text-gray-200 transition">
9546
</a>
</div>
<div className="flex items-center gap-2 text-gray-100">
<Mail className="w-4 h-4" />
<a href="mailto:edr_@edrsc.com" className="hover:text-gray-200 transition">
edr_@edrsc.com
</a>
</div>
<div className="flex items-center gap-2 text-gray-100">
<MapPin className="w-4 h-4" />
<span>Furi, Sheger City</span>
</div>
</div>
</div>
{/* Quick Links */}
<div>
<h4 className="font-semibold mb-4">{t('nav.home')}</h4>
<ul className="space-y-2 text-sm text-gray-100">
<li>
<Link href="/" className="hover:text-gray-200 transition">
{t('nav.home')}
</Link>
</li>
<li>
<Link href="/services" className="hover:text-gray-200 transition">
{t('nav.services')}
</Link>
</li>
<li>
<Link href="/about" className="hover:text-gray-200 transition">
{t('nav.about')}
</Link>
</li>
<li>
<Link href="/contact" className="hover:text-gray-200 transition">
{t('nav.contact')}
</Link>
</li>
</ul>
</div>
{/* Support */}
<div>
<h4 className="font-semibold mb-4">{t('footer.support')}</h4>
<ul className="space-y-2 text-sm text-gray-100">
<li>
<Link href="/help" className="hover:text-gray-200 transition">
{t('nav.help')}
</Link>
</li>
<li>
<a href="#" className="hover:text-gray-200 transition">
{t('footer.privacy')}
</a>
</li>
<li>
<a href="#" className="hover:text-gray-200 transition">
{t('footer.terms')}
</a>
</li>
</ul>
</div>
{/* Social Media */}
<div>
<h4 className="font-semibold mb-4">{t('footer.follow')}</h4>
<div className="flex gap-3">
<a
href="https://web.facebook.com/ethiodjiboutirailwaysc"
target="_blank"
rel="noopener noreferrer"
className="w-10 h-10 bg-white bg-opacity-20 rounded-lg flex items-center justify-center hover:bg-opacity-30 transition duration-200 transform hover:scale-110"
aria-label="Facebook"
>
<Facebook className="w-5 h-5" />
</a>
<a
href="https://twitter.com/edr"
target="_blank"
rel="noopener noreferrer"
className="w-10 h-10 bg-white bg-opacity-20 rounded-lg flex items-center justify-center hover:bg-opacity-30 transition duration-200 transform hover:scale-110"
aria-label="Twitter"
>
<Twitter className="w-5 h-5" />
</a>
<a
href="https://instagram.com/edr"
target="_blank"
rel="noopener noreferrer"
className="w-10 h-10 bg-white bg-opacity-20 rounded-lg flex items-center justify-center hover:bg-opacity-30 transition duration-200 transform hover:scale-110"
aria-label="Instagram"
>
<Instagram className="w-5 h-5" />
</a>
<a
href="https://linkedin.com/company/edr"
target="_blank"
rel="noopener noreferrer"
className="w-10 h-10 bg-white bg-opacity-20 rounded-lg flex items-center justify-center hover:bg-opacity-30 transition duration-200 transform hover:scale-110"
aria-label="LinkedIn"
>
<Linkedin className="w-5 h-5" />
</a>
</div>
</div>
</div>
{/* Divider */}
<div className="border-t border-white border-opacity-20 dark:border-gray-700 pt-8">
<div className="flex flex-col md:flex-row justify-between items-center text-sm text-gray-100">
<p>
&copy; 2024 Ethio-Djibouti Railway. {t('footer.rights')}
</p>
<div className="flex gap-6 mt-4 md:mt-0">
<a href="#" className="hover:text-gray-200 transition">
{t('footer.privacy')}
</a>
<a href="#" className="hover:text-gray-200 transition">
{t('footer.terms')}
</a>
</div>
</div>
</div>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,163 @@
'use client';
import { Train, Menu, X, Moon, Sun, HelpCircle } from 'lucide-react';
import Link from 'next/link';
import { useState, useEffect } from 'react';
import { LanguageSwitcher } from './LanguageSwitcher';
import { getTranslation, Language } from '@/lib/i18n';
import { useLanguage } from '@/lib/i18n';
export function LandingNav() {
const [isOpen, setIsOpen] = useState(false);
const [lang, setLang] = useState<Language>('en');
const [isDark, setIsDark] = useState(false);
const [mounted, setMounted] = useState(false);
const { getLang } = useLanguage();
const t = (key: string) => getTranslation(lang, key);
useEffect(() => {
setMounted(true);
setLang(getLang());
const handleLanguageChange = (e: any) => setLang(e.detail);
window.addEventListener('languageChange', handleLanguageChange);
return () => window.removeEventListener('languageChange', handleLanguageChange);
}, [getLang]);
useEffect(() => {
const isDarkMode = document.documentElement.classList.contains('dark');
setIsDark(isDarkMode);
}, []);
const toggleTheme = () => {
const html = document.documentElement;
const isDarkMode = html.classList.contains('dark');
if (isDarkMode) {
html.classList.remove('dark');
setIsDark(false);
localStorage.setItem('theme', 'light');
} else {
html.classList.add('dark');
setIsDark(true);
localStorage.setItem('theme', 'dark');
}
};
const menuItems = [
{ label: t('nav.home'), href: '/' },
{ label: t('nav.services'), href: '/services' },
{ label: t('nav.about'), href: '/about' },
{ label: t('nav.contact'), href: '/contact' },
];
if (!mounted) return null;
return (
<nav className="sticky top-0 z-40 bg-[rgb(20_113_76)] border-b border-[rgb(16_89_60)] shadow-sm">
<div className="container mx-auto px-4">
<div className="max-w-6xl mx-auto">
<div className="flex items-center justify-between h-16">
{/* Logo */}
<Link href="/" className="flex items-center gap-3 hover:opacity-80 transition-opacity">
<div className="w-10 h-10 bg-white rounded-lg flex items-center justify-center">
<Train className="w-6 h-6 text-[rgb(20_113_76)]" />
</div>
<div>
<h1 className="text-lg font-bold text-white">
Ethio-Djibouti Railway
</h1>
<p className="text-xs text-gray-100">
Book your train journey with us
</p>
</div>
</Link>
{/* Desktop Menu */}
<div className="hidden md:flex items-center gap-8">
{menuItems.map((item) => (
<Link
key={item.href}
href={item.href}
className="text-sm font-medium text-gray-100 hover:text-white transition-colors"
>
{item.label}
</Link>
))}
</div>
{/* Right Actions */}
<div className="flex items-center gap-3">
<LanguageSwitcher />
{/* Help Link */}
<Link
href="/help"
className="hidden sm:flex items-center gap-1.5 px-3 py-2 text-gray-100 hover:bg-white hover:bg-opacity-10 rounded-lg transition-colors"
title="Help & FAQ"
>
<HelpCircle className="w-5 h-5" />
</Link>
{/* Theme Toggler */}
<button
onClick={toggleTheme}
className="p-2 text-gray-100 hover:bg-white hover:bg-opacity-10 rounded-lg transition-colors"
title={isDark ? 'Light mode' : 'Dark mode'}
>
{isDark ? (
<Sun className="w-5 h-5" />
) : (
<Moon className="w-5 h-5" />
)}
</button>
<Link
href="/booking/search"
className="hidden sm:block px-4 py-2 bg-white text-[rgb(20_113_76)] text-sm font-medium rounded-lg transition-all duration-200 transform hover:scale-105 hover:bg-gray-100"
>
{t('nav.bookNow')}
</Link>
{/* Mobile Menu Button */}
<button
onClick={() => setIsOpen(!isOpen)}
className="md:hidden p-2 text-gray-100 hover:bg-white hover:bg-opacity-10 rounded-lg"
>
{isOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
</button>
</div>
</div>
{/* Mobile Menu */}
{isOpen && (
<div className="md:hidden border-t border-white border-opacity-20 py-4 space-y-2 animate-in slide-in-from-top-2 duration-200">
{menuItems.map((item) => (
<Link
key={item.href}
href={item.href}
className="block px-4 py-2 text-gray-100 hover:bg-white hover:bg-opacity-10 rounded-lg transition-colors"
onClick={() => setIsOpen(false)}
>
{item.label}
</Link>
))}
<Link
href="/help"
className="block px-4 py-2 text-gray-100 hover:bg-white hover:bg-opacity-10 rounded-lg transition-colors"
onClick={() => setIsOpen(false)}
>
{t('nav.help')}
</Link>
<Link
href="/booking/search"
className="block px-4 py-2 bg-white text-[rgb(20_113_76)] font-medium rounded-lg transition-colors hover:bg-gray-100"
onClick={() => setIsOpen(false)}
>
{t('nav.bookNow')}
</Link>
</div>
)}
</div>
</div>
</nav>
);
}

View File

@@ -0,0 +1,65 @@
'use client';
import { useEffect, useState } from 'react';
import { useLanguage, Language } from '@/lib/i18n';
import { Globe } from 'lucide-react';
const languages: { code: Language; label: string }[] = [
{ code: 'en', label: 'English' },
{ code: 'am', label: 'አማርኛ' },
{ code: 'om', label: 'Afan Oromo' },
{ code: 'fr', label: 'Français' },
];
export function LanguageSwitcher() {
const [isOpen, setIsOpen] = useState(false);
const [currentLang, setCurrentLang] = useState<Language>('en');
const { getLang, setLang } = useLanguage();
useEffect(() => {
setCurrentLang(getLang());
}, [getLang]);
const handleLanguageChange = (lang: Language) => {
setLang(lang);
setCurrentLang(lang);
setIsOpen(false);
};
return (
<div className="relative">
<button
onClick={() => setIsOpen(!isOpen)}
className="p-2 text-gray-100 hover:bg-white hover:bg-opacity-10 rounded-lg transition-colors"
title="Change language"
aria-label="Change language"
>
<Globe className="w-5 h-5" />
</button>
{isOpen && (
<>
<div
className="fixed inset-0 z-30"
onClick={() => setIsOpen(false)}
/>
<div className="absolute right-0 mt-2 w-40 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-40 animate-in fade-in zoom-in-95 duration-200">
{languages.map((lang) => (
<button
key={lang.code}
onClick={() => handleLanguageChange(lang.code)}
className={`w-full text-left px-4 py-2.5 transition-all duration-200 ${
currentLang === lang.code
? 'bg-primary text-white font-medium'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
{lang.label}
</button>
))}
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,30 @@
'use client';
import { useEffect, useState } from 'react';
export function LoadingIndicator() {
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const handleStart = () => setIsVisible(true);
const handleEnd = () => setIsVisible(false);
window.addEventListener('beforeunload', handleStart);
window.addEventListener('load', handleEnd);
return () => {
window.removeEventListener('beforeunload', handleStart);
window.removeEventListener('load', handleEnd);
};
}, []);
if (!isVisible) return null;
return (
<div className="fixed top-0 left-0 right-0 z-[9999]">
<div className="h-1 bg-gradient-to-r from-primary via-primary/70 to-primary animate-pulse">
<div className="h-full bg-gradient-to-r from-primary to-primary/50 animate-[shimmer_2s_infinite]" />
</div>
</div>
);
}

View File

@@ -27,7 +27,7 @@ export function ProgressIndicator({ currentStep }: ProgressIndicatorProps) {
return (
<nav aria-label="Progress" className="py-6">
<ol className="flex items-center max-w-4xl mx-auto">
<ol className="flex items-center max-w-6xl mx-auto">
{steps.map((step, index) => {
const isComplete = index < currentIndex;
const isCurrent = index === currentIndex;

View File

@@ -0,0 +1,279 @@
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useRouter } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
import { useBookingStore } from '@/lib/booking-store';
import { Station } from '@/types';
import { MapPin, Users, Search, Plus, Minus, ChevronDown } from 'lucide-react';
import { useState } from 'react';
import ModernDatePicker from '@/components/ModernDatePicker';
const searchSchema = z.object({
originStationId: z.string().min(1),
destinationStationId: z.string().min(1),
departureDate: z.string().min(1),
adultCount: z.number().min(1).max(9),
childCount: z.number().min(0).max(9),
nationality: z.enum(['ETHIOPIAN', 'DJIBOUTIAN', 'OTHER']),
}).refine((data) => data.originStationId !== data.destinationStationId, {
message: 'Origin and destination must be different',
path: ['destinationStationId'],
});
type SearchForm = z.infer<typeof searchSchema>;
interface SearchWidgetProps {
fullWidth?: boolean;
onSearch?: () => void;
}
export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps) {
const router = useRouter();
const setSearchCriteria = useBookingStore((s) => s.setSearchCriteria);
const [isPassengerOpen, setIsPassengerOpen] = useState(false);
const { data: stations, isLoading } = useQuery<Station[]>({
queryKey: ['stations'],
queryFn: async () => await apiClient.get('/stations') as Station[],
});
const { register, handleSubmit, watch, setValue, formState: { errors } } = useForm<SearchForm>({
resolver: zodResolver(searchSchema),
defaultValues: {
adultCount: 1,
childCount: 0,
nationality: 'ETHIOPIAN',
departureDate: new Date().toISOString().split('T')[0],
},
});
const originId = watch('originStationId');
const adultCount = watch('adultCount');
const childCount = watch('childCount');
const onSubmit = (data: SearchForm) => {
setSearchCriteria({
...data,
adultCount: data.adultCount,
childCount: data.childCount,
nationality: data.nationality,
});
const params = new URLSearchParams({
origin: data.originStationId,
destination: data.destinationStationId,
date: data.departureDate,
adults: data.adultCount.toString(),
children: data.childCount.toString(),
nationality: data.nationality,
});
if (onSearch) onSearch();
router.push(`/booking/results?${params}`);
};
return (
<div className={fullWidth ? 'w-full' : 'w-full max-w-6xl mx-auto'}>
<form onSubmit={handleSubmit(onSubmit)} className="bg-white/95 dark:bg-gray-800/95 rounded-2xl shadow-lg border border-gray-200/20 dark:border-gray-700/20 overflow-visible backdrop-blur-sm">
<div className="p-6 md:p-8 overflow-visible">
{/* Row 1: From, To, Date */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end mb-4">
{/* From */}
<div className="space-y-2 md:col-span-1">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 text-left">From</label>
<div className="relative">
<MapPin className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-primary" />
<select
{...register('originStationId')}
className="w-full pl-11 pr-4 py-3.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent disabled:bg-gray-50 dark:disabled:bg-gray-800 disabled:cursor-not-allowed text-base bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
disabled={isLoading}
>
<option value="">Select departure</option>
{stations?.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</div>
{errors.originStationId && (
<p className="text-red-600 dark:text-red-400 text-sm">{errors.originStationId.message}</p>
)}
</div>
{/* To */}
<div className="space-y-2 md:col-span-1">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 text-left">To</label>
<div className="relative">
<MapPin className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-primary" />
<select
{...register('destinationStationId')}
className="w-full pl-11 pr-4 py-3.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent disabled:bg-gray-50 dark:disabled:bg-gray-800 disabled:cursor-not-allowed text-base bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
disabled={isLoading}
>
<option value="">Select arrival</option>
{stations?.map((s) => (
<option key={s.id} value={s.id} disabled={s.id === originId}>{s.name}</option>
))}
</select>
</div>
{errors.destinationStationId && (
<p className="text-red-600 dark:text-red-400 text-sm">{errors.destinationStationId.message}</p>
)}
</div>
{/* Date */}
<div className="space-y-2 relative z-30 md:col-span-1">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 text-left">Date</label>
<ModernDatePicker
value={watch('departureDate') ? new Date(watch('departureDate') + 'T00:00:00') : undefined}
onChange={(date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
setValue('departureDate', `${year}-${month}-${day}`);
}}
minDate={new Date()}
placeholder="Select date"
/>
{errors.departureDate && (
<p className="text-red-600 dark:text-red-400 text-sm">{errors.departureDate.message}</p>
)}
</div>
</div>
{/* Row 2: Passengers, Nationality, Promo Code */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end mb-4">
{/* Passengers Dropdown */}
<div className="space-y-2 relative z-20">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 text-left">Passengers</label>
<button
type="button"
onClick={() => setIsPassengerOpen(!isPassengerOpen)}
className="w-full px-4 py-3.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-base bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 flex items-center justify-between hover:border-primary transition-colors"
>
<span className="flex items-center gap-2">
<Users className="w-4 h-4 text-primary" />
{(adultCount || 1) + (childCount || 0)} Passenger{((adultCount || 1) + (childCount || 0)) !== 1 ? 's' : ''}
</span>
<ChevronDown className={`w-4 h-4 transition-transform text-primary ${isPassengerOpen ? 'rotate-180' : ''}`} />
</button>
{/* Passenger Dropdown Menu */}
{isPassengerOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setIsPassengerOpen(false)} />
<div className="absolute top-full left-0 right-0 mt-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg z-50 p-4 space-y-4">
{/* Adults */}
<div>
<div className="flex items-center justify-between mb-2">
<div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">Adults</div>
<div className="text-xs text-gray-500 dark:text-gray-400">5 years</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
const current = adultCount || 1;
if (current > 1) setValue('adultCount', current - 1);
}}
className="w-7 h-7 rounded-full border border-gray-300 dark:border-gray-600 flex items-center justify-center hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50"
disabled={(adultCount || 1) <= 1}
>
<Minus className="w-4 h-4 text-primary" />
</button>
<span className="w-6 text-center font-semibold text-gray-900 dark:text-gray-100">{adultCount || 1}</span>
<button
type="button"
onClick={() => {
const current = adultCount || 1;
if (current < 9) setValue('adultCount', current + 1);
}}
className="w-7 h-7 rounded-full border border-gray-300 dark:border-gray-600 flex items-center justify-center hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50"
disabled={(adultCount || 1) >= 9}
>
<Plus className="w-4 h-4 text-primary" />
</button>
</div>
</div>
</div>
{/* Children */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">Children</div>
<div className="text-xs text-gray-500 dark:text-gray-400">&lt;5 years First free</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
const current = childCount || 0;
if (current > 0) setValue('childCount', current - 1);
}}
className="w-7 h-7 rounded-full border border-gray-300 dark:border-gray-600 flex items-center justify-center hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50"
disabled={(childCount || 0) <= 0}
>
<Minus className="w-4 h-4 text-primary" />
</button>
<span className="w-6 text-center font-semibold text-gray-900 dark:text-gray-100">{childCount || 0}</span>
<button
type="button"
onClick={() => {
const current = childCount || 0;
if (current < 9) setValue('childCount', current + 1);
}}
className="w-7 h-7 rounded-full border border-gray-300 dark:border-gray-600 flex items-center justify-center hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50"
disabled={(childCount || 0) >= 9}
>
<Plus className="w-4 h-4 text-primary" />
</button>
</div>
</div>
</div>
</div>
</>
)}
</div>
{/* Nationality */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 text-left">Nationality</label>
<select
{...register('nationality')}
className="w-full px-4 py-3.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-base bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
>
<option value="ETHIOPIAN">Ethiopian</option>
<option value="DJIBOUTIAN">Djiboutian</option>
<option value="OTHER">Other</option>
</select>
</div>
{/* Promo Code */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 text-left">Promo Code (Optional)</label>
<input
type="text"
placeholder="Enter promo code"
className="w-full px-4 py-3.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-base bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400"
/>
</div>
</div>
{/* Row 3: Search Button */}
<div>
<button
type="submit"
className="w-full bg-primary hover:bg-primary/90 text-white font-semibold py-3.5 px-6 rounded-lg transition-all duration-200 flex items-center justify-center gap-2 shadow-lg hover:shadow-xl"
>
<Search className="w-5 h-5 text-white" />
<span>Search Train</span>
</button>
</div>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,419 @@
export type Language = 'en' | 'am' | 'om' | 'fr';
const translations = {
en: {
nav: {
home: 'Home',
services: 'Services',
about: 'About',
contact: 'Contact',
help: 'Help',
bookNow: 'Book now',
},
home: {
hero: 'Travel across the East Africa',
heroSub: 'Experience seamless train travel from Ethiopia to Djibouti',
cta: 'Start booking',
searchNow: 'Search trips',
features: 'Why choose EDR?',
comfortable: 'Comfortable journey',
comfortDesc: 'Modern coaches with seating and sleeping berths',
affordable: 'Affordable pricing',
affordableDesc: 'Competitive fares with discounts for families',
safe: 'Safe and reliable',
safeDesc: 'On-time arrivals with 24/7 customer support',
fast: 'Quick booking',
fastDesc: 'Book in minutes, pay with multiple methods',
},
services: {
title: 'Our services',
subtitle: 'Everything you need for a great journey',
booking: 'Easy booking',
bookingDesc: 'Simple online booking with instant confirmation',
seats: 'Seat selection',
seatsDesc: 'Choose from economy, standard, and VIP coaches',
payment: 'Flexible payment',
paymentDesc: 'Pay with Telebirr, CBE Birr, cards, or wallet',
support: 'Customer support',
supportDesc: 'Live chat and 24/7 assistance available',
loyalty: 'Loyalty rewards',
loyaltyDesc: 'Earn points and unlock exclusive benefits',
tracking: 'Live tracking',
trackingDesc: 'Real-time updates on your train journey',
},
about: {
title: 'About EDR',
subtitle: 'Connecting East Africa',
mission: 'Our mission',
missionText: 'To provide reliable, affordable, and comfortable train travel connecting Ethiopia and Djibouti.',
network: 'Extensive network',
networkText: '21 stations across Ethiopia and Djibouti with modern infrastructure.',
comfort: 'Comfort first',
comfortText: 'Modern coaches designed for your comfort with multiple classes.',
eco: 'Eco-friendly',
ecoText: 'Sustainable travel option that reduces carbon footprint.',
},
contact: {
title: 'Get in touch',
subtitle: 'We are here to help',
email: 'Email',
phone: 'Phone',
address: 'Address',
form: 'Send us a message',
name: 'Full Name',
emailField: 'Email address',
subject: 'Subject',
message: 'Message',
send: 'Send message',
sending: 'Sending...',
success: 'Message sent successfully!',
error: 'Failed to send message',
},
help: {
title: 'Help and FAQs',
subtitle: 'Find answers to common questions',
bookingFaq: 'Booking questions',
how: 'How do I book a ticket?',
howAnswer: 'Visit our booking page, search for available trips, select your seats, and complete payment.',
modify: 'Can I modify my booking?',
modifyAnswer: 'Yes, you can modify bookings up to 24 hours before departure from your profile.',
cancel: 'What is the cancellation policy?',
cancelAnswer: 'Cancellations made 48 hours before departure receive full refund.',
paymentFaq: 'Payment questions',
payMethods: 'What payment methods do you accept?',
payMethodsAnswer: 'We accept Telebirr, CBE Birr, eBirr, credit/debit cards, and wallet balance.',
refund: 'When will I receive my refund?',
refundAnswer: 'Refunds are processed within 5-7 business days.',
other: 'Other questions',
docs: 'What documents do I need?',
docsAnswer: 'Valid national ID, passport, or travel permit required.',
help: 'Still need help?',
contact: 'Contact our support team',
},
footer: {
about: 'About',
support: 'Support',
privacy: 'Privacy policy',
terms: 'Terms of service',
follow: 'Follow us',
rights: 'All rights reserved.',
},
},
am: {
nav: {
home: 'ቤት',
services: 'አገልግሎቶች',
about: 'ስለ ኛ',
contact: 'አግኙን',
help: 'ርዳታ',
bookNow: 'አሁን ይመዝገቡ',
},
home: {
hero: 'በምስራቅ አፍሪካ ውስጥ ይጓዙ',
heroSub: 'ከኢትዮጵያ ወደ ጂቡቲ ello seamless train travel ያስተዋውቁ',
cta: 'ቁጠባ ይጀምሩ',
searchNow: 'ጉብኝቶች ይፈልጉ',
features: 'ለምን ኢደር ይምረጡ?',
comfortable: 'ምቹ ጉዞ',
comfortDesc: 'ዘመናዊ ሕንፃዎች ወንበር እና ማተም ወራጆች ጋር',
affordable: 'ርካሽ ዋጋ',
affordableDesc: 'ለቤተሰቦች ምጣኔ ሞገድ እና ቅናሾች',
safe: 'ደህንነተኛ & አስተማማኝ',
safeDesc: 'በጊዜ ምጡና 24/7 ደንበኛ ድጋፍ',
fast: 'ፈጣን ቁጠባ',
fastDesc: 'በደቂቃዎች ይመዝገቡ፣ ብዙ ዘዴዎችን ይከፍሉ',
},
services: {
title: 'አገልግሎቶቻችን',
subtitle: 'ታላቅ ጉዞ ለሚፈልጉ ሁሉ ነገር',
booking: 'ቀላል ቁጠባ',
bookingDesc: 'ቀላል የመስመር ላይ ቁጠባ ወቅታዊ ማረጋገጫ ጋር',
seats: 'ወንበር ምርጫ',
seatsDesc: 'ኢኮኖሚ፣ መደበኛ እና VIP ሕንፃዎች ምረጡ',
payment: '유연한ከጊዜ ወደ ጊዜ ክፍያ',
paymentDesc: 'Telebirr፣ CBE Birr፣ ካርዶች ወይም ዋሌት ይከፍሉ',
support: 'ደንበኛ ድጋፍ',
supportDesc: 'ライブ ቻት እና 24/7 ረዳት ይገኛሉ',
loyalty: 'ታማኝነት ሽልማቶች',
loyaltyDesc: 'ነጥቦች ያዙ እና ብቁ ጥቅሞች ያስከፍቱ',
tracking: 'ライブ ትንተና',
trackingDesc: 'የእርስዎ ባቡር ጉዞ ውስጥ ወቅታዊ ማሻሻያዎች',
},
about: {
title: 'ስለ ኢትዮጵያ-ጂቡቲ ባቡር',
subtitle: 'ምስራቅ አፍሪካ ያገናኙ',
mission: 'ራሳችን መጠን',
missionText: 'ኢትዮጵያ እና ጂቡቲን የሚያገናኙ አስተማማኝ፣ ርካሽ እና ምቹ ባቡር ጉዞ ይሰጡ።',
network: 'ሰፊ አውታር',
networkText: 'ኢትዮጵያ እና ጂቡቲ ውስጥ 21 ጣቢያዎች ዘመናዊ መሰረተ ልማት ጋር።',
comfort: 'ምቹ ሞላላ',
comfortText: 'ብዙ ክፍሎች ጋር የእርስዎ comfort ለ ዲዛይን ዘመናዊ ሕንፃዎች።',
eco: 'ପରିବେश __ 친화적',
ecoText: 'ካርቦን እግድ ሪሞት የሚቀንስ sustainable ጉዞ አማራጭ።',
},
contact: {
title: 'ከኛ ጋር ግንኙነት ወስጥ ደርሱ',
subtitle: 'እኛ ረድ ለ ልንሆን ሞገዱ',
email: 'ኢሜይል',
phone: 'ስልክ',
address: 'አድራሻ',
form: 'ለኛ መልእክት ይላኩ',
name: 'ሙሉ ስም',
emailField: 'ኢሜይል አድራሻ',
subject: 'ርዕስ',
message: 'መልእክት',
send: 'መልእክት ይላኩ',
sending: 'በመላክ ላይ...',
success: 'መልእክት በተሳካ ተልኩ!',
error: 'መልእክት ወደ ላክ ያልተሳካ',
},
help: {
title: 'ርዳታ & FAQs',
subtitle: 'ከ general ጥያቄዎች የሚመለስ መልስ ይፈልጉ',
bookingFaq: 'ቁጠባ ጥያቄዎች',
how: 'ስንት በየት ላይ ቁጠባ?',
howAnswer: 'ቁጠባ ገጽ ይጎብኙ፣ ዞሯ ለ ይፈልጉ ፣ ወንበር ይምረጡ ዝሕ ክፍያ።',
modify: 'እኔ ቁጠባ እንደገና ማስተካከል ይችላሉ?',
modifyAnswer: 'አዎ፣ መውጫ 24 ሰዓታት በፊት ቁጠባ እንደገና ማስተካከል ይችላሉ።',
cancel: 'cancellation ወሳኔ ምንድ ነው?',
cancelAnswer: 'አስመልከት 48 ሰዓታት በፊት አውጪ ሙሉ መልስ ያገኛሉ።',
paymentFaq: 'ክፍያ ጥያቄዎች',
payMethods: 'Telebirr, CBE Birr, eBirr, ክレジット/ዲቢት ካርዶች, እና ዋሌት ሚዛን ኤ acceptedይገቡ।',
payMethodsAnswer: 'Telebirr, CBE Birr, eBirr, credit/debit ካርዶች፣ እና ዋሌት ሚዛን ยอมrับ',
refund: 'my አሌ ሥራ refund?',
refundAnswer: 'Refund በ 5-7 ሥራ ቀናት ውስጥ ተወስኖልት።',
other: 'ሌሎች ጥያቄዎች',
docs: 'ኔ ወሰደ ምን documentation?',
docsAnswer: 'ዋጋ ብሔር ID፣ ፓስፖርት ወይም ጉዞ permit አስፈላጊ።',
help: 'አሁንም ረዳታ ፈልገው?',
contact: 'ደንበኛ ድጋፍ ቡድን ጋር ማንቲ',
},
footer: {
about: 'ስለ',
contact: 'አግኙን',
privacy: 'ግላዊነት ፖሊሲ',
terms: 'service ውል',
follow: 'ከሱ ተከተሉ',
rights: 'ሁሉ ሙሉ።',
},
},
om: {
nav: {
home: 'Mana',
services: 'Tajaajilaa',
about: 'Waa',
contact: 'Nu Ilaali',
help: 'Gargaarsa',
bookNow: 'Amma Qindeessuu',
},
home: {
hero: 'Kaasaa Baafata Gidiraa',
heroSub: 'Kunoosni caraa qammateen Itoophiyaa hoo Jibuuraa jira.',
cta: 'Qindeessuu Jalqabi',
searchNow: 'Naannoo Barbaadi',
features: 'Maaliif EDR Filachuu?',
comfortable: 'Wantaa Mijataa',
comfortDesc: 'Haalaan biraa konkolaata mooniisa seeda jira.',
affordable: 'Gatii Gabbina',
affordableDesc: 'Karoora muummichaa jala maallaqa qabeenya.',
safe: 'Nageenya & Jidha',
safeDesc: 'Yeroo jilbaa dhumaasuu fi gargaarsa 24/7 deggara.',
fast: 'Qindeessuu Abadii',
fastDesc: 'Yeroo giddu gidduu qindeessuu, mallaatoo ijaa gadii.',
},
services: {
title: 'Tajaajilaa Keenya',
subtitle: 'Waa waliigalaan wantoota gid caraa mijataa',
booking: 'Qindeessuu Salphaa',
bookingDesc: 'Qindeessuu interneetii salphaa jidha gaafatama',
seats: 'Filannoo Teessaa',
seatsDesc: 'Ekonomii, idileessaa jala VIP konkolaata filachuu',
payment: 'Maallaqa Giddu Gidduu',
paymentDesc: 'Telebirr, CBE Birr, kaardii ykn wallaattii jalqabi',
support: 'Gargaarsa Fayyadhaa',
supportDesc: 'Haftuu liixii jala gargaarsa 24/7 jira',
loyalty: 'Gammachiisa Jidha',
loyaltyDesc: 'Qooda qabu jila waan gaarii gargaara',
tracking: 'Jidha Liixii',
trackingDesc: 'Jiraataa kunoosni caraa qammateen gammachisu',
},
about: {
title: 'Waa Baafata Itoophiyaa-Jibuuraa',
subtitle: 'Baafata Gidiraa Yoo Wal Qabu',
mission: 'Kora Keenya',
missionText: 'Nageenya jidha, gatii gabbina, jidha mijataa Itoophiyaa jala Jibuuraa tajaajili.',
network: 'Shabdalee Babaasaa',
networkText: '21 taasaa Itoophiyaa jala Jibuuraa keessatti konkolaata biraa.',
comfort: 'Mirga Jalqabaa',
comfortText: 'Konkolaata mooniisa mijataa kee waraqsa gidduu jira.',
eco: 'Kaasaalee Mijataa',
ecoText: 'Caraa ijaa yoo sadarka karbuuni muraa gidduu.',
},
contact: {
title: 'Nu Ilaali',
subtitle: 'Nu gargaaruufi barbaachisaa',
email: 'Imeelii',
phone: 'Bilbila',
address: 'Tuulaa',
form: 'Naaraan Itti Ergi',
name: 'Maqaa Guutuu',
emailField: 'Imeelii tuula',
subject: 'Mata',
message: 'Itti Ergi',
send: 'Naaraan Ergi',
sending: 'Erguun jira...',
success: 'Naaraan gaafatama jira!',
error: 'Naaraan erguu hin raasuu',
},
help: {
title: 'Gargaarsa & FAQs',
subtitle: 'Gaaffii guutuu jala deebii barbaadi',
bookingFaq: 'Gaaffii Qindeessuu',
how: 'Akkamitti qindeessuu?',
howAnswer: 'Fuula qindeessuu dhugi, naannoo barbaadi, teessaa filachuu, maallaqa xumura.',
modify: 'Qindeessuu koo maratti jidha adeemsa?',
modifyAnswer: 'Eenyee, walta 24 sa itti fufu qindeessuu maratti jidha adeemsa dandeenya.',
cancel: 'Hoggansa gaafatama maal jira?',
cancelAnswer: 'Walta 48 sa itti fufu walitti erga dhumaasuu guutuu arguu jidha.',
paymentFaq: 'Gaaffii Maallaqa',
payMethods: 'Maallaqa keessaa maallaqa hayyamaa?',
payMethodsAnswer: 'Telebirr, CBE Birr, eBirr, kaardii kreediitii/dibati, jala wallaattii haa jira.',
refund: 'Yeroo maallaqa deebina?',
refundAnswer: 'Maallaaq 5-7 guyyaa hojii keessatti deebi.',
other: 'Gaaffii Biraa',
docs: 'Waraqaa maal barbaachisa?',
docsAnswer: 'Kaardii ID bakka, paaspoortii ykn walii haa barbaachisaa.',
help: 'Haaluma facaasin gargaarsa Barbaadi?',
contact: 'Gargaarsa fayyadhaa mana waliin ilaali',
},
footer: {
about: 'Waa',
contact: 'Nu Ilaali',
privacy: 'Kabaja Kunnamuu',
terms: 'Seera Tajaajila',
follow: 'Isaa Hordofu',
rights: 'Hundi moodeewwan.',
},
},
fr: {
nav: {
home: 'Accueil',
services: 'Services',
about: 'À Propos',
contact: 'Contact',
help: 'Aide',
bookNow: 'Réserver Maintenant',
},
home: {
hero: 'Voyagez à travers l\'Afrique de l\'Est',
heroSub: 'Découvrez les voyages en train sans interruption d\'Éthiopie à Djibouti',
cta: 'Commencer la Réservation',
searchNow: 'Rechercher des Trajets',
features: 'Pourquoi Choisir EDR?',
comfortable: 'Voyage Confortable',
comfortDesc: 'Autobus modernes avec sièges et couchettes',
affordable: 'Prix Abordables',
affordableDesc: 'Tarifs compétitifs avec réductions pour familles',
safe: 'Sûr & Fiable',
safeDesc: 'Arrivées à l\'heure avec support client 24/7',
fast: 'Réservation Rapide',
fastDesc: 'Réservez en minutes, payez par plusieurs méthodes',
},
services: {
title: 'Nos Services',
subtitle: 'Tout ce dont vous avez besoin pour un excellent voyage',
booking: 'Réservation Facile',
bookingDesc: 'Réservation en ligne simple avec confirmation instantanée',
seats: 'Sélection des Sièges',
seatsDesc: 'Choisissez parmi les autobus économique, standard et VIP',
payment: 'Paiement Flexible',
paymentDesc: 'Payez avec Telebirr, CBE Birr, cartes ou portefeuille',
support: 'Support Client',
supportDesc: 'Chat en direct et assistance 24/7 disponibles',
loyalty: 'Récompenses de Fidélité',
loyaltyDesc: 'Gagnez des points et déverrouillez des avantages exclusifs',
tracking: 'Suivi en Direct',
trackingDesc: 'Mises à jour en temps réel de votre voyage en train',
},
about: {
title: 'À Propos du Chemin de Fer Éthiopie-Djibouti',
subtitle: 'Connecter l\'Afrique de l\'Est',
mission: 'Notre Mission',
missionText: 'Offrir des voyages en train fiables, abordables et confortables reliant l\'Éthiopie et Djibouti.',
network: 'Réseau Étendu',
networkText: '21 gares à travers l\'Éthiopie et Djibouti avec infrastructure moderne.',
comfort: 'Confort D\'Abord',
comfortText: 'Autobus modernes conçus pour votre confort avec plusieurs catégories.',
eco: 'Écologique',
ecoText: 'Option de voyage durable qui réduit l\'empreinte carbone.',
},
contact: {
title: 'Contactez-Nous',
subtitle: 'Nous sommes là pour vous aider',
email: 'Email',
phone: 'Téléphone',
address: 'Adresse',
form: 'Envoyez-Nous un Message',
name: 'Nom Complet',
emailField: 'Adresse E-mail',
subject: 'Sujet',
message: 'Message',
send: 'Envoyer le Message',
sending: 'Envoi en cours...',
success: 'Message envoyé avec succès!',
error: 'Échec de l\'envoi du message',
},
help: {
title: 'Aide & Questions Fréquentes',
subtitle: 'Trouvez des réponses aux questions courantes',
bookingFaq: 'Questions de Réservation',
how: 'Comment réserver un billet?',
howAnswer: 'Visitez notre page de réservation, recherchez les trajets disponibles, sélectionnez vos sièges et complétez le paiement.',
modify: 'Puis-je modifier ma réservation?',
modifyAnswer: 'Oui, vous pouvez modifier les réservations jusqu\'à 24 heures avant le départ.',
cancel: 'Quelle est la politique d\'annulation?',
cancelAnswer: 'Les annulations effectuées 48 heures avant le départ reçoivent un remboursement complet.',
paymentFaq: 'Questions de Paiement',
payMethods: 'Quels modes de paiement acceptez-vous?',
payMethodsAnswer: 'Nous acceptons Telebirr, CBE Birr, eBirr, cartes bancaires et portefeuille.',
refund: 'Quand recevrai-je mon remboursement?',
refundAnswer: 'Les remboursements sont traités dans les 5-7 jours ouvrables.',
other: 'Autres Questions',
docs: 'Quels documents dois-je avoir?',
docsAnswer: 'Une carte d\'identité nationale, un passeport ou un permis de voyage valide est requis.',
help: 'Avez-vous toujours besoin d\'aide?',
contact: 'Contactez notre équipe d\'assistance',
},
footer: {
about: 'À Propos',
contact: 'Contact',
privacy: 'Politique de Confidentialité',
terms: 'Conditions d\'Utilisation',
follow: 'Suivez-Nous',
rights: 'Tous droits réservés.',
},
},
};
export function getTranslation(lang: Language, key: string): string {
const keys = key.split('.');
let value: any = translations[lang];
for (const k of keys) {
value = value?.[k];
}
return value || key;
}
export function useLanguage() {
const getLang = (): Language => {
if (typeof window === 'undefined') return 'en';
return (localStorage.getItem('language') as Language) || 'en';
};
const setLang = (lang: Language) => {
localStorage.setItem('language', lang);
window.dispatchEvent(new CustomEvent('languageChange', { detail: lang }));
};
return { getLang, setLang };
}

View File

@@ -0,0 +1,88 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: 'class',
content: [
'./src/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
primary: 'rgb(20 113 76)',
},
backgroundColor: {
primary: 'rgb(20 113 76)',
},
textColor: {
primary: 'rgb(20 113 76)',
},
borderColor: {
primary: 'rgb(20 113 76)',
},
ringColor: {
primary: 'rgb(20 113 76)',
},
animation: {
'bounce-in': 'bounce-in 0.5s cubic-bezier(0.34, 1.56, 0.64, 1)',
'float': 'float 6s ease-in-out infinite',
'shimmer': 'shimmer 2s infinite',
'slide-in-left': 'slide-in-left 0.5s ease-out',
'slide-in-right': 'slide-in-right 0.5s ease-out',
},
keyframes: {
'bounce-in': {
'0%': {
opacity: '0',
transform: 'translateY(20px) scale(0.9)',
},
'50%': {
opacity: '1',
},
'100%': {
opacity: '1',
transform: 'translateY(0) scale(1)',
},
},
'float': {
'0%, 100%': {
transform: 'translateY(0px)',
},
'50%': {
transform: 'translateY(-20px)',
},
},
'shimmer': {
'0%': {
'background-position': '-1000px 0',
},
'100%': {
'background-position': '1000px 0',
},
},
'slide-in-left': {
'from': {
opacity: '0',
transform: 'translateX(-20px)',
},
'to': {
opacity: '1',
transform: 'translateX(0)',
},
},
'slide-in-right': {
'from': {
opacity: '0',
transform: 'translateX(20px)',
},
'to': {
opacity: '1',
transform: 'translateX(0)',
},
},
},
},
},
plugins: [],
};

202
pnpm-lock.yaml generated
View File

@@ -183,6 +183,9 @@ importers:
class-validator:
specifier: ^0.14.0
version: 0.14.4
express:
specifier: ^4.18.2
version: 4.22.2
jose:
specifier: ^5.10.0
version: 5.10.0
@@ -203,7 +206,7 @@ importers:
version: 7.8.2
swagger-ui-express:
specifier: ^5.0.0
version: 5.0.1(express@5.2.1)
version: 5.0.1(express@4.22.2)
tsconfig-paths:
specifier: ^4.2.0
version: 4.2.0
@@ -2007,6 +2010,10 @@ packages:
abbrev@1.1.1:
resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}
accepts@1.3.8:
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
engines: {node: '>= 0.6'}
accepts@2.0.0:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
engines: {node: '>= 0.6'}
@@ -2296,6 +2303,9 @@ packages:
resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==}
engines: {node: '>= 0.4'}
array-flatten@1.1.1:
resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==}
array-ify@1.0.0:
resolution: {integrity: sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==}
@@ -2456,6 +2466,10 @@ packages:
bluebird@3.4.7:
resolution: {integrity: sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==}
body-parser@1.20.5:
resolution: {integrity: sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==}
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
body-parser@2.2.2:
resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==}
engines: {node: '>=18'}
@@ -2755,6 +2769,10 @@ packages:
console-control-strings@1.1.0:
resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==}
content-disposition@0.5.4:
resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==}
engines: {node: '>= 0.6'}
content-disposition@1.1.0:
resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==}
engines: {node: '>=18'}
@@ -2783,6 +2801,9 @@ packages:
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
cookie-signature@1.0.7:
resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==}
cookie-signature@1.2.2:
resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
engines: {node: '>=6.6.0'}
@@ -3047,6 +3068,10 @@ packages:
destr@2.0.5:
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
destroy@1.2.0:
resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==}
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
@@ -3418,6 +3443,10 @@ packages:
resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
express@4.22.2:
resolution: {integrity: sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==}
engines: {node: '>= 0.10.0'}
express@5.2.1:
resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
engines: {node: '>= 18'}
@@ -3511,6 +3540,10 @@ packages:
resolution: {integrity: sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==}
engines: {node: '>=0.10.0'}
finalhandler@1.3.2:
resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==}
engines: {node: '>= 0.8'}
finalhandler@2.1.1:
resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
engines: {node: '>= 18.0.0'}
@@ -3593,6 +3626,10 @@ packages:
resolution: {integrity: sha512-GMBAbW9antB8iZRHLoGw0b3HANt57diZYFO/HL1JGIC1MjKrdmhxvrJbupnVvpys0zsz7yBApXdQyfepKly2kA==}
engines: {node: '>=0.10.0'}
fresh@0.5.2:
resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==}
engines: {node: '>= 0.6'}
fresh@2.0.0:
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
engines: {node: '>= 0.8'}
@@ -3867,6 +3904,10 @@ packages:
engines: {node: '>=18'}
hasBin: true
iconv-lite@0.4.24:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
engines: {node: '>=0.10.0'}
iconv-lite@0.7.2:
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
engines: {node: '>=0.10.0'}
@@ -4733,6 +4774,9 @@ packages:
resolution: {integrity: sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==}
engines: {node: '>=16.10'}
merge-descriptors@1.0.3:
resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==}
merge-descriptors@2.0.0:
resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==}
engines: {node: '>=18'}
@@ -4772,6 +4816,11 @@ packages:
resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
engines: {node: '>=18'}
mime@1.6.0:
resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==}
engines: {node: '>=4'}
hasBin: true
mime@2.6.0:
resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==}
engines: {node: '>=4.0.0'}
@@ -4877,6 +4926,10 @@ packages:
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
negotiator@0.6.3:
resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==}
engines: {node: '>= 0.6'}
negotiator@1.0.0:
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
engines: {node: '>= 0.6'}
@@ -5154,6 +5207,9 @@ packages:
resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==}
engines: {node: 18 || 20 || >=22}
path-to-regexp@0.1.13:
resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==}
path-to-regexp@3.3.0:
resolution: {integrity: sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==}
@@ -5400,6 +5456,10 @@ packages:
rapiq@0.9.0:
resolution: {integrity: sha512-k4oT4RarFBrlLMJ49xUTeQpa/us0uU4I70D/UEnK3FWQ4GENzei01rEQAmvPKAIzACo4NMW+YcYJ7EVfSa7EFg==}
raw-body@2.5.3:
resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==}
engines: {node: '>= 0.8'}
raw-body@3.0.2:
resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==}
engines: {node: '>= 0.10'}
@@ -5649,10 +5709,18 @@ packages:
engines: {node: '>=10'}
hasBin: true
send@0.19.2:
resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==}
engines: {node: '>= 0.8.0'}
send@1.2.1:
resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==}
engines: {node: '>= 18'}
serve-static@1.16.3:
resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==}
engines: {node: '>= 0.8.0'}
serve-static@2.2.1:
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
engines: {node: '>= 18'}
@@ -8385,6 +8453,11 @@ snapshots:
abbrev@1.1.1: {}
accepts@1.3.8:
dependencies:
mime-types: 2.1.35
negotiator: 0.6.3
accepts@2.0.0:
dependencies:
mime-types: 3.0.2
@@ -8695,6 +8768,8 @@ snapshots:
call-bound: 1.0.4
is-array-buffer: 3.0.5
array-flatten@1.1.1: {}
array-ify@1.0.0: {}
array-includes@3.1.9:
@@ -8916,6 +8991,23 @@ snapshots:
bluebird@3.4.7: {}
body-parser@1.20.5:
dependencies:
bytes: 3.1.2
content-type: 1.0.5
debug: 2.6.9
depd: 2.0.0
destroy: 1.2.0
http-errors: 2.0.1
iconv-lite: 0.4.24
on-finished: 2.4.1
qs: 6.15.2
raw-body: 2.5.3
type-is: 1.6.18
unpipe: 1.0.0
transitivePeerDependencies:
- supports-color
body-parser@2.2.2:
dependencies:
bytes: 3.1.2
@@ -9243,6 +9335,10 @@ snapshots:
console-control-strings@1.1.0: {}
content-disposition@0.5.4:
dependencies:
safe-buffer: 5.2.1
content-disposition@1.1.0: {}
content-type@1.0.5: {}
@@ -9266,6 +9362,8 @@ snapshots:
convert-source-map@2.0.0: {}
cookie-signature@1.0.7: {}
cookie-signature@1.2.2: {}
cookie@0.7.2: {}
@@ -9495,6 +9593,8 @@ snapshots:
destr@2.0.5: {}
destroy@1.2.0: {}
detect-libc@2.1.2: {}
detect-newline@3.1.0: {}
@@ -10014,6 +10114,42 @@ snapshots:
jest-message-util: 29.7.0
jest-util: 29.7.0
express@4.22.2:
dependencies:
accepts: 1.3.8
array-flatten: 1.1.1
body-parser: 1.20.5
content-disposition: 0.5.4
content-type: 1.0.5
cookie: 0.7.2
cookie-signature: 1.0.7
debug: 2.6.9
depd: 2.0.0
encodeurl: 2.0.0
escape-html: 1.0.3
etag: 1.8.1
finalhandler: 1.3.2
fresh: 0.5.2
http-errors: 2.0.1
merge-descriptors: 1.0.3
methods: 1.1.2
on-finished: 2.4.1
parseurl: 1.3.3
path-to-regexp: 0.1.13
proxy-addr: 2.0.7
qs: 6.15.2
range-parser: 1.2.1
safe-buffer: 5.2.1
send: 0.19.2
serve-static: 1.16.3
setprototypeof: 1.2.0
statuses: 2.0.2
type-is: 1.6.18
utils-merge: 1.0.1
vary: 1.1.2
transitivePeerDependencies:
- supports-color
express@5.2.1:
dependencies:
accepts: 2.0.0
@@ -10146,6 +10282,18 @@ snapshots:
filter-obj@1.1.0: {}
finalhandler@1.3.2:
dependencies:
debug: 2.6.9
encodeurl: 2.0.0
escape-html: 1.0.3
on-finished: 2.4.1
parseurl: 1.3.3
statuses: 2.0.2
unpipe: 1.0.0
transitivePeerDependencies:
- supports-color
finalhandler@2.1.1:
dependencies:
debug: 4.4.3
@@ -10241,6 +10389,8 @@ snapshots:
dependencies:
map-cache: 0.2.2
fresh@0.5.2: {}
fresh@2.0.0: {}
fs-constants@1.0.0: {}
@@ -10573,6 +10723,10 @@ snapshots:
husky@9.1.7: {}
iconv-lite@0.4.24:
dependencies:
safer-buffer: 2.1.2
iconv-lite@0.7.2:
dependencies:
safer-buffer: 2.1.2
@@ -11600,6 +11754,8 @@ snapshots:
meow@12.1.1: {}
merge-descriptors@1.0.3: {}
merge-descriptors@2.0.0: {}
merge-stream@2.0.0: {}
@@ -11643,6 +11799,8 @@ snapshots:
dependencies:
mime-db: 1.54.0
mime@1.6.0: {}
mime@2.6.0: {}
mimic-fn@2.1.0: {}
@@ -11753,6 +11911,8 @@ snapshots:
natural-compare@1.4.0: {}
negotiator@0.6.3: {}
negotiator@1.0.0: {}
neo-async@2.6.2: {}
@@ -12043,6 +12203,8 @@ snapshots:
lru-cache: 11.5.0
minipass: 7.1.3
path-to-regexp@0.1.13: {}
path-to-regexp@3.3.0: {}
path-to-regexp@8.4.2: {}
@@ -12249,6 +12411,13 @@ snapshots:
ebec: 1.1.1
smob: 1.6.2
raw-body@2.5.3:
dependencies:
bytes: 3.1.2
http-errors: 2.0.1
iconv-lite: 0.4.24
unpipe: 1.0.0
raw-body@3.0.2:
dependencies:
bytes: 3.1.2
@@ -12525,6 +12694,24 @@ snapshots:
semver@7.8.1: {}
send@0.19.2:
dependencies:
debug: 2.6.9
depd: 2.0.0
destroy: 1.2.0
encodeurl: 2.0.0
escape-html: 1.0.3
etag: 1.8.1
fresh: 0.5.2
http-errors: 2.0.1
mime: 1.6.0
ms: 2.1.3
on-finished: 2.4.1
range-parser: 1.2.1
statuses: 2.0.2
transitivePeerDependencies:
- supports-color
send@1.2.1:
dependencies:
debug: 4.4.3
@@ -12541,6 +12728,15 @@ snapshots:
transitivePeerDependencies:
- supports-color
serve-static@1.16.3:
dependencies:
encodeurl: 2.0.0
escape-html: 1.0.3
parseurl: 1.3.3
send: 0.19.2
transitivePeerDependencies:
- supports-color
serve-static@2.2.1:
dependencies:
encodeurl: 2.0.0
@@ -12906,9 +13102,9 @@ snapshots:
dependencies:
'@scarf/scarf': 1.4.0
swagger-ui-express@5.0.1(express@5.2.1):
swagger-ui-express@5.0.1(express@4.22.2):
dependencies:
express: 5.2.1
express: 4.22.2
swagger-ui-dist: 5.32.6
symbol-observable@4.0.0: {}