mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Production cleanups
This commit is contained in:
@@ -369,7 +369,7 @@ pnpm --filter @edr/passenger-api run prisma:seed
|
|||||||
- 3 User accounts (Admin, Passenger, Agent)
|
- 3 User accounts (Admin, Passenger, Agent)
|
||||||
- Fare rules for ADULT and CHILD passenger categories
|
- Fare rules for ADULT and CHILD passenger categories
|
||||||
- Currency exchange rates (ETB, DJF, USD)
|
- Currency exchange rates (ETB, DJF, USD)
|
||||||
- Baggage allowance rules
|
- Luggage allowance rules
|
||||||
- Notification templates
|
- Notification templates
|
||||||
- Promotions and FAQ content
|
- Promotions and FAQ content
|
||||||
- Menu items and station crowd signals
|
- Menu items and station crowd signals
|
||||||
|
|||||||
@@ -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.
|
Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and management platform. Built with NestJS, TypeScript, PostgreSQL, and Prisma ORM.
|
||||||
|
|
||||||
## Latest Updates
|
## 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.
|
- **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.
|
- **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.
|
- **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",
|
"JWT-auth",
|
||||||
)
|
)
|
||||||
.addTag("Agents", "Counter booking, shift management, commission tracking, and reconciliation")
|
.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("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("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")
|
.addTag("Audit", "User activity logging, system changes, compliance tracking, and audit trails")
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ function generateRef(): string {
|
|||||||
return 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('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* For package round-trip bookings, totalMinor in the DB may have been stored as a
|
* 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.
|
* single-leg amount before the server fix. Recompute from the tier price when needed.
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const BOOKING_CUTOFF_MS = 30 * 60 * 1000;
|
|||||||
|
|
||||||
function generateRef(): string {
|
function generateRef(): string {
|
||||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
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)
|
// Ethiopian mobile prefixes: Ethio Telecom (09xx) and Safaricom ET (07xx)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class UpsertBaggageAllowanceDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── IAM-protected agent/supervisor routes ────────────────────────────────────
|
// ── IAM-protected agent/supervisor routes ────────────────────────────────────
|
||||||
@ApiTags('Excess Baggage')
|
@ApiTags('Excess Luggage')
|
||||||
@Controller('agents/excess-baggage')
|
@Controller('agents/excess-baggage')
|
||||||
@UseGuards(IamJwtGuard)
|
@UseGuards(IamJwtGuard)
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
@@ -100,7 +100,7 @@ export class ExcessBaggageAgentController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Public pay-by-token routes (passenger self-service) ──────────────────────
|
// ── Public pay-by-token routes (passenger self-service) ──────────────────────
|
||||||
@ApiTags('Excess Baggage')
|
@ApiTags('Excess Luggage')
|
||||||
@Controller('excess-baggage')
|
@Controller('excess-baggage')
|
||||||
export class ExcessBaggagePublicController {
|
export class ExcessBaggagePublicController {
|
||||||
constructor(private service: ExcessBaggageService) {}
|
constructor(private service: ExcessBaggageService) {}
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import { SearchService } from './search.service';
|
|||||||
import { CurrencyModule } from '../currency/currency.module';
|
import { CurrencyModule } from '../currency/currency.module';
|
||||||
import { FareEngineModule } from '../fare-engine/fare-engine.module';
|
import { FareEngineModule } from '../fare-engine/fare-engine.module';
|
||||||
import { SegmentsModule } from '../segments/segments.module';
|
import { SegmentsModule } from '../segments/segments.module';
|
||||||
|
import { SystemConfigModule } from '../system-config/system-config.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [CurrencyModule, FareEngineModule, SegmentsModule],
|
imports: [CurrencyModule, FareEngineModule, SegmentsModule, SystemConfigModule],
|
||||||
controllers: [SearchController],
|
controllers: [SearchController],
|
||||||
providers: [SearchService],
|
providers: [SearchService],
|
||||||
exports: [SearchService],
|
exports: [SearchService],
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { FareEngineService } from '../fare-engine/fare-engine.service';
|
|||||||
import { SegmentsService } from '../segments/segments.service';
|
import { SegmentsService } from '../segments/segments.service';
|
||||||
import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto';
|
import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto';
|
||||||
import { Currency } from '@prisma/client';
|
import { Currency } from '@prisma/client';
|
||||||
|
import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
|
||||||
|
|
||||||
const POINTS_TO_MINOR = 10;
|
const POINTS_TO_MINOR = 10;
|
||||||
|
|
||||||
@@ -40,8 +41,13 @@ export class SearchService {
|
|||||||
private currencyService: CurrencyService,
|
private currencyService: CurrencyService,
|
||||||
private fareEngine: FareEngineService,
|
private fareEngine: FareEngineService,
|
||||||
private segmentsService: SegmentsService,
|
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) {
|
async searchTrips(dto: SearchTripsDto) {
|
||||||
const [direct, transit] = await Promise.all([
|
const [direct, transit] = await Promise.all([
|
||||||
this.searchSchedules(
|
this.searchSchedules(
|
||||||
@@ -166,13 +172,14 @@ export class SearchService {
|
|||||||
|
|
||||||
const schedules = await this.prisma.trainSchedule.findMany({
|
const schedules = await this.prisma.trainSchedule.findMany({
|
||||||
where: {
|
where: {
|
||||||
status: { in: ['SCHEDULED', 'BOARDING'] },
|
status: 'SCHEDULED',
|
||||||
isPackageOnly: false,
|
isPackageOnly: false,
|
||||||
OR: [
|
OR: [
|
||||||
{ departureAt: { gte: windowStart, lt: requestedDate } },
|
{ departureAt: { gte: windowStart, lt: requestedDate } },
|
||||||
{ departureAt: { gte: requestedNextDay < now ? now : requestedNextDay, lt: windowEnd } },
|
{ departureAt: { gte: requestedNextDay < now ? now : requestedNextDay, lt: windowEnd } },
|
||||||
],
|
],
|
||||||
stopTimes: { some: { stationId: originStationId } },
|
stopTimes: { some: { stationId: originStationId } },
|
||||||
|
coachAssignments: { some: {} },
|
||||||
},
|
},
|
||||||
include: SCHEDULE_INCLUDE,
|
include: SCHEDULE_INCLUDE,
|
||||||
orderBy: { departureAt: 'asc' },
|
orderBy: { departureAt: 'asc' },
|
||||||
@@ -183,7 +190,7 @@ export class SearchService {
|
|||||||
this.buildScheduleResult(schedule as any, originStationId, destinationStationId, totalPassengers, nationality)
|
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(
|
private async searchSchedules(
|
||||||
@@ -200,12 +207,17 @@ export class SearchService {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
const totalPassengers = adultCount + (childCount ?? 0);
|
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({
|
const schedules = await this.prisma.trainSchedule.findMany({
|
||||||
where: {
|
where: {
|
||||||
status: { in: ['SCHEDULED', 'BOARDING'] },
|
status: 'SCHEDULED',
|
||||||
isPackageOnly: false,
|
isPackageOnly: false,
|
||||||
departureAt: { gte: date < now ? now : date, lt: nextDay },
|
departureAt: { gte: earliest, lt: nextDay },
|
||||||
stopTimes: { some: { stationId: originStationId } },
|
stopTimes: { some: { stationId: originStationId } },
|
||||||
|
coachAssignments: { some: {} },
|
||||||
},
|
},
|
||||||
include: SCHEDULE_INCLUDE,
|
include: SCHEDULE_INCLUDE,
|
||||||
});
|
});
|
||||||
@@ -215,7 +227,7 @@ export class SearchService {
|
|||||||
this.buildScheduleResult(schedule as any, originStationId, destinationStationId, totalPassengers, nationality)
|
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 ─────────────────────────────────────────────────────────
|
// ── Transit search ─────────────────────────────────────────────────────────
|
||||||
@@ -241,26 +253,31 @@ export class SearchService {
|
|||||||
const [leg1Schedules, allCandidates] = await Promise.all([
|
const [leg1Schedules, allCandidates] = await Promise.all([
|
||||||
this.prisma.trainSchedule.findMany({
|
this.prisma.trainSchedule.findMany({
|
||||||
where: {
|
where: {
|
||||||
status: { in: ['SCHEDULED', 'BOARDING'] },
|
status: 'SCHEDULED',
|
||||||
isPackageOnly: false,
|
isPackageOnly: false,
|
||||||
departureAt: { gte: dayStart, lt: dayEnd },
|
departureAt: { gte: dayStart, lt: dayEnd },
|
||||||
stopTimes: { some: { stationId: originStationId } },
|
stopTimes: { some: { stationId: originStationId } },
|
||||||
|
coachAssignments: { some: {} },
|
||||||
},
|
},
|
||||||
include: SCHEDULE_INCLUDE,
|
include: SCHEDULE_INCLUDE,
|
||||||
}),
|
}),
|
||||||
this.prisma.trainSchedule.findMany({
|
this.prisma.trainSchedule.findMany({
|
||||||
where: {
|
where: {
|
||||||
status: { in: ['SCHEDULED', 'BOARDING'] },
|
status: 'SCHEDULED',
|
||||||
isPackageOnly: false,
|
isPackageOnly: false,
|
||||||
departureAt: { gte: dayStart, lt: leg2WindowEnd },
|
departureAt: { gte: dayStart, lt: leg2WindowEnd },
|
||||||
|
coachAssignments: { some: {} },
|
||||||
},
|
},
|
||||||
include: SCHEDULE_INCLUDE,
|
include: SCHEDULE_INCLUDE,
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const cutoffHours = await this.getCutoffHours();
|
||||||
|
const cutoffThreshold = new Date(Date.now() + cutoffHours * 60 * 60 * 1000);
|
||||||
|
|
||||||
const results: any[] = [];
|
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);
|
const originStop = leg1.stopTimes.find(s => s.stationId === originStationId);
|
||||||
if (!originStop) continue;
|
if (!originStop) continue;
|
||||||
|
|
||||||
@@ -354,6 +371,9 @@ export class SearchService {
|
|||||||
.map((s: any) => s.id as string)
|
.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
|
// Run availability batch and fare calculation in parallel
|
||||||
const [freeSeats, faresByClass] = await Promise.all([
|
const [freeSeats, faresByClass] = await Promise.all([
|
||||||
this.segmentsService.getFreeSeatIds(
|
this.segmentsService.getFreeSeatIds(
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ The Passenger Backoffice Application is a comprehensive management system for th
|
|||||||
- **Live Tracking**: Monitor trip status and real-time updates
|
- **Live Tracking**: Monitor trip status and real-time updates
|
||||||
- **Security Monitoring**: Fraud detection and audit logging
|
- **Security Monitoring**: Fraud detection and audit logging
|
||||||
- **Comprehensive Analytics**: Revenue, occupancy, and performance reports
|
- **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
|
- **🆕 Travel Packages**: Manage pilgrimage and group travel packages with tiered pricing
|
||||||
- **🆕 System Health Monitoring**: Real-time API health checks and system status
|
- **🆕 System Health Monitoring**: Real-time API health checks and system status
|
||||||
- **🆕 Advanced Fare Configuration**: Dynamic pricing with segment-based rules
|
- **🆕 Advanced Fare Configuration**: Dynamic pricing with segment-based rules
|
||||||
@@ -94,7 +94,7 @@ The application is organized into 8 main sections:
|
|||||||
├── System Config
|
├── System Config
|
||||||
└── Settings
|
└── Settings
|
||||||
└── Enhanced Features
|
└── Enhanced Features
|
||||||
├── Excess Baggage
|
├── Excess Luggage
|
||||||
├── Travel Packages
|
├── Travel Packages
|
||||||
├── Package Inquiries
|
├── Package Inquiries
|
||||||
├── Health Monitoring
|
├── Health Monitoring
|
||||||
@@ -2862,7 +2862,7 @@ Action: Block user
|
|||||||
|
|
||||||
### Version 1.0.0 (January 15, 2026)
|
### Version 1.0.0 (January 15, 2026)
|
||||||
- **Complete Platform Release** - Full-featured passenger management system
|
- **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
|
- **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 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
|
- **System Configuration** - Centralized config management with feature flags, rate limiting, and operational controls
|
||||||
@@ -2920,7 +2920,7 @@ Action: Block user
|
|||||||
|
|
||||||
## Enhanced Features
|
## Enhanced Features
|
||||||
|
|
||||||
### Excess Baggage
|
### Excess Luggage
|
||||||
|
|
||||||
**Purpose**: Manage excess baggage charges at boarding with agent tools and passenger self-pay
|
**Purpose**: Manage excess baggage charges at boarding with agent tools and passenger self-pay
|
||||||
**Access Level**: Agent, Supervisor, Admin
|
**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
|
1. **At Boarding**: Agent weighs passenger baggage
|
||||||
2. **If Excess**: Agent creates charge in system
|
2. **If Excess**: Agent creates charge in system
|
||||||
@@ -2962,8 +2962,8 @@ Action: Block user
|
|||||||
|
|
||||||
##### READ (List Charges)
|
##### READ (List Charges)
|
||||||
|
|
||||||
1. **Access Excess Baggage Page**:
|
1. **Access Excess Luggage Page**:
|
||||||
- Click **Excess Baggage** in Enhanced Features section
|
- Click **Excess Luggage** in Enhanced Features section
|
||||||
- Shows all baggage charges
|
- Shows all baggage charges
|
||||||
|
|
||||||
2. **Search & Filter**:
|
2. **Search & Filter**:
|
||||||
@@ -3000,7 +3000,7 @@ Action: Block user
|
|||||||
|
|
||||||
#### Agent Workflow
|
#### Agent Workflow
|
||||||
|
|
||||||
1. **Weigh Baggage**: Use station scales
|
1. **Weigh Luggage**: Use station scales
|
||||||
2. **Check Allowance**: Compare to passenger's seat class allowance
|
2. **Check Allowance**: Compare to passenger's seat class allowance
|
||||||
3. **Create Charge**: If excess weight found
|
3. **Create Charge**: If excess weight found
|
||||||
4. **Offer Payment Options**:
|
4. **Offer Payment Options**:
|
||||||
|
|||||||
@@ -311,22 +311,8 @@ export default function ClassesPage() {
|
|||||||
<p className="text-xs text-muted-foreground mt-1">Per-km distance-based fare rate</p>
|
<p className="text-xs text-muted-foreground mt-1">Per-km distance-based fare rate</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
<input type="hidden" name="premiumMinor" value="0" />
|
||||||
<div>
|
<div className="mt-4">
|
||||||
<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>
|
<label className="label">Insurance Fee (ETB)</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -339,13 +325,11 @@ export default function ClassesPage() {
|
|||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground mt-1">Flat fee per passenger (e.g., travel insurance)</p>
|
<p className="text-xs text-muted-foreground mt-1">Flat fee per passenger (e.g., travel insurance)</p>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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 className="font-medium mb-1">Total Fare Calculation:</p>
|
||||||
<p>Total = (Base Fare × Distance) + Premium + Insurance</p>
|
<p>Total = (Base Fare × Distance) + Insurance</p>
|
||||||
<p className="mt-2 text-xs">• Premium applies per passenger</p>
|
<p className="mt-2 text-xs">• Insurance applies per passenger</p>
|
||||||
<p className="text-xs">• Insurance applies per passenger</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -220,13 +220,6 @@ export default function CurrenciesPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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
|
<Modal
|
||||||
isOpen={showAddModal}
|
isOpen={showAddModal}
|
||||||
onClose={() => { setShowAddModal(false); setError(null); }}
|
onClose={() => { setShowAddModal(false); setError(null); }}
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export default function OperationsSection() {
|
|||||||
|
|
||||||
{/* EXCESS BAGGAGE */}
|
{/* EXCESS BAGGAGE */}
|
||||||
<div id="excess-baggage" className="border-t pt-8">
|
<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>
|
<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">
|
<div className="mt-3 flex flex-wrap gap-2 text-xs">
|
||||||
{['PENDING','PAID','CASH_COLLECTED','EXPIRED','WAIVED'].map(s => (
|
{['PENDING','PAID','CASH_COLLECTED','EXPIRED','WAIVED'].map(s => (
|
||||||
@@ -91,7 +91,7 @@ export default function OperationsSection() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="excess-baggage-how" className="border-t pt-8">
|
<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">
|
<div className="space-y-3">
|
||||||
<HowToStep number={1} title="Find Charges">
|
<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>
|
<ol className={ol}><li>Click <strong>Luggage</strong> in Operations</li><li>Search by booking reference; filter by status or date</li></ol>
|
||||||
|
|||||||
@@ -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'
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -765,24 +765,6 @@ export default function PricingPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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 (<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 */}
|
{/* Delete Confirmation */}
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
isOpen={deleteConfirm.isOpen}
|
isOpen={deleteConfirm.isOpen}
|
||||||
@@ -1113,7 +1095,7 @@ export default function PricingPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</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">
|
<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">
|
<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>}
|
{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>}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export default function SeatsPage() {
|
|||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!selectedRoute) return null;
|
if (!selectedRoute) return null;
|
||||||
const template: any[] = await routeCoachTemplatesApi.get(selectedRoute);
|
const template: any[] = await routeCoachTemplatesApi.get(selectedRoute);
|
||||||
if (!template?.length) return [];
|
if (template?.length) {
|
||||||
const fullCoaches = await Promise.all(
|
const fullCoaches = await Promise.all(
|
||||||
template.map((entry: any) => fleetApi.getCoach(entry.coachId ?? entry.coach?.id))
|
template.map((entry: any) => fleetApi.getCoach(entry.coachId ?? entry.coach?.id))
|
||||||
);
|
);
|
||||||
@@ -62,6 +62,19 @@ export default function SeatsPage() {
|
|||||||
positionNumber: template[i].positionNumber,
|
positionNumber: template[i].positionNumber,
|
||||||
seatArrangement: coach.arrangement,
|
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 ?? coach.coachNumber,
|
||||||
|
positionNumber: coach.positionNumber ?? i + 1,
|
||||||
|
seatArrangement: coach.arrangement ?? coach.seatArrangement,
|
||||||
|
}));
|
||||||
},
|
},
|
||||||
enabled: !!selectedRoute,
|
enabled: !!selectedRoute,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -237,16 +237,6 @@ export default function TariffRatesPage() {
|
|||||||
</ActionButton>
|
</ActionButton>
|
||||||
</div>
|
</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="card">
|
||||||
<div className="relative mb-4">
|
<div className="relative mb-4">
|
||||||
|
|||||||
@@ -884,11 +884,11 @@ export default function TicketsPage() {
|
|||||||
})()}
|
})()}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* Excess Baggage Modal */}
|
{/* Excess Luggage Modal */}
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={excessModalOpen}
|
isOpen={excessModalOpen}
|
||||||
onClose={() => { setExcessModalOpen(false); setExcessTicket(null); setExcessResult(null); }}
|
onClose={() => { setExcessModalOpen(false); setExcessTicket(null); setExcessResult(null); }}
|
||||||
title="Log Excess Baggage"
|
title="Log Excess Luggage"
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
{excessResult ? (
|
{excessResult ? (
|
||||||
|
|||||||
@@ -444,7 +444,7 @@ export const packageInquiriesApi = {
|
|||||||
remove: (id: string) => apiClient.delete(`/packages/inquiries/${id}`),
|
remove: (id: string) => apiClient.delete(`/packages/inquiries/${id}`),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Excess Baggage API
|
// Excess Luggage API
|
||||||
export const excessBaggageApi = {
|
export const excessBaggageApi = {
|
||||||
logCharge: (data: any) => apiClient.post<any>('/agents/excess-baggage', data),
|
logCharge: (data: any) => apiClient.post<any>('/agents/excess-baggage', data),
|
||||||
getCharge: (id: string) => apiClient.get<any>(`/agents/excess-baggage/${id}`),
|
getCharge: (id: string) => apiClient.get<any>(`/agents/excess-baggage/${id}`),
|
||||||
|
|||||||
@@ -26,6 +26,20 @@ import {
|
|||||||
import { useEffect, useRef, useState, useCallback } from "react";
|
import { useEffect, useRef, useState, useCallback } from "react";
|
||||||
import ModernDatePicker from "@/components/ModernDatePicker";
|
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
|
const searchSchema = z
|
||||||
.object({
|
.object({
|
||||||
tripType: z.enum(["ONE_WAY", "ROUND_TRIP"]),
|
tripType: z.enum(["ONE_WAY", "ROUND_TRIP"]),
|
||||||
@@ -535,6 +549,7 @@ export default function SearchPage() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { user, isAuthenticated } = useAuthStore();
|
const { user, isAuthenticated } = useAuthStore();
|
||||||
|
|
||||||
|
const dark = useDarkMode();
|
||||||
const [passengerModalOpen, setPassengerModalOpen] = useState(false);
|
const [passengerModalOpen, setPassengerModalOpen] = useState(false);
|
||||||
const [promoVisible, setPromoVisible] = useState(false);
|
const [promoVisible, setPromoVisible] = useState(false);
|
||||||
const [promoCode, setPromoCode] = useState("");
|
const [promoCode, setPromoCode] = useState("");
|
||||||
@@ -922,12 +937,14 @@ export default function SearchPage() {
|
|||||||
? "border-red-400"
|
? "border-red-400"
|
||||||
: originId
|
: originId
|
||||||
? "border-primary bg-primary/5"
|
? "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" />
|
<MapPin className="w-4 h-4 text-primary flex-shrink-0" />
|
||||||
<span
|
<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"}
|
{originStation?.name ?? "Select departure"}
|
||||||
</span>
|
</span>
|
||||||
@@ -969,12 +986,13 @@ export default function SearchPage() {
|
|||||||
? "border-red-400"
|
? "border-red-400"
|
||||||
: destId
|
: destId
|
||||||
? "border-primary bg-primary/5"
|
? "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" />
|
<MapPin className="w-4 h-4 text-primary flex-shrink-0" />
|
||||||
<span
|
<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"}
|
{destStation?.name ?? "Select destination"}
|
||||||
</span>
|
</span>
|
||||||
@@ -1042,11 +1060,11 @@ export default function SearchPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPassengerModalOpen(true)}
|
onClick={() => setPassengerModalOpen(true)}
|
||||||
className={`w-full flex items-center justify-between px-3.5 py-3 border-2 rounded-xl bg-white ${
|
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"
|
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" />
|
<Users className="w-4 h-4 text-primary" />
|
||||||
{totalPassengers} Pax
|
{totalPassengers} Pax
|
||||||
{nationalityFlag(watch("nationality"))
|
{nationalityFlag(watch("nationality"))
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { getTranslation, Language, useLanguage } from '@/lib/i18n';
|
|
||||||
import Link from 'next/link';
|
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 {
|
interface FAQItem {
|
||||||
question: string;
|
question: string;
|
||||||
@@ -11,414 +20,389 @@ interface FAQItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface FAQCategory {
|
interface FAQCategory {
|
||||||
|
icon: React.ReactNode;
|
||||||
title: string;
|
title: string;
|
||||||
items: FAQItem[];
|
items: FAQItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = `
|
const FAQ_CATEGORIES: FAQCategory[] = [
|
||||||
.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'),
|
icon: <Ticket className="w-5 h-5" />,
|
||||||
|
title: 'Booking',
|
||||||
items: [
|
items: [
|
||||||
{ question: t('help.how'), answer: t('help.howAnswer') },
|
{
|
||||||
{ question: t('help.modify'), answer: t('help.modifyAnswer') },
|
question: 'How do I book a ticket?',
|
||||||
{ question: t('help.cancel'), answer: t('help.cancelAnswer') },
|
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.',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('help.paymentFaq'),
|
icon: <Users className="w-5 h-5" />,
|
||||||
|
title: 'Passengers & Age-Based Pricing',
|
||||||
items: [
|
items: [
|
||||||
{ question: t('help.payMethods'), answer: t('help.payMethodsAnswer') },
|
{
|
||||||
{ question: t('help.refund'), answer: t('help.refundAnswer') },
|
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.',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('help.other'),
|
icon: <ShieldCheck className="w-5 h-5" />,
|
||||||
|
title: 'Identity Verification (Verifayda)',
|
||||||
items: [
|
items: [
|
||||||
{ question: t('help.docs'), answer: t('help.docsAnswer') },
|
{
|
||||||
|
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 5–7 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.',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const toggleFAQ = (index: number) => {
|
export default function HelpPage() {
|
||||||
if (openIndexes.includes(index)) {
|
const [openKeys, setOpenKeys] = useState<Set<string>>(new Set());
|
||||||
setOpenIndexes(openIndexes.filter(i => i !== index));
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
} 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 })[] = [];
|
const query = searchTerm.trim().toLowerCase();
|
||||||
faqCategories.forEach((cat) => {
|
const searchResults: { catTitle: string; item: FAQItem; key: string }[] = [];
|
||||||
cat.items.forEach((item) => {
|
if (query) {
|
||||||
flatFAQs.push({ ...item, id: flatFAQs.length });
|
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 (
|
return (
|
||||||
<>
|
<main className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||||
<style>{styles}</style>
|
{/* Hero */}
|
||||||
<main>
|
<section className="bg-gradient-to-br from-primary/90 to-primary/60 py-14 px-4 text-center">
|
||||||
<section className="help-hero">
|
<h1 className="text-3xl md:text-4xl font-bold text-white mb-3">Help & FAQs</h1>
|
||||||
<h1>{t('help.title')}</h1>
|
<p className="text-white/80 text-base max-w-xl mx-auto">
|
||||||
<p>{t('help.subtitle')}</p>
|
Find answers about booking, pricing, payments, and more.
|
||||||
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="search-section">
|
{/* Search */}
|
||||||
<div className="search-box">
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search FAQs..."
|
placeholder="Search FAQs…"
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
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"
|
||||||
/>
|
/>
|
||||||
<Search className="search-icon" size={20} />
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="faq-section">
|
{/* Content */}
|
||||||
<div className="faq-container">
|
<section className="px-4 py-10 max-w-3xl mx-auto">
|
||||||
{searchTerm ? (
|
{query ? (
|
||||||
<>
|
searchResults.length > 0 ? (
|
||||||
{filteredFAQs.length > 0 ? (
|
<div className="space-y-3">
|
||||||
filteredFAQs.map((faq) => (
|
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||||
<div key={faq.id} className="faq-item">
|
{searchResults.length} result{searchResults.length !== 1 ? 's' : ''} for “{searchTerm}”
|
||||||
<div className="faq-question">
|
</p>
|
||||||
<span>{faq.question}</span>
|
{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>
|
||||||
<div className="faq-answer">{faq.answer}</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : (
|
) : (
|
||||||
<div className="no-results">
|
<div className="text-center py-16 text-gray-400 dark:text-gray-500">
|
||||||
No FAQs found for "{searchTerm}"
|
<Search className="w-10 h-10 mx-auto mb-3 opacity-40" />
|
||||||
|
<p className="text-sm">No results for “{searchTerm}”</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
faqCategories.map((category, catIdx) => (
|
<div className="space-y-10">
|
||||||
<div key={catIdx} className="faq-category">
|
{FAQ_CATEGORIES.map((cat) => (
|
||||||
<h2>{category.title}</h2>
|
<div key={cat.title}>
|
||||||
{category.items.map((item, itemIdx) => {
|
<div className="flex items-center gap-2 mb-4">
|
||||||
const globalIdx = catIdx * 100 + itemIdx;
|
<span className="text-primary">{cat.icon}</span>
|
||||||
const isOpen = openIndexes.includes(globalIdx);
|
<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 (
|
return (
|
||||||
<div key={itemIdx} className="faq-item">
|
<FAQRow
|
||||||
<button
|
key={key}
|
||||||
className="faq-question"
|
question={item.question}
|
||||||
onClick={() => toggleFAQ(globalIdx)}
|
answer={item.answer}
|
||||||
>
|
isOpen={openKeys.has(key)}
|
||||||
<span>{item.question}</span>
|
onToggle={() => toggle(key)}
|
||||||
<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>
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="help-section">
|
{/* Contact CTA */}
|
||||||
<div className="help-card">
|
<section className="px-4 pb-16">
|
||||||
<div className="help-icon">
|
<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">
|
||||||
<MessageCircle size={32} color="white" />
|
<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>
|
</div>
|
||||||
<h2>{t('help.help')}</h2>
|
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-2">Still need help?</h2>
|
||||||
<p>{t('help.contact')}</p>
|
<p className="text-gray-500 dark:text-gray-400 text-sm mb-6">
|
||||||
<Link href="/contact" className="button-primary">Contact Support</Link>
|
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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Mail, Phone, MapPin } from 'lucide-react';
|
import { Mail, Phone, MapPin } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
|
||||||
import { useLanguage, getTranslation, Language } from '@/lib/i18n';
|
import { useLanguage, getTranslation, Language } from '@/lib/i18n';
|
||||||
import { useEffect, useState } from 'react';
|
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">
|
<footer className="bg-[rgb(20_113_76)] dark:bg-gray-900 text-white py-12">
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4">
|
||||||
<div className="max-w-6xl mx-auto">
|
<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 */}
|
{/* Company Info */}
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-semibold mb-2">
|
<h4 className="font-semibold mb-2">Ethio-Djibouti Railway</h4>
|
||||||
Ethio-Djibouti Railway
|
<p className="text-sm text-gray-100 my-3">Connecting East Africa with train travel.</p>
|
||||||
</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="space-y-2 text-sm">
|
||||||
<div className="flex items-center gap-2 text-gray-100">
|
<div className="flex items-center gap-2 text-gray-100">
|
||||||
<Phone className="w-4 h-4" />
|
<Phone className="w-4 h-4 flex-shrink-0" />
|
||||||
<a href="tel:9546" className="hover:text-gray-200 transition">
|
<a href="tel:9546" className="hover:text-gray-200 transition">9546</a>
|
||||||
9546
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-gray-100">
|
<div className="flex items-center gap-2 text-gray-100">
|
||||||
<Mail className="w-4 h-4" />
|
<Mail className="w-4 h-4 flex-shrink-0" />
|
||||||
<a href="mailto:edr_@edrsc.com" className="hover:text-gray-200 transition">
|
<a href="mailto:edr_@edrsc.com" className="hover:text-gray-200 transition">edr_@edrsc.com</a>
|
||||||
edr_@edrsc.com
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-gray-100">
|
<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>
|
<span>Furi, Sheger City</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 */}
|
{/* Social Media */}
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-semibold mb-4">{t('footer.follow')}</h4>
|
<h4 className="font-semibold mb-4">{t('footer.follow')}</h4>
|
||||||
@@ -118,21 +61,10 @@ export function Footer() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Divider */}
|
<div className="border-t border-white border-opacity-20 dark:border-gray-700 pt-6">
|
||||||
<div className="border-t border-white border-opacity-20 dark:border-gray-700 pt-8">
|
<p className="text-sm text-gray-100 text-center md:text-left">
|
||||||
<div className="flex flex-col md:flex-row justify-between items-center text-sm text-gray-100">
|
© {new Date().getFullYear()} Ethio-Djibouti Railway. {t('footer.rights')}
|
||||||
<p>
|
|
||||||
© 2024 Ethio-Djibouti Railway. {t('footer.rights')}
|
|
||||||
</p>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -277,15 +277,15 @@ function FeaturedCard({ pkg }: { pkg: HolidayPackage }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom: price + CTA */}
|
{/* 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 ? (
|
{price ? (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[10px] text-gray-400 uppercase tracking-wider font-medium">
|
<p className="text-[10px] text-gray-400 uppercase tracking-wider font-medium">
|
||||||
Starting from
|
Starting from
|
||||||
</p>
|
</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}{" "}
|
{price.currency}{" "}
|
||||||
<span className="text-2xl">
|
<span className="text-xl">
|
||||||
{price.amount.toLocaleString(undefined, {
|
{price.amount.toLocaleString(undefined, {
|
||||||
minimumFractionDigits: 2,
|
minimumFractionDigits: 2,
|
||||||
maximumFractionDigits: 2,
|
maximumFractionDigits: 2,
|
||||||
@@ -300,7 +300,7 @@ function FeaturedCard({ pkg }: { pkg: HolidayPackage }) {
|
|||||||
) : (
|
) : (
|
||||||
<div />
|
<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
|
View Package
|
||||||
<ArrowRight className="w-4 h-4" />
|
<ArrowRight className="w-4 h-4" />
|
||||||
</span>
|
</span>
|
||||||
@@ -384,20 +384,20 @@ function PackageCard({ pkg }: { pkg: HolidayPackage }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Price + CTA */}
|
{/* 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 ? (
|
{price ? (
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<p className="text-[10px] text-gray-400 uppercase tracking-wide">
|
<p className="text-[10px] text-gray-400 uppercase tracking-wide">
|
||||||
From
|
From
|
||||||
</p>
|
</p>
|
||||||
<p className="text-base font-extrabold text-primary">
|
<p className="text-sm font-extrabold text-primary truncate">
|
||||||
{fmtPrice(price.amount * 100, price.currency)}
|
{fmtPrice(price.amount * 100, price.currency)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<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" />
|
View <ArrowRight className="w-3.5 h-3.5" />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,8 +8,22 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { useBookingStore } from '@/lib/booking-store';
|
import { useBookingStore } from '@/lib/booking-store';
|
||||||
import { Station } from '@/types';
|
import { Station } from '@/types';
|
||||||
import { MapPin, Users, Search, Plus, Minus, ChevronDown } from 'lucide-react';
|
import { MapPin, Users, Search, Plus, Minus, ChevronDown, Globe } from 'lucide-react';
|
||||||
import { useState } from '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';
|
import ModernDatePicker from '@/components/ModernDatePicker';
|
||||||
|
|
||||||
const searchSchema = z.object({
|
const searchSchema = z.object({
|
||||||
@@ -36,18 +50,104 @@ interface SearchWidgetProps {
|
|||||||
onSearch?: () => void;
|
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) {
|
export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const setSearchCriteria = useBookingStore((s) => s.setSearchCriteria);
|
const setSearchCriteria = useBookingStore((s) => s.setSearchCriteria);
|
||||||
const [isPassengerOpen, setIsPassengerOpen] = useState(false);
|
const [isPassengerOpen, setIsPassengerOpen] = useState(false);
|
||||||
|
const dark = useDarkMode();
|
||||||
|
|
||||||
const { data: stations, isLoading } = useQuery<Station[]>({
|
const { data: stations, isLoading } = useQuery<Station[]>({
|
||||||
queryKey: ['stations'],
|
queryKey: ['stations'],
|
||||||
queryFn: async () => await apiClient.get('/stations') as Station[],
|
queryFn: async () => await apiClient.get('/stations') as Station[],
|
||||||
});
|
});
|
||||||
|
|
||||||
const { register, handleSubmit, watch, setValue, clearErrors, formState: { errors } } = useForm<SearchForm>({
|
const { handleSubmit, watch, setValue, clearErrors, formState: { errors } } = useForm<SearchForm>({
|
||||||
// @ts-ignore - ZodEffects type compatibility issue
|
// @ts-ignore
|
||||||
resolver: zodResolver(searchSchema),
|
resolver: zodResolver(searchSchema),
|
||||||
mode: 'onSubmit',
|
mode: 'onSubmit',
|
||||||
reValidateMode: 'onChange',
|
reValidateMode: 'onChange',
|
||||||
@@ -63,16 +163,16 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
|
|||||||
});
|
});
|
||||||
|
|
||||||
const originId = watch('originStationId');
|
const originId = watch('originStationId');
|
||||||
|
const destinationId = watch('destinationStationId');
|
||||||
|
const nationality = watch('nationality');
|
||||||
const adultCount = watch('adultCount');
|
const adultCount = watch('adultCount');
|
||||||
const childCount = watch('childCount');
|
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) => {
|
const onSubmit = (data: SearchForm) => {
|
||||||
setSearchCriteria({
|
setSearchCriteria({ ...data });
|
||||||
...data,
|
|
||||||
adultCount: data.adultCount,
|
|
||||||
childCount: data.childCount,
|
|
||||||
nationality: data.nationality,
|
|
||||||
});
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
origin: data.originStationId,
|
origin: data.originStationId,
|
||||||
destination: data.destinationStationId,
|
destination: data.destinationStationId,
|
||||||
@@ -89,173 +189,115 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
|
|||||||
<div className={fullWidth ? 'w-full' : 'w-full max-w-6xl mx-auto'}>
|
<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">
|
<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">
|
<div className="p-6 md:p-8 overflow-visible">
|
||||||
|
|
||||||
{/* Row 1: From, To, Date */}
|
{/* Row 1: From, To, Date */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end mb-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end mb-4">
|
||||||
{/* From */}
|
{/* From */}
|
||||||
<div className="space-y-2 md:col-span-1">
|
<div className="space-y-2">
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 text-left">From</label>
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">From</label>
|
||||||
<div className="relative">
|
<CustomSelect
|
||||||
<MapPin className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-primary" />
|
value={originId}
|
||||||
<select
|
onChange={(val) => { setValue('originStationId', val); clearErrors('originStationId'); }}
|
||||||
{...register('originStationId', {
|
options={stationOptions}
|
||||||
onChange: (e) => {
|
placeholder="Select departure"
|
||||||
if (e.target.value) clearErrors('originStationId');
|
icon={<MapPin className="w-5 h-5 text-primary" />}
|
||||||
}
|
error={!!errors.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}
|
disabled={isLoading}
|
||||||
>
|
/>
|
||||||
<option value="">Select departure</option>
|
|
||||||
{stations?.map((s) => (
|
|
||||||
<option key={s.id} value={s.id}>{s.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
{errors.originStationId && (
|
{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>
|
</div>
|
||||||
|
|
||||||
{/* To */}
|
{/* To */}
|
||||||
<div className="space-y-2 md:col-span-1">
|
<div className="space-y-2">
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 text-left">To</label>
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">To</label>
|
||||||
<div className="relative">
|
<CustomSelect
|
||||||
<MapPin className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-primary" />
|
value={destinationId}
|
||||||
<select
|
onChange={(val) => { setValue('destinationStationId', val); clearErrors('destinationStationId'); }}
|
||||||
{...register('destinationStationId', {
|
options={destinationOptions}
|
||||||
onChange: (e) => {
|
placeholder="Select arrival"
|
||||||
if (e.target.value) clearErrors('destinationStationId');
|
icon={<MapPin className="w-5 h-5 text-primary" />}
|
||||||
}
|
error={!!errors.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}
|
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 && (
|
{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>
|
</div>
|
||||||
|
|
||||||
{/* Date */}
|
{/* Date */}
|
||||||
<div className="space-y-2 md:col-span-1">
|
<div className="space-y-2">
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 text-left">Date</label>
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Date</label>
|
||||||
<ModernDatePicker
|
<ModernDatePicker
|
||||||
value={watch('departureDate') ? new Date(watch('departureDate') + 'T00:00:00') : undefined}
|
value={watch('departureDate') ? new Date(watch('departureDate') + 'T00:00:00') : undefined}
|
||||||
onChange={(date) => {
|
onChange={(date) => {
|
||||||
const year = date.getFullYear();
|
const y = date.getFullYear();
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
const day = String(date.getDate()).padStart(2, '0');
|
const d = String(date.getDate()).padStart(2, '0');
|
||||||
setValue('departureDate', `${year}-${month}-${day}`);
|
setValue('departureDate', `${y}-${m}-${d}`);
|
||||||
clearErrors('departureDate');
|
clearErrors('departureDate');
|
||||||
}}
|
}}
|
||||||
minDate={new Date()}
|
minDate={new Date()}
|
||||||
placeholder="Select date"
|
placeholder="Select date"
|
||||||
/>
|
/>
|
||||||
{errors.departureDate && (
|
{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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 2: Passengers, Nationality, Promo Code */}
|
{/* Row 2: Passengers, Nationality, Promo Code */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end mb-4">
|
<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">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsPassengerOpen(!isPassengerOpen)}
|
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" />
|
<Users className="w-4 h-4 text-primary" />
|
||||||
{(adultCount || 1) + (childCount || 0)} Passenger{((adultCount || 1) + (childCount || 0)) !== 1 ? 's' : ''}
|
{(adultCount || 1) + (childCount || 0)} Passenger{((adultCount || 1) + (childCount || 0)) !== 1 ? 's' : ''}
|
||||||
</span>
|
</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>
|
</button>
|
||||||
|
|
||||||
{/* Passenger Dropdown Menu */}
|
|
||||||
{isPassengerOpen && (
|
{isPassengerOpen && (
|
||||||
<>
|
<>
|
||||||
<div className="fixed inset-0 z-40" onClick={() => setIsPassengerOpen(false)} />
|
<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">
|
<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">
|
||||||
{/* 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 className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">Children</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 • First free</div>
|
<div className="text-xs text-gray-500 dark:text-gray-400">≥5 years</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<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">
|
||||||
type="button"
|
<Minus className="w-3.5 h-3.5 text-primary" />
|
||||||
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>
|
</button>
|
||||||
<span className="w-6 text-center font-semibold text-gray-900 dark:text-gray-100">{childCount || 0}</span>
|
<span className="w-5 text-center font-semibold text-gray-900 dark:text-white">{adultCount || 1}</span>
|
||||||
<button
|
<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">
|
||||||
type="button"
|
<Plus className="w-3.5 h-3.5 text-primary" />
|
||||||
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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"><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>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -264,50 +306,36 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
|
|||||||
|
|
||||||
{/* Nationality */}
|
{/* Nationality */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 text-left">Nationality</label>
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Nationality</label>
|
||||||
<select
|
<CustomSelect
|
||||||
{...register('nationality')}
|
value={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"
|
onChange={(val) => setValue('nationality', val as SearchForm['nationality'])}
|
||||||
>
|
options={NATIONALITIES.map((n) => ({ value: n.value, label: n.label }))}
|
||||||
<option value="ETHIOPIAN">Ethiopian</option>
|
placeholder="Select nationality"
|
||||||
<option value="DJIBOUTIAN">Djiboutian</option>
|
icon={<Globe className="w-5 h-5 text-primary" />}
|
||||||
<option value="OTHER">Other</option>
|
/>
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Promo Code */}
|
{/* Promo Code */}
|
||||||
<div className="space-y-2">
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Enter promo code"
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 3: Search Button */}
|
{/* 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
|
<button
|
||||||
type="submit"
|
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"
|
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" />
|
<Search className="w-5 h-5" />
|
||||||
<span>Search Train</span>
|
<span>Search Train</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user