Production cleanups

This commit is contained in:
Stephanos A
2026-07-08 09:01:07 +03:00
parent 63fa6996b1
commit c7403e1a1e
21 changed files with 701 additions and 755 deletions

View File

@@ -369,7 +369,7 @@ pnpm --filter @edr/passenger-api run prisma:seed
- 3 User accounts (Admin, Passenger, Agent)
- Fare rules for ADULT and CHILD passenger categories
- Currency exchange rates (ETB, DJF, USD)
- Baggage allowance rules
- Luggage allowance rules
- Notification templates
- Promotions and FAQ content
- Menu items and station crowd signals

View File

@@ -60,7 +60,7 @@ async function bootstrap() {
Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and management platform. Built with NestJS, TypeScript, PostgreSQL, and Prisma ORM.
## Latest Updates
- **Enhanced Module Coverage:** Complete API coverage with 25+ core modules including System Config, Excess Baggage, Packages, and comprehensive CRUD operations across all entities.
- **Enhanced Module Coverage:** Complete API coverage with 25+ core modules including System Config, Excess Luggage, Packages, and comprehensive CRUD operations across all entities.
- **Health Check Endpoints:** Three public probes added under \`/health\`. Liveness (\`GET /health\`), readiness with live DB ping (\`GET /health/ready\`), and app info (\`GET /health/info\`). All are exempt from rate limiting.
- **Rate Limiting:** Global throttle enforced via ThrottlerGuard with three named tiers: auth (5 req/min on \`/auth\` and \`/fayda/verification\`), strict (20 req/min on \`/bookings\`, \`/passengers\`, \`/payments\`, \`/wallet\`), default (100 req/min everywhere else). Health probes, webhook handlers, and internal service endpoints are exempt.
- **Boarding Pass on Gate Validation:** Every successful gate validation at \`POST /tickets/:ref/validate\` now automatically delivers a boarding pass to the passenger via email (full HTML with QR code, route, seat table) and SMS (compact text with ref, route, seats, barcode). The leg label (OUTBOUND, RETURN, LEG1, etc.) is included so passengers know which boarding it covers.
@@ -328,7 +328,7 @@ Payment providers send notifications to:
"JWT-auth",
)
.addTag("Agents", "Counter booking, shift management, commission tracking, and reconciliation")
.addTag("Excess Baggage", "IAM-protected agent/supervisor endpoints to log excess baggage charges, waive fees, resend payment links, and manage allowance rules per seat class. Public token-based endpoints let passengers self-pay outstanding charges.")
.addTag("Excess Luggage", "IAM-protected agent/supervisor endpoints to log excess baggage charges, waive fees, resend payment links, and manage allowance rules per seat class. Public token-based endpoints let passengers self-pay outstanding charges.")
.addTag("Packages", "Bundled travel packages with tiered pricing. Public endpoints for browsing and booking; JWT-authenticated endpoints for purchase history; IAM-protected endpoints for admin CRUD and tier management.")
.addTag("Config", "System-wide configuration management including feature flags, maintenance modes, and operational parameters. IAM-protected endpoints for administrative control.")
.addTag("Audit", "User activity logging, system changes, compliance tracking, and audit trails")

View File

@@ -17,6 +17,7 @@ function generateRef(): string {
return Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
}
/**
* For package round-trip bookings, totalMinor in the DB may have been stored as a
* single-leg amount before the server fix. Recompute from the tier price when needed.

View File

@@ -14,7 +14,7 @@ const BOOKING_CUTOFF_MS = 30 * 60 * 1000;
function generateRef(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
return 'EDR-' + Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
return Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
}
// Ethiopian mobile prefixes: Ethio Telecom (09xx) and Safaricom ET (07xx)

View File

@@ -17,7 +17,7 @@ class UpsertBaggageAllowanceDto {
}
// ── IAM-protected agent/supervisor routes ────────────────────────────────────
@ApiTags('Excess Baggage')
@ApiTags('Excess Luggage')
@Controller('agents/excess-baggage')
@UseGuards(IamJwtGuard)
@ApiBearerAuth('IAM-auth')
@@ -100,7 +100,7 @@ export class ExcessBaggageAgentController {
}
// ── Public pay-by-token routes (passenger self-service) ──────────────────────
@ApiTags('Excess Baggage')
@ApiTags('Excess Luggage')
@Controller('excess-baggage')
export class ExcessBaggagePublicController {
constructor(private service: ExcessBaggageService) {}

View File

@@ -4,9 +4,10 @@ import { SearchService } from './search.service';
import { CurrencyModule } from '../currency/currency.module';
import { FareEngineModule } from '../fare-engine/fare-engine.module';
import { SegmentsModule } from '../segments/segments.module';
import { SystemConfigModule } from '../system-config/system-config.module';
@Module({
imports: [CurrencyModule, FareEngineModule, SegmentsModule],
imports: [CurrencyModule, FareEngineModule, SegmentsModule, SystemConfigModule],
controllers: [SearchController],
providers: [SearchService],
exports: [SearchService],

View File

@@ -6,6 +6,7 @@ import { FareEngineService } from '../fare-engine/fare-engine.service';
import { SegmentsService } from '../segments/segments.service';
import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto';
import { Currency } from '@prisma/client';
import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
const POINTS_TO_MINOR = 10;
@@ -40,8 +41,13 @@ export class SearchService {
private currencyService: CurrencyService,
private fareEngine: FareEngineService,
private segmentsService: SegmentsService,
private systemConfig: SystemConfigService,
) {}
private async getCutoffHours(): Promise<number> {
return this.systemConfig.getNumber(CONFIG_KEYS.HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE);
}
async searchTrips(dto: SearchTripsDto) {
const [direct, transit] = await Promise.all([
this.searchSchedules(
@@ -166,13 +172,14 @@ export class SearchService {
const schedules = await this.prisma.trainSchedule.findMany({
where: {
status: { in: ['SCHEDULED', 'BOARDING'] },
status: 'SCHEDULED',
isPackageOnly: false,
OR: [
{ departureAt: { gte: windowStart, lt: requestedDate } },
{ departureAt: { gte: requestedNextDay < now ? now : requestedNextDay, lt: windowEnd } },
],
stopTimes: { some: { stationId: originStationId } },
coachAssignments: { some: {} },
},
include: SCHEDULE_INCLUDE,
orderBy: { departureAt: 'asc' },
@@ -183,7 +190,7 @@ export class SearchService {
this.buildScheduleResult(schedule as any, originStationId, destinationStationId, totalPassengers, nationality)
)
);
return results.filter(Boolean);
return results.filter((r): r is NonNullable<typeof r> => !!r && r.hasAvailability);
}
private async searchSchedules(
@@ -200,12 +207,17 @@ export class SearchService {
const now = new Date();
const totalPassengers = adultCount + (childCount ?? 0);
const cutoffHours = await this.getCutoffHours();
const cutoffThreshold = new Date(now.getTime() + cutoffHours * 60 * 60 * 1000);
const earliest = new Date(Math.max((date < now ? now : date).getTime(), cutoffThreshold.getTime()));
const schedules = await this.prisma.trainSchedule.findMany({
where: {
status: { in: ['SCHEDULED', 'BOARDING'] },
status: 'SCHEDULED',
isPackageOnly: false,
departureAt: { gte: date < now ? now : date, lt: nextDay },
departureAt: { gte: earliest, lt: nextDay },
stopTimes: { some: { stationId: originStationId } },
coachAssignments: { some: {} },
},
include: SCHEDULE_INCLUDE,
});
@@ -215,7 +227,7 @@ export class SearchService {
this.buildScheduleResult(schedule as any, originStationId, destinationStationId, totalPassengers, nationality)
)
);
return results.filter(Boolean);
return results.filter((r): r is NonNullable<typeof r> => !!r && r.hasAvailability);
}
// ── Transit search ─────────────────────────────────────────────────────────
@@ -241,26 +253,31 @@ export class SearchService {
const [leg1Schedules, allCandidates] = await Promise.all([
this.prisma.trainSchedule.findMany({
where: {
status: { in: ['SCHEDULED', 'BOARDING'] },
status: 'SCHEDULED',
isPackageOnly: false,
departureAt: { gte: dayStart, lt: dayEnd },
stopTimes: { some: { stationId: originStationId } },
coachAssignments: { some: {} },
},
include: SCHEDULE_INCLUDE,
}),
this.prisma.trainSchedule.findMany({
where: {
status: { in: ['SCHEDULED', 'BOARDING'] },
status: 'SCHEDULED',
isPackageOnly: false,
departureAt: { gte: dayStart, lt: leg2WindowEnd },
coachAssignments: { some: {} },
},
include: SCHEDULE_INCLUDE,
}),
]);
const cutoffHours = await this.getCutoffHours();
const cutoffThreshold = new Date(Date.now() + cutoffHours * 60 * 60 * 1000);
const results: any[] = [];
for (const leg1 of leg1Schedules as ScheduleWithIncludes[]) {
for (const leg1 of (leg1Schedules as ScheduleWithIncludes[]).filter(s => new Date(s.departureAt) > cutoffThreshold)) {
const originStop = leg1.stopTimes.find(s => s.stationId === originStationId);
if (!originStop) continue;
@@ -354,6 +371,9 @@ export class SearchService {
.map((s: any) => s.id as string)
);
// Exclude schedules with no seats at all
if (allValidSeatIds.length === 0) return null;
// Run availability batch and fare calculation in parallel
const [freeSeats, faresByClass] = await Promise.all([
this.segmentsService.getFreeSeatIds(

View File

@@ -34,7 +34,7 @@ The Passenger Backoffice Application is a comprehensive management system for th
- **Live Tracking**: Monitor trip status and real-time updates
- **Security Monitoring**: Fraud detection and audit logging
- **Comprehensive Analytics**: Revenue, occupancy, and performance reports
- **🆕 Excess Baggage Management**: Handle boarding baggage charges with agent tools
- **🆕 Excess Luggage Management**: Handle boarding baggage charges with agent tools
- **🆕 Travel Packages**: Manage pilgrimage and group travel packages with tiered pricing
- **🆕 System Health Monitoring**: Real-time API health checks and system status
- **🆕 Advanced Fare Configuration**: Dynamic pricing with segment-based rules
@@ -94,7 +94,7 @@ The application is organized into 8 main sections:
├── System Config
└── Settings
└── Enhanced Features
├── Excess Baggage
├── Excess Luggage
├── Travel Packages
├── Package Inquiries
├── Health Monitoring
@@ -2862,7 +2862,7 @@ Action: Block user
### Version 1.0.0 (January 15, 2026)
- **Complete Platform Release** - Full-featured passenger management system
- **Excess Baggage Management** - Complete boarding baggage handling with agent tools and passenger self-pay options
- **Excess Luggage Management** - Complete boarding baggage handling with agent tools and passenger self-pay options
- **Travel Packages** - Pilgrimage and group travel packages with tiered pricing, capacity management, and inquiry handling
- **System Health Monitoring** - Real-time API health checks with liveness, readiness, and performance metrics
- **System Configuration** - Centralized config management with feature flags, rate limiting, and operational controls
@@ -2920,7 +2920,7 @@ Action: Block user
## Enhanced Features
### Excess Baggage
### Excess Luggage
**Purpose**: Manage excess baggage charges at boarding with agent tools and passenger self-pay
**Access Level**: Agent, Supervisor, Admin
@@ -2941,7 +2941,7 @@ Action: Block user
└──────────────────────────────┘
```
#### Excess Baggage Process
#### Excess Luggage Process
1. **At Boarding**: Agent weighs passenger baggage
2. **If Excess**: Agent creates charge in system
@@ -2962,8 +2962,8 @@ Action: Block user
##### READ (List Charges)
1. **Access Excess Baggage Page**:
- Click **Excess Baggage** in Enhanced Features section
1. **Access Excess Luggage Page**:
- Click **Excess Luggage** in Enhanced Features section
- Shows all baggage charges
2. **Search & Filter**:
@@ -3000,7 +3000,7 @@ Action: Block user
#### Agent Workflow
1. **Weigh Baggage**: Use station scales
1. **Weigh Luggage**: Use station scales
2. **Check Allowance**: Compare to passenger's seat class allowance
3. **Create Charge**: If excess weight found
4. **Offer Payment Options**:

View File

@@ -311,41 +311,25 @@ export default function ClassesPage() {
<p className="text-xs text-muted-foreground mt-1">Per-km distance-based fare rate</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
<div>
<label className="label">Premium Fee (ETB)</label>
<input
type="number"
name="premiumMinor"
className="input"
defaultValue={editingClass?.premiumMinor ? (editingClass.premiumMinor / 100).toFixed(2) : '0.00'}
min="0"
step="0.01"
placeholder="e.g., 50.00"
/>
<p className="text-xs text-muted-foreground mt-1">Flat fee per passenger (e.g., lounge access, extra legroom)</p>
</div>
<div>
<label className="label">Insurance Fee (ETB)</label>
<input
type="number"
name="insuranceFeeMinor"
className="input"
defaultValue={editingClass?.insuranceFeeMinor ? (editingClass.insuranceFeeMinor / 100).toFixed(2) : '0.00'}
min="0"
step="0.01"
placeholder="e.g., 25.00"
/>
<p className="text-xs text-muted-foreground mt-1">Flat fee per passenger (e.g., travel insurance)</p>
</div>
<input type="hidden" name="premiumMinor" value="0" />
<div className="mt-4">
<label className="label">Insurance Fee (ETB)</label>
<input
type="number"
name="insuranceFeeMinor"
className="input"
defaultValue={editingClass?.insuranceFeeMinor ? (editingClass.insuranceFeeMinor / 100).toFixed(2) : '0.00'}
min="0"
step="0.01"
placeholder="e.g., 25.00"
/>
<p className="text-xs text-muted-foreground mt-1">Flat fee per passenger (e.g., travel insurance)</p>
</div>
<div className="mt-4 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded text-sm text-blue-800 dark:text-blue-200">
<p className="font-medium mb-1">Total Fare Calculation:</p>
<p>Total = (Base Fare × Distance) + Premium + Insurance</p>
<p className="mt-2 text-xs"> Premium applies per passenger</p>
<p className="text-xs"> Insurance applies per passenger</p>
<p>Total = (Base Fare × Distance) + Insurance</p>
<p className="mt-2 text-xs"> Insurance applies per passenger</p>
</div>
</div>

View File

@@ -220,13 +220,6 @@ export default function CurrenciesPage() {
)}
</div>
<div className="card bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 text-sm text-blue-800 dark:text-blue-300 space-y-1">
<p className="font-semibold text-blue-900 dark:text-blue-200 mb-2">How it works</p>
<p> ETB is the transaction currency all fares are stored in ETB minor units (1 ETB = 100 minor)</p>
<p> DJF and USD rates are used to display prices to passengers in their preferred currency</p>
<p> Rates apply globally; changes take effect immediately on the next booking or fare quote</p>
</div>
<Modal
isOpen={showAddModal}
onClose={() => { setShowAddModal(false); setError(null); }}

View File

@@ -82,7 +82,7 @@ export default function OperationsSection() {
{/* EXCESS BAGGAGE */}
<div id="excess-baggage" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">📦 Luggage (Excess Baggage)</h2>
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">📦 Luggage (Excess Luggage)</h2>
<p className={li}>Handle excess baggage charges at boarding passenger self-pay or agent cash collection. Access level: Agent, Supervisor, Admin.</p>
<div className="mt-3 flex flex-wrap gap-2 text-xs">
{['PENDING','PAID','CASH_COLLECTED','EXPIRED','WAIVED'].map(s => (
@@ -91,7 +91,7 @@ export default function OperationsSection() {
</div>
</div>
<div id="excess-baggage-how" className="border-t pt-8">
<h3 className="text-xl font-bold text-slate-900 dark:text-white mb-4">📦 How-To: Handle Excess Baggage</h3>
<h3 className="text-xl font-bold text-slate-900 dark:text-white mb-4">📦 How-To: Handle Excess Luggage</h3>
<div className="space-y-3">
<HowToStep number={1} title="Find Charges">
<ol className={ol}><li>Click <strong>Luggage</strong> in Operations</li><li>Search by booking reference; filter by status or date</li></ol>

View File

@@ -609,7 +609,7 @@ export default function PricingPage() {
className={`px-4 py-2 font-medium border-b-2 transition-colors ${tab === 'baggage' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground'
}`}
>
Excess Baggage Rates
Excess Luggage Rates
</button>
</div>
@@ -765,24 +765,6 @@ export default function PricingPage() {
</div>
</div>
<div className="card bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800">
<h3 className="font-semibold text-blue-900 dark:text-blue-200 mb-3">Pricing Structure</h3>
<ul className="text-sm text-blue-800 dark:text-blue-300 space-y-2">
<li>
<strong>Segment Fares:</strong> Set fares for specific stop-to-stop segments (e.g., Addis Dire Dawa)
</li>
<li>
<strong>Schedule Fares:</strong> Set custom pricing for each schedule by seat class and passenger type
</li>
<li>
<strong>Passenger Type:</strong> ADULT (5+ years) or CHILD (&lt;5) first child travels free, subsequent children pay full fare
</li>
<li>
<strong>Nationality-based:</strong> Override fares for specific nationalities (Ethiopian, Djiboutian, Other)
</li>
</ul>
</div>
{/* Delete Confirmation */}
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
@@ -1113,7 +1095,7 @@ export default function PricingPage() {
</div>
</div>
</Modal>
{/* Baggage Allowance Modal */}
{/* Luggage Allowance Modal */}
<Modal isOpen={baggageModal} onClose={() => { setBaggageModal(false); setEditingAllowance(null); setBaggageError(null); }} title={editingAllowance ? 'Edit Allowance Rule' : 'Add Allowance Rule'} size="md">
<div className="space-y-4">
{baggageError && <div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 rounded-lg text-sm text-red-800 dark:text-red-200">{baggageError}</div>}

View File

@@ -52,15 +52,28 @@ export default function SeatsPage() {
queryFn: async () => {
if (!selectedRoute) return null;
const template: any[] = await routeCoachTemplatesApi.get(selectedRoute);
if (!template?.length) return [];
const fullCoaches = await Promise.all(
template.map((entry: any) => fleetApi.getCoach(entry.coachId ?? entry.coach?.id))
);
return fullCoaches.map((coach: any, i: number) => ({
if (template?.length) {
const fullCoaches = await Promise.all(
template.map((entry: any) => fleetApi.getCoach(entry.coachId ?? entry.coach?.id))
);
return fullCoaches.map((coach: any, i: number) => ({
...coach,
coachNumber: coach.number,
positionNumber: template[i].positionNumber,
seatArrangement: coach.arrangement,
}));
}
// No template — fetch coaches from the most recent schedule for this route
const schedules: any = await schedulesApi.getAll({ routeId: selectedRoute });
const scheduleList: any[] = schedules?.items || schedules?.data || (Array.isArray(schedules) ? schedules : []);
if (!scheduleList.length) return [];
const latestSchedule = scheduleList[scheduleList.length - 1];
const seatMap: any = await seatsApi.getSeatMap(latestSchedule.id);
return (seatMap?.coaches || []).map((coach: any, i: number) => ({
...coach,
coachNumber: coach.number,
positionNumber: template[i].positionNumber,
seatArrangement: coach.arrangement,
coachNumber: coach.number ?? coach.coachNumber,
positionNumber: coach.positionNumber ?? i + 1,
seatArrangement: coach.arrangement ?? coach.seatArrangement,
}));
},
enabled: !!selectedRoute,

View File

@@ -237,16 +237,6 @@ export default function TariffRatesPage() {
</ActionButton>
</div>
{/* Tariff reference card */}
<div className="card bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800">
<h3 className="font-semibold text-blue-900 dark:text-blue-200 mb-2">Official Tariff Formula</h3>
<p className="text-sm text-blue-800 dark:text-blue-300 font-mono">
Fare = KM × rate × 1.02 × ExchangeRate
</p>
<p className="text-xs text-blue-700 dark:text-blue-400 mt-1">
Rate is stored as <strong>baseFareMinor = tariff_decimal × 100,000</strong> (e.g. 0.03 3000). The ×1.02 insurance coefficient is applied automatically by the fare engine.
</p>
</div>
<div className="card">
<div className="relative mb-4">

View File

@@ -884,11 +884,11 @@ export default function TicketsPage() {
})()}
</Modal>
{/* Excess Baggage Modal */}
{/* Excess Luggage Modal */}
<Modal
isOpen={excessModalOpen}
onClose={() => { setExcessModalOpen(false); setExcessTicket(null); setExcessResult(null); }}
title="Log Excess Baggage"
title="Log Excess Luggage"
size="sm"
>
{excessResult ? (

View File

@@ -444,7 +444,7 @@ export const packageInquiriesApi = {
remove: (id: string) => apiClient.delete(`/packages/inquiries/${id}`),
};
// Excess Baggage API
// Excess Luggage API
export const excessBaggageApi = {
logCharge: (data: any) => apiClient.post<any>('/agents/excess-baggage', data),
getCharge: (id: string) => apiClient.get<any>(`/agents/excess-baggage/${id}`),

View File

@@ -26,6 +26,20 @@ import {
import { useEffect, useRef, useState, useCallback } from "react";
import ModernDatePicker from "@/components/ModernDatePicker";
function useDarkMode() {
const [dark, setDark] = useState(() =>
typeof window !== 'undefined' && document.documentElement.classList.contains('dark')
);
useEffect(() => {
const obs = new MutationObserver(() =>
setDark(document.documentElement.classList.contains('dark'))
);
obs.observe(document.documentElement, { attributeFilter: ['class'] });
return () => obs.disconnect();
}, []);
return dark;
}
const searchSchema = z
.object({
tripType: z.enum(["ONE_WAY", "ROUND_TRIP"]),
@@ -535,6 +549,7 @@ export default function SearchPage() {
const queryClient = useQueryClient();
const { user, isAuthenticated } = useAuthStore();
const dark = useDarkMode();
const [passengerModalOpen, setPassengerModalOpen] = useState(false);
const [promoVisible, setPromoVisible] = useState(false);
const [promoCode, setPromoCode] = useState("");
@@ -922,12 +937,14 @@ export default function SearchPage() {
? "border-red-400"
: originId
? "border-primary bg-primary/5"
: "border-gray-200"
: "border-gray-200 dark:border-gray-700"
}`}
style={{ backgroundColor: originId ? undefined : undefined }}
>
<MapPin className="w-4 h-4 text-primary flex-shrink-0" />
<span
className={`text-sm ${originStation ? "font-semibold text-gray-900" : "text-gray-400"}`}
style={{ color: originStation ? (dark ? '#ffffff' : '#111827') : (dark ? '#6b7280' : '#9ca3af') }}
className={`text-sm ${originStation ? 'font-semibold' : ''}`}
>
{originStation?.name ?? "Select departure"}
</span>
@@ -969,12 +986,13 @@ export default function SearchPage() {
? "border-red-400"
: destId
? "border-primary bg-primary/5"
: "border-gray-200"
: "border-gray-200 dark:border-gray-700"
}`}
>
<MapPin className="w-4 h-4 text-primary flex-shrink-0" />
<span
className={`text-sm ${destStation ? "font-semibold text-gray-900" : "text-gray-400"}`}
style={{ color: destStation ? (dark ? '#ffffff' : '#111827') : (dark ? '#6b7280' : '#9ca3af') }}
className={`text-sm ${destStation ? 'font-semibold' : ''}`}
>
{destStation?.name ?? "Select destination"}
</span>
@@ -1042,11 +1060,11 @@ export default function SearchPage() {
<button
type="button"
onClick={() => setPassengerModalOpen(true)}
className={`w-full flex items-center justify-between px-3.5 py-3 border-2 rounded-xl bg-white ${
showNationalityError ? "border-red-400" : "border-gray-200"
className={`w-full flex items-center justify-between px-3.5 py-3 border-2 rounded-xl bg-white dark:bg-gray-800 ${
showNationalityError ? "border-red-400" : "border-gray-200 dark:border-gray-700"
}`}
>
<span className="flex items-center gap-2 text-sm font-medium text-gray-900">
<span className="flex items-center gap-2 text-sm font-medium" style={{ color: dark ? '#ffffff' : '#111827' }}>
<Users className="w-4 h-4 text-primary" />
{totalPassengers} Pax
{nationalityFlag(watch("nationality"))

View File

@@ -1,9 +1,18 @@
'use client';
import { useEffect, useState } from 'react';
import { getTranslation, Language, useLanguage } from '@/lib/i18n';
import { useState } from 'react';
import Link from 'next/link';
import { ChevronDown, Search, MessageCircle } from 'lucide-react';
import {
ChevronDown,
Search,
MessageCircle,
Ticket,
CreditCard,
Users,
MapPin,
Wallet,
ShieldCheck,
} from 'lucide-react';
interface FAQItem {
question: string;
@@ -11,414 +20,389 @@ interface FAQItem {
}
interface FAQCategory {
icon: React.ReactNode;
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;
}
const FAQ_CATEGORIES: FAQCategory[] = [
{
icon: <Ticket className="w-5 h-5" />,
title: 'Booking',
items: [
{
question: 'How do I book a ticket?',
answer:
'Go to the search page, enter your departure and destination stations, select a date, choose the number of passengers and your nationality, then tap Search. Pick a trip from the results, select your seats, fill in passenger details, and complete payment.',
},
{
question: 'Can I book without creating an account?',
answer:
'Yes. Guest booking is fully supported — no account required. You can optionally create an account at the end of the booking flow to save your details for future trips.',
},
{
question: 'What is the booking reference format?',
answer:
'Your booking reference is a 6-character alphanumeric code (e.g. A3F9KZ). It is shown on your confirmation screen and sent to your email or phone. Use it to look up your booking at any time via the Booking Lookup page.',
},
{
question: 'Can I book a round trip?',
answer:
'Yes. Select "Round Trip" on the search form, choose both a departure and return date, and complete both legs in a single flow.',
},
{
question: 'How far in advance can I book?',
answer:
'Tickets are available for booking up to the hold cutoff time before departure (typically 2 hours). Schedules are published in advance so you can plan ahead.',
},
{
question: 'Can I save passenger details for future bookings?',
answer:
'Yes. When booking as a logged-in user you can save passenger profiles. On subsequent bookings you can select a saved passenger instead of re-entering their details.',
},
{
question: 'How do I modify my booking?',
answer:
'Log in and go to your profile, find the booking, and select Modify. Changes are allowed up to 24 hours before departure. Fare differences may apply.',
},
{
question: 'What is the cancellation policy?',
answer:
'Cancellations made at least 48 hours before departure receive a full refund. Cancellations within 48 hours may be subject to a fee. Refunds are returned to your original payment method or wallet.',
},
],
},
{
icon: <Users className="w-5 h-5" />,
title: 'Passengers & Age-Based Pricing',
items: [
{
question: 'How is the fare calculated?',
answer:
'The fare is calculated as: Base Fare × Distance + Insurance. The base fare depends on the seat class (Economy Regular, Economy Bed, or VIP Bed). Age-based rules then apply on top of this.',
},
{
question: 'What are the passenger age categories?',
answer:
'Adults are passengers aged 5 years and above and pay 100% of the fare. Children are passengers under 5 years old — the first child in a booking travels free, and any additional children pay the full fare.',
},
{
question: 'How is a child\'s age determined?',
answer:
'Age is calculated automatically from the date of birth you enter for each passenger. Make sure to enter the correct date of birth so the right fare is applied.',
},
{
question: 'Example: how much does a family of 2 adults + 3 children pay?',
answer:
'The first child is free, so you pay for 2 adults + 2 children = 4× the base fare for that seat class and distance.',
},
{
question: 'What seat classes are available?',
answer:
'Three classes are available: Economy Regular (standard seating), Economy Bed (sleeping berth in economy), and VIP Bed (premium sleeping berth). Each has its own base fare.',
},
{
question: 'What is the nationality field for?',
answer:
'Nationality determines which ID verification path applies. Ethiopian nationals are verified via the Verifayda national ID system. Djiboutian and other international passengers use their passport instead.',
},
],
},
{
icon: <ShieldCheck className="w-5 h-5" />,
title: 'Identity Verification (Verifayda)',
items: [
{
question: 'What is Verifayda?',
answer:
'Verifayda is the Ethiopian government\'s national ID verification system. When you book as an Ethiopian national, your national ID is verified in real time to confirm your identity and retrieve your name and date of birth.',
},
{
question: 'Is my national ID stored?',
answer:
'No. National ID numbers are not stored in our system. Verification happens in real time and only the confirmed passenger data (name, date of birth, nationality) is used for the booking.',
},
{
question: 'What if I am not Ethiopian?',
answer:
'Non-Ethiopian passengers use their passport number instead. No Verifayda verification is required. Select your nationality on the search form and enter your passport details during the passenger information step.',
},
{
question: 'What happens if verification fails?',
answer:
'If Verifayda verification is unsuccessful the booking cannot be completed for that passenger. Check that the national ID number is correct and try again. If the problem persists, contact support.',
},
],
},
{
icon: <MapPin className="w-5 h-5" />,
title: 'Stations & Routes',
items: [
{
question: 'How many stations does EDR serve?',
answer:
'EDR serves 21 stations along the Ethio-Djibouti Railway corridor, spanning from Addis Ababa in Ethiopia to Djibouti City in Djibouti.',
},
{
question: 'Can I book a partial journey (not the full route)?',
answer:
'Yes. You can book between any two stations on the route. Fares are calculated based on the distance between your chosen origin and destination.',
},
{
question: 'How do I find my nearest station?',
answer:
'On the search page, tap the From or To field and browse or search the full list of stations. Each station shows its code and country.',
},
{
question: 'Are prices shown in my local currency?',
answer:
'All transactions are processed in Ethiopian Birr (ETB). You can view prices in ETB, Djiboutian Franc (DJF), or US Dollar (USD) by selecting your preferred display currency on the fare or booking screen.',
},
],
},
{
icon: <CreditCard className="w-5 h-5" />,
title: 'Payment',
items: [
{
question: 'What payment methods are accepted?',
answer:
'We accept Telebirr, CBE Birr, eBirr, credit/debit cards, and EDR Wallet balance. You can choose your preferred method at checkout.',
},
{
question: 'What is the EDR Wallet?',
answer:
'The EDR Wallet is a stored-value account linked to your profile. You can top it up and use it to pay for tickets instantly. Your wallet balance and transaction history are available in your profile.',
},
{
question: 'When will I receive my refund?',
answer:
'Refunds are processed within 57 business days to your original payment method. If you paid via EDR Wallet, the refund is credited to your wallet immediately.',
},
{
question: 'Is my payment information secure?',
answer:
'Yes. We do not store card details. All payments are processed through certified payment providers. Transactions are encrypted end-to-end.',
},
],
},
{
icon: <Ticket className="w-5 h-5" />,
title: 'Tickets & Boarding',
items: [
{
question: 'How do I get my ticket?',
answer:
'After payment is confirmed, your ticket is generated automatically. You can view and download it as a PDF from your booking detail page. A QR code and barcode are included for gate validation.',
},
{
question: 'Do I need to print my ticket?',
answer:
'No. You can show your digital ticket (QR code) on your phone at the gate. Printed tickets are also accepted.',
},
{
question: 'What do I need to board?',
answer:
'Present your ticket QR code and a valid ID (national ID or passport matching the passenger details on the booking) at the gate.',
},
{
question: 'Can I look up a booking without an account?',
answer:
'Yes. Go to the Booking Lookup page and enter your booking reference and the phone number or email used when booking.',
},
],
},
{
icon: <Wallet className="w-5 h-5" />,
title: 'Account & Profile',
items: [
{
question: 'How do I create an account?',
answer:
'Tap Register on the login page and enter your email, phone number, full name, and a password. You will receive an OTP to verify your account.',
},
{
question: 'I forgot my password. What do I do?',
answer:
'Tap "Forgot password" on the login page, enter your registered email, and follow the reset link sent to your inbox.',
},
{
question: 'How do I set up Verifayda on my account?',
answer:
'Go to your profile and find the Fayda Setup section. Enter your national ID to link your verified identity to your account. This enables faster booking as your details are pre-filled.',
},
{
question: 'Can I use the app in multiple languages?',
answer:
'Yes. The app supports English, Amharic (አማርኛ), Afaan Oromoo, and French. Change your language from the navigation bar.',
},
],
},
];
.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[]>([]);
export default function HelpPage() {
const [openKeys, setOpenKeys] = useState<Set<string>>(new Set());
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]);
}
const toggle = (key: string) => {
setOpenKeys((prev) => {
const next = new Set(prev);
next.has(key) ? next.delete(key) : next.add(key);
return next;
});
};
let flatFAQs: (FAQItem & { id: number })[] = [];
faqCategories.forEach((cat) => {
cat.items.forEach((item) => {
flatFAQs.push({ ...item, id: flatFAQs.length });
const query = searchTerm.trim().toLowerCase();
const searchResults: { catTitle: string; item: FAQItem; key: string }[] = [];
if (query) {
FAQ_CATEGORIES.forEach((cat) => {
cat.items.forEach((item, i) => {
if (
item.question.toLowerCase().includes(query) ||
item.answer.toLowerCase().includes(query)
) {
searchResults.push({ catTitle: cat.title, item, key: `search-${cat.title}-${i}` });
}
});
});
});
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>
<main className="min-h-screen bg-gray-50 dark:bg-gray-950">
{/* Hero */}
<section className="bg-gradient-to-br from-primary/90 to-primary/60 py-14 px-4 text-center">
<h1 className="text-3xl md:text-4xl font-bold text-white mb-3">Help & FAQs</h1>
<p className="text-white/80 text-base max-w-xl mx-auto">
Find answers about booking, pricing, payments, and more.
</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>
{/* Search */}
<section className="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 px-4 py-5">
<div className="max-w-2xl mx-auto relative">
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="Search FAQs…"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-3 border-2 border-gray-200 dark:border-gray-700 rounded-xl bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:border-primary text-sm"
/>
</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);
{/* Content */}
<section className="px-4 py-10 max-w-3xl mx-auto">
{query ? (
searchResults.length > 0 ? (
<div className="space-y-3">
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
{searchResults.length} result{searchResults.length !== 1 ? 's' : ''} for &ldquo;{searchTerm}&rdquo;
</p>
{searchResults.map(({ catTitle, item, key }) => (
<FAQRow
key={key}
question={item.question}
answer={item.answer}
badge={catTitle}
isOpen={openKeys.has(key)}
onToggle={() => toggle(key)}
/>
))}
</div>
) : (
<div className="text-center py-16 text-gray-400 dark:text-gray-500">
<Search className="w-10 h-10 mx-auto mb-3 opacity-40" />
<p className="text-sm">No results for &ldquo;{searchTerm}&rdquo;</p>
</div>
)
) : (
<div className="space-y-10">
{FAQ_CATEGORIES.map((cat) => (
<div key={cat.title}>
<div className="flex items-center gap-2 mb-4">
<span className="text-primary">{cat.icon}</span>
<h2 className="text-lg font-bold text-gray-900 dark:text-white">{cat.title}</h2>
</div>
<div className="space-y-2">
{cat.items.map((item, i) => {
const key = `${cat.title}-${i}`;
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>
<FAQRow
key={key}
question={item.question}
answer={item.answer}
isOpen={openKeys.has(key)}
onToggle={() => toggle(key)}
/>
);
})}
</div>
))
)}
</div>
))}
</div>
</section>
)}
</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>
{/* Contact CTA */}
<section className="px-4 pb-16">
<div className="max-w-xl mx-auto bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-2xl p-8 text-center shadow-sm">
<div className="w-14 h-14 bg-primary rounded-xl flex items-center justify-center mx-auto mb-4">
<MessageCircle className="w-7 h-7 text-white" />
</div>
</section>
</main>
</>
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Still need help?</h2>
<p className="text-gray-500 dark:text-gray-400 text-sm mb-6">
Our support team is available to assist you.
</p>
<Link
href="/contact"
className="inline-block px-8 py-3 bg-primary hover:bg-primary/90 text-white font-bold rounded-xl transition-colors text-sm"
>
Contact Support
</Link>
</div>
</section>
</main>
);
}
function FAQRow({
question,
answer,
badge,
isOpen,
onToggle,
}: {
question: string;
answer: string;
badge?: string;
isOpen: boolean;
onToggle: () => void;
}) {
return (
<div className="border-2 border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden hover:border-primary/50 transition-colors">
<button
type="button"
onClick={onToggle}
className="w-full flex items-start justify-between gap-3 px-4 py-4 bg-white dark:bg-gray-800 text-left"
>
<div className="flex-1 min-w-0">
{badge && (
<span className="inline-block text-xs font-semibold text-primary bg-primary/10 rounded-full px-2 py-0.5 mb-1.5">
{badge}
</span>
)}
<p className="text-sm font-semibold text-gray-900 dark:text-white">{question}</p>
</div>
<ChevronDown
className={`w-4 h-4 text-primary flex-shrink-0 mt-0.5 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
/>
</button>
{isOpen && (
<div className="px-4 py-4 bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700 text-sm text-gray-600 dark:text-gray-300 leading-relaxed">
{answer}
</div>
)}
</div>
);
}

View File

@@ -1,7 +1,6 @@
'use client';
import { Mail, Phone, MapPin } from 'lucide-react';
import Link from 'next/link';
import { useLanguage, getTranslation, Language } from '@/lib/i18n';
import { useEffect, useState } from 'react';
@@ -21,83 +20,27 @@ export function Footer() {
<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">
<div className="grid grid-cols-1 md:grid-cols-2 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>
<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>
<Phone className="w-4 h-4 flex-shrink-0" />
<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>
<Mail className="w-4 h-4 flex-shrink-0" />
<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" />
<MapPin className="w-4 h-4 flex-shrink-0" />
<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>
@@ -118,21 +61,10 @@ export function Footer() {
</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 className="border-t border-white border-opacity-20 dark:border-gray-700 pt-6">
<p className="text-sm text-gray-100 text-center md:text-left">
&copy; {new Date().getFullYear()} Ethio-Djibouti Railway. {t('footer.rights')}
</p>
</div>
</div>
</div>

View File

@@ -277,15 +277,15 @@ function FeaturedCard({ pkg }: { pkg: HolidayPackage }) {
</div>
{/* Bottom: price + CTA */}
<div className="flex items-end justify-between pt-5 border-t border-gray-100 dark:border-gray-800">
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-3 pt-5 border-t border-gray-100 dark:border-gray-800">
{price ? (
<div>
<p className="text-[10px] text-gray-400 uppercase tracking-wider font-medium">
Starting from
</p>
<p className="text-3xl font-extrabold text-primary leading-none mt-1">
<p className="text-2xl font-extrabold text-primary leading-none mt-1">
{price.currency}{" "}
<span className="text-2xl">
<span className="text-xl">
{price.amount.toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
@@ -300,7 +300,7 @@ function FeaturedCard({ pkg }: { pkg: HolidayPackage }) {
) : (
<div />
)}
<span className="inline-flex items-center gap-2 bg-[rgb(20,113,76)] group-hover:bg-[rgb(16,89,60)] text-white text-sm font-bold px-6 py-3.5 rounded-xl shadow-lg group-hover:shadow-xl transition-all duration-200 group-hover:gap-3">
<span className="inline-flex items-center justify-center gap-2 bg-[rgb(20,113,76)] group-hover:bg-[rgb(16,89,60)] text-white text-sm font-bold px-5 py-3 rounded-xl shadow-lg group-hover:shadow-xl transition-all duration-200 group-hover:gap-3 w-full sm:w-auto">
View Package
<ArrowRight className="w-4 h-4" />
</span>
@@ -384,20 +384,20 @@ function PackageCard({ pkg }: { pkg: HolidayPackage }) {
</div>
{/* Price + CTA */}
<div className="mt-auto pt-3.5 border-t border-gray-100 dark:border-gray-800 flex items-center justify-between">
<div className="mt-auto pt-3.5 border-t border-gray-100 dark:border-gray-800 flex items-center justify-between gap-2">
{price ? (
<div>
<div className="min-w-0">
<p className="text-[10px] text-gray-400 uppercase tracking-wide">
From
</p>
<p className="text-base font-extrabold text-primary">
<p className="text-sm font-extrabold text-primary truncate">
{fmtPrice(price.amount * 100, price.currency)}
</p>
</div>
) : (
<div />
)}
<span className="text-xs text-primary font-bold flex items-center gap-1 group-hover:gap-2 transition-all">
<span className="flex-shrink-0 text-xs text-white bg-primary font-bold flex items-center gap-1 group-hover:gap-2 transition-all px-3 py-2 rounded-lg">
View <ArrowRight className="w-3.5 h-3.5" />
</span>
</div>

View File

@@ -8,8 +8,22 @@ 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 { MapPin, Users, Search, Plus, Minus, ChevronDown, Globe } from 'lucide-react';
import { useState, useRef, useEffect } from 'react';
function useDarkMode() {
const [dark, setDark] = useState(() =>
typeof window !== 'undefined' && document.documentElement.classList.contains('dark')
);
useEffect(() => {
const obs = new MutationObserver(() =>
setDark(document.documentElement.classList.contains('dark'))
);
obs.observe(document.documentElement, { attributeFilter: ['class'] });
return () => obs.disconnect();
}, []);
return dark;
}
import ModernDatePicker from '@/components/ModernDatePicker';
const searchSchema = z.object({
@@ -36,18 +50,104 @@ interface SearchWidgetProps {
onSearch?: () => void;
}
const NATIONALITIES = [
{ value: 'ETHIOPIAN', label: 'Ethiopian' },
{ value: 'DJIBOUTIAN', label: 'Djiboutian' },
{ value: 'OTHER', label: 'Other' },
] as const;
// Reusable custom dropdown
function CustomSelect({
value,
onChange,
options,
placeholder,
icon,
error,
disabled,
}: {
value: string;
onChange: (val: string) => void;
options: { value: string; label: string; disabled?: boolean }[];
placeholder: string;
icon?: React.ReactNode;
error?: boolean;
disabled?: boolean;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const selected = options.find((o) => o.value === value);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
const dark = useDarkMode();
return (
<div ref={ref} className="relative">
<button
type="button"
disabled={disabled}
onClick={() => !disabled && setOpen((o) => !o)}
className={`w-full ${icon ? 'pl-11' : 'pl-4'} pr-10 py-3.5 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-base text-left flex items-center gap-2
bg-white dark:bg-gray-800
disabled:opacity-60 disabled:cursor-not-allowed
${error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'}
transition-colors`}
style={{ color: dark ? '#ffffff' : '#111827' }}
>
{icon && <span className="absolute left-3 top-1/2 -translate-y-1/2">{icon}</span>}
<span style={{ color: selected ? (dark ? '#ffffff' : '#111827') : (dark ? '#6b7280' : '#9ca3af') }}>
{selected ? selected.label : placeholder}
</span>
<ChevronDown className={`absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 dark:text-gray-400 transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{open && (
<>
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
<div className="absolute top-full left-0 right-0 mt-1 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg shadow-xl z-50 max-h-60 overflow-y-auto">
{options.map((opt) => (
<button
key={opt.value}
type="button"
disabled={opt.disabled}
onClick={() => { onChange(opt.value); setOpen(false); }}
className={`w-full text-left px-4 py-3 text-sm transition-colors
${opt.disabled ? 'opacity-40 cursor-not-allowed' : 'hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer'}
${opt.value === value
? 'text-primary font-semibold bg-primary/5 dark:bg-primary/10'
: 'text-gray-900 dark:text-white'
}`}
>
{opt.label}
</button>
))}
</div>
</>
)}
</div>
);
}
export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps) {
const router = useRouter();
const setSearchCriteria = useBookingStore((s) => s.setSearchCriteria);
const [isPassengerOpen, setIsPassengerOpen] = useState(false);
const dark = useDarkMode();
const { data: stations, isLoading } = useQuery<Station[]>({
queryKey: ['stations'],
queryFn: async () => await apiClient.get('/stations') as Station[],
});
const { register, handleSubmit, watch, setValue, clearErrors, formState: { errors } } = useForm<SearchForm>({
// @ts-ignore - ZodEffects type compatibility issue
const { handleSubmit, watch, setValue, clearErrors, formState: { errors } } = useForm<SearchForm>({
// @ts-ignore
resolver: zodResolver(searchSchema),
mode: 'onSubmit',
reValidateMode: 'onChange',
@@ -63,16 +163,16 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
});
const originId = watch('originStationId');
const destinationId = watch('destinationStationId');
const nationality = watch('nationality');
const adultCount = watch('adultCount');
const childCount = watch('childCount');
const stationOptions = (stations ?? []).map((s) => ({ value: s.id, label: s.name }));
const destinationOptions = stationOptions.map((s) => ({ ...s, disabled: s.value === originId }));
const onSubmit = (data: SearchForm) => {
setSearchCriteria({
...data,
adultCount: data.adultCount,
childCount: data.childCount,
nationality: data.nationality,
});
setSearchCriteria({ ...data });
const params = new URLSearchParams({
origin: data.originStationId,
destination: data.destinationStationId,
@@ -89,172 +189,114 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
<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', {
onChange: (e) => {
if (e.target.value) clearErrors('originStationId');
}
})}
className={`w-full pl-11 pr-4 py-3.5 border 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 ${
errors.originStationId ? 'border-red-500 dark:border-red-500' : 'border-gray-300 dark:border-gray-600'
}`}
disabled={isLoading}
>
<option value="">Select departure</option>
{stations?.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">From</label>
<CustomSelect
value={originId}
onChange={(val) => { setValue('originStationId', val); clearErrors('originStationId'); }}
options={stationOptions}
placeholder="Select departure"
icon={<MapPin className="w-5 h-5 text-primary" />}
error={!!errors.originStationId}
disabled={isLoading}
/>
{errors.originStationId && (
<p className="text-red-600 dark:text-red-400 text-sm mt-1">{errors.originStationId.message}</p>
<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', {
onChange: (e) => {
if (e.target.value) clearErrors('destinationStationId');
}
})}
className={`w-full pl-11 pr-4 py-3.5 border 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 ${
errors.destinationStationId ? 'border-red-500 dark:border-red-500' : 'border-gray-300 dark:border-gray-600'
}`}
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>
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">To</label>
<CustomSelect
value={destinationId}
onChange={(val) => { setValue('destinationStationId', val); clearErrors('destinationStationId'); }}
options={destinationOptions}
placeholder="Select arrival"
icon={<MapPin className="w-5 h-5 text-primary" />}
error={!!errors.destinationStationId}
disabled={isLoading}
/>
{errors.destinationStationId && (
<p className="text-red-600 dark:text-red-400 text-sm mt-1">{errors.destinationStationId.message}</p>
<p className="text-red-600 dark:text-red-400 text-sm">{errors.destinationStationId.message}</p>
)}
</div>
{/* Date */}
<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">Date</label>
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">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}`);
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
setValue('departureDate', `${y}-${m}-${d}`);
clearErrors('departureDate');
}}
minDate={new Date()}
placeholder="Select date"
/>
{errors.departureDate && (
<p className="text-red-600 dark:text-red-400 text-sm mt-1">{errors.departureDate.message}</p>
<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 */}
{/* Passengers */}
<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>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">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"
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 text-base bg-white dark:bg-gray-800 flex items-center justify-between hover:border-primary transition-colors"
style={{ color: dark ? '#ffffff' : '#111827' }}
>
<span className="flex items-center gap-2">
<span className="flex items-center gap-2" style={{ color: 'inherit' }}>
<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' : ''}`} />
<ChevronDown className={`w-4 h-4 text-gray-400 transition-transform ${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 className="absolute top-full left-0 right-0 mt-1 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg shadow-xl z-50 p-4 space-y-4">
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium text-gray-900 dark:text-white">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 c = adultCount || 1; if (c > 1) setValue('adultCount', c - 1); }} disabled={(adultCount || 1) <= 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-40">
<Minus className="w-3.5 h-3.5 text-primary" />
</button>
<span className="w-5 text-center font-semibold text-gray-900 dark:text-white">{adultCount || 1}</span>
<button type="button" onClick={() => { const c = adultCount || 1; if (c < 9) setValue('adultCount', c + 1); }} disabled={(adultCount || 1) >= 9} 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-40">
<Plus className="w-3.5 h-3.5 text-primary" />
</button>
</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 className="border-t border-gray-100 dark:border-gray-700 pt-4 flex items-center justify-between">
<div>
<div className="text-sm font-medium text-gray-900 dark:text-white">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-3">
<button type="button" onClick={() => { const c = childCount || 0; if (c > 0) setValue('childCount', c - 1); }} disabled={(childCount || 0) <= 0} 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-40">
<Minus className="w-3.5 h-3.5 text-primary" />
</button>
<span className="w-5 text-center font-semibold text-gray-900 dark:text-white">{childCount || 0}</span>
<button type="button" onClick={() => { const c = childCount || 0; if (c < 9) setValue('childCount', c + 1); }} disabled={(childCount || 0) >= 9} 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-40">
<Plus className="w-3.5 h-3.5 text-primary" />
</button>
</div>
</div>
</div>
@@ -264,49 +306,35 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
{/* 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>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Nationality</label>
<CustomSelect
value={nationality}
onChange={(val) => setValue('nationality', val as SearchForm['nationality'])}
options={NATIONALITIES.map((n) => ({ value: n.value, label: n.label }))}
placeholder="Select nationality"
icon={<Globe className="w-5 h-5 text-primary" />}
/>
</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>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">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"
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 text-base bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500"
/>
</div>
</div>
{/* Row 3: Search Button */}
<div>
{/* Debug info - remove after testing */}
{Object.keys(errors).length > 0 && (
<div className="mb-3 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-sm font-medium text-red-800 dark:text-red-200 mb-1">Validation Errors:</p>
<ul className="text-xs text-red-700 dark:text-red-300 list-disc list-inside">
{Object.entries(errors).map(([key, value]) => (
<li key={key}>{key}: {value?.message}</li>
))}
</ul>
</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>
{/* Search Button */}
<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" />
<span>Search Train</span>
</button>
</div>
</form>
</div>